Skip to content

File I/O and preferences

Almost every useful desktop application reads and writes files. It also needs to remember user preferences between sessions — window size and position, recently opened files, UI settings. This lesson covers both, building a simple text editor step by step using the multi-file structure from lesson 3.2.

File structure

file_io_app/
├── main.rb
└── lib/
    ├── app_frame.rb
    ├── models/
    │   └── document.rb
    └── panels/
        └── editor_panel.rb

Start fresh

Create main.rb:

1
2
3
4
5
# main.rb
require 'wx'
require_relative 'lib/app_frame'

Wx::App.run { AppFrame.new.show }

Create lib/app_frame.rb with the full menu in place from the start. On macOS, Wx::ID_EXIT is automatically relocated to the application menu — starting with only Exit in the File menu leaves it empty and produces a blank bubble on macOS. Having the full menu present from the beginning avoids this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# lib/app_frame.rb

class AppFrame < Wx::Frame
  def initialize
    super(nil, title: 'Editor', size: [700, 500])

    @loading = false

    build_menu
    build_ui
    bind_events

    layout
    centre
  end

  private

  def build_menu
    menu_bar  = Wx::MenuBar.new
    file_menu = Wx::Menu.new
    file_menu.append(Wx::ID_NEW,    "&New\tCtrl+N")
    file_menu.append(Wx::ID_OPEN,   "&Open...\tCtrl+O")
    file_menu.append(Wx::ID_SAVE,   "&Save\tCtrl+S")
    file_menu.append(Wx::ID_SAVEAS, "Save &As...\tCtrl+Shift+S")
    file_menu.append_separator
    file_menu.append(Wx::ID_EXIT,   "E&xit\tCtrl+Q")
    menu_bar.append(file_menu, "&File")
    set_menu_bar(menu_bar)
  end

  def build_ui
    @panel = Wx::Panel.new(self)

    sizer = Wx::VBoxSizer.new
    @panel.set_sizer(sizer)
  end

  def bind_events
    evt_close             { |event| on_close(event) }
    evt_menu(Wx::ID_EXIT) { close }
  end

  def on_close(event)
    event.skip
  end
end

Run it. A window with a complete File menu. New, Open, Save, and Save As are visible but do nothing yet — we add their handlers as we go.

Step 1 — The Document model

The document model holds all state — file path, content, and modification status. The frame and panel know nothing about these details; they ask the model.

Create lib/models/document.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# lib/models/document.rb

class Document
  attr_reader :path, :content, :modified

  def initialize
    reset
  end

  def reset
    @path     = nil
    @content  = ''
    @modified = false
  end

  def load(path)
    @content  = File.read(path)
    @path     = path
    @modified = false
  end

  def save
    raise 'No path set' unless @path
    File.write(@path, @content)
    @modified = false
  end

  def save_as(path)
    @path = path
    save
  end

  def update_content(text)
    return if text == @content
    @content  = text
    @modified = true
  end

  def title
    name = @path ? File.basename(@path) : 'Untitled'
    @modified ? "#{name} *" : name
  end
end

Add it to app_frame.rb — require it and create an instance in initialize:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# lib/app_frame.rb

require_relative 'models/document'

class AppFrame < Wx::Frame
  def initialize
    super(nil, title: 'Editor', size: [700, 500])

    @document = Document.new
    @loading  = false

    build_menu
    build_ui
    bind_events

    update_title
    layout
    centre
  end

  ...

  def update_title
    self.title = "#{@document.title} — Editor"
  end
end

Run it. The title bar shows “Untitled — Editor”.

Step 2 — The editor panel

The editor panel owns the TextCtrl widget and provides a clean interface to it. It notifies the frame when the user types via a callback — but crucially, the notification comes from evt_key_up registered directly on the editor widget, not from evt_text. This is important: setting the editor content programmatically (when loading a file) fires evt_text but does not fire evt_key_up, so there is no false dirty notification.

Create lib/panels/editor_panel.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# lib/panels/editor_panel.rb

class EditorPanel < Wx::Panel
  def initialize(parent, on_change: nil)
    super(parent)
    @on_change = on_change
    build_ui
  end

  def content
    @editor.value
  end

  def content=(text)
    @editor.value = text
  end

  def clear
    @editor.clear
  end

  private

  def build_ui
    @editor = Wx::TextCtrl.new(self, value: '',
                                style: Wx::TE_MULTILINE | Wx::HSCROLL)

    sizer = Wx::VBoxSizer.new
    sizer.add(@editor, 1, Wx::EXPAND | Wx::ALL, 4)
    set_sizer(sizer)

    # Register directly on the editor widget — keyboard events go to the
    # focused widget, not to the parent panel
    @editor.evt_key_up do |event|
      @on_change&.call
      event.skip
    end
  end
end

Add it to app_frame.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
require_relative 'models/document'
require_relative 'panels/editor_panel'

class AppFrame < Wx::Frame
  def initialize
    super(nil, title: 'Editor', size: [700, 500])

    @document = Document.new
    @loading  = false

    build_menu
    build_ui
    bind_events

    update_title
    layout
    centre
  end

  ...

  def build_ui
    @panel  = Wx::Panel.new(self)
    @editor = EditorPanel.new(@panel, on_change: method(:on_text_changed))

    create_status_bar
    set_status_text('Ready')

    sizer = Wx::VBoxSizer.new
    sizer.add(@editor, 1, Wx::EXPAND | Wx::ALL, 4)
    @panel.set_sizer(sizer)
  end

  def on_text_changed
    @document.update_content(@editor.content)
    update_title
  end
end

Run it. Type anything — the title bar gains a * indicating unsaved changes.

Step 3 — File operations and dirty state

Add the file operation handlers. Update bind_events:

1
2
3
4
5
6
7
8
def bind_events
  evt_close              { |event| on_close(event) }
  evt_menu(Wx::ID_NEW)   { on_new }
  evt_menu(Wx::ID_OPEN)  { on_open }
  evt_menu(Wx::ID_SAVE)  { on_save }
  evt_menu(Wx::ID_SAVEAS){ on_save_as }
  evt_menu(Wx::ID_EXIT)  { close }
end

Update on_close and add the handler methods:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def on_close(event)
  if @document.modified && !confirm_discard
    # user chose not to discard — do not close
  else
    event.skip
  end
end

def on_new
  return unless confirm_discard
  @loading = true
  @editor.clear
  @loading = false
  @document.reset
  update_title
  set_status_text('New document')
end

def on_open
  return unless confirm_discard
  dialog = Wx::FileDialog.new(
    self, 'Open file', '', '',
    'Text files (*.txt)|*.txt|All files (*.*)|*.*',
    Wx::FD_OPEN | Wx::FD_FILE_MUST_EXIST
  )
  if dialog.show_modal == Wx::ID_OK
    load_file(dialog.path)
  end
  dialog.destroy
end

def on_save
  if @document.path
    save_file(@document.path)
  else
    on_save_as
  end
end

def on_save_as
  dialog = Wx::FileDialog.new(
    self, 'Save file',
    @document.path ? File.dirname(@document.path) : '',
    @document.path ? File.basename(@document.path) : 'untitled.txt',
    'Text files (*.txt)|*.txt|All files (*.*)|*.*',
    Wx::FD_SAVE | Wx::FD_OVERWRITE_PROMPT
  )
  if dialog.show_modal == Wx::ID_OK
    save_file(dialog.path)
  end
  dialog.destroy
end

def load_file(path)
  @loading = true
  @document.load(path)
  @editor.content = @document.content
  @loading = false
  update_title
  set_status_text("Opened: #{File.basename(path)}")
rescue => e
  @loading = false
  Wx::message_box("Could not open file:\n#{e.message}", 'Error',
                  Wx::OK | Wx::ICON_ERROR)
end

def save_file(path)
  @document.update_content(@editor.content)
  @document.save_as(path)
  update_title
  set_status_text("Saved: #{File.basename(path)}")
rescue => e
  Wx::message_box("Could not save file:\n#{e.message}", 'Error',
                  Wx::OK | Wx::ICON_ERROR)
end

def confirm_discard
  return true unless @document.modified
  result = Wx::message_box(
    'You have unsaved changes. Discard them?',
    'Unsaved changes',
    Wx::YES_NO | Wx::ICON_QUESTION
  )
  result == Wx::YES
end

Run it. New, Open, Save, and Save As all work. Close with unsaved changes — the confirm dialog appears.

A few things worth noting:

The @loading flag. Setting the editor content programmatically — when loading a file or clearing for New — fires evt_key_up does not, but to be safe we set @loading = true during any programmatic content change and check it in on_text_changed. This is a standard pattern in wxWidgets applications for suppressing event handling during programmatic updates.

All IO is wrapped in rescue. An unhandled IO error in a desktop app crashes the whole application. Always rescue file operations and report the error to the user.

confirm_discard returns early when unmodified. return true unless @document.modified means file operations proceed without interrupting the user when there are no unsaved changes.

on_save delegates to on_save_as for new documents that have never been saved. This is the standard pattern — Save only needs its own path when one exists.

Step 4 — Persistent preferences with Wx::ConfigBase

Wx::ConfigBase provides platform-appropriate preference storage — the registry on Windows, config files on Linux, NSUserDefaults on macOS. The same code works on all platforms.

Add load_prefs and save_prefs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def load_prefs
  config = Wx::ConfigBase.get
  w = config.read('/window/width',  700).to_i
  h = config.read('/window/height', 500).to_i
  x = config.read('/window/x',       -1).to_i
  y = config.read('/window/y',       -1).to_i

  set_size(Wx::Size.new(w, h)) if w > 100 && h > 100
  move(x, y) if x >= 0 && y >= 0
end

def save_prefs
  config = Wx::ConfigBase.get
  rect   = get_rect
  config.write('/window/width',  rect.width)
  config.write('/window/height', rect.height)
  config.write('/window/x',      rect.x)
  config.write('/window/y',      rect.y)
  config.flush
end

Call load_prefs after layout and centre in initialize, and save_prefs in on_close before event.skip:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def initialize
  ...
  layout
  centre
  load_prefs    # after layout and centre
end

def on_close(event)
  if @document.modified && !confirm_discard
    # do not close
  else
    save_prefs
    event.skip
  end
end

Run it. Resize and reposition the window, close and reopen — it restores exactly where you left it.

Wx::ConfigBase notes:

  • Use read and write — the API is simpler than the C++ docs suggest
  • read always returns a string — call .to_i when you expect an integer
  • Guard restored values: w > 100 && h > 100 prevents applying a corrupted size; x >= 0 && y >= 0 prevents positioning off-screen
  • config.flush writes to disk immediately — without it, a crash may lose saved prefs
  • Path strings use Unix-style hierarchy: '/window/width', '/editor/font_size'

What to take forward

  • The Document model holds all state — path, content, modified. The frame reads from it and delegates to it.
  • EditorPanel registers evt_key_up directly on the TextCtrl widget — keyboard events go to the focused widget, not its parent panel
  • Use @loading = true during programmatic content changes to suppress spurious change notifications
  • Wrap all file IO in rescue — never let an IO error crash the app
  • Wx::ConfigBase.read returns strings — always convert with .to_i as needed
  • Load prefs after layout and centre; save prefs in on_close before event.skip

Previous: Data-driven widgets | Next: Threading: keeping the UI responsive