Skip to content

Application structure

Every app in this series so far has lived in a single file. That works for tutorials, but real applications need more structure. As an app grows, a single file becomes hard to navigate, hard to test, and hard to reason about.

This lesson covers how to split a wxRuby3 app across multiple files, how to keep each class focused on one responsibility, and how to manage shared state without creating tangled dependencies.

The single-file problem

A typical single-file app grows like this: you add a feature, the frame class gets a new method. Then another. Then the frame is handling file I/O, business logic, UI layout, event handling, and data management all at once. Finding anything becomes difficult and changing one thing breaks another.

The solution is the same as in any Ruby application — separate concerns into separate classes, put those classes in separate files, and use require_relative to pull them together.

A standard file structure

For a wxRuby3 app of moderate complexity:

my_app/
├── main.rb              # Entry point only — Wx::App.run lives here
├── Gemfile
├── lib/
│   ├── app_frame.rb     # Main frame class
│   ├── panels/
│   │   ├── input_panel.rb
│   │   └── output_panel.rb
│   ├── dialogs/
│   │   └── preferences_dialog.rb
│   └── models/
│       ├── document.rb
│       └── settings.rb
└── assets/
    ├── icons/
    └── html/

main.rb does nothing but require and run:

1
2
3
4
require 'wx'
require_relative 'lib/app_frame'

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

Everything else lives under lib/. Each file contains one class.

Step 1 — A two-panel app

Create the following file structure:

hello_app/
├── main.rb
└── lib/
    ├── app_frame.rb
    └── panels/
        ├── input_panel.rb
        └── output_panel.rb

The input panel has a text field and a button. The output panel shows a greeting. We will connect them so that clicking the button in the input panel updates the output panel.

Start with main.rb:

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

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

Create lib/panels/input_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
# lib/panels/input_panel.rb

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

  private

  def build_ui
    label    = Wx::StaticText.new(self, label: 'Enter your name:')
    @name    = Wx::TextCtrl.new(self, value: '')
    greet_btn = Wx::Button.new(self, label: 'Greet')

    sizer = Wx::VBoxSizer.new
    sizer.add(label,     0, Wx::ALL, 8)
    sizer.add(@name,     0, Wx::EXPAND | Wx::LEFT | Wx::RIGHT, 8)
    sizer.add(greet_btn, 0, Wx::ALL, 8)
    set_sizer(sizer)

    evt_button(greet_btn.id) { @on_greet&.call(@name.value) }
  end
end

Create lib/panels/output_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
# lib/panels/output_panel.rb

class OutputPanel < Wx::Panel
  def initialize(parent)
    super(parent)
    build_ui
  end

  def show_greeting(name)
    @greeting.value = name.empty? ? '' : "Hello, #{name}!"
  end

  private

  def build_ui
    label     = Wx::StaticText.new(self, label: 'Greeting:')
    @greeting = Wx::TextCtrl.new(self, value: '',
                                  style: Wx::TE_READONLY | Wx::TE_CENTRE)

    sizer = Wx::VBoxSizer.new
    sizer.add(label,     0, Wx::ALL, 8)
    sizer.add(@greeting, 0, Wx::EXPAND | Wx::LEFT | Wx::RIGHT, 8)
    set_sizer(sizer)
  end
end

Create lib/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
39
40
41
42
43
44
45
# lib/app_frame.rb

require_relative 'panels/input_panel'
require_relative 'panels/output_panel'

class AppFrame < Wx::Frame
  def initialize
    super(nil, title: 'Hello App', size: [400, 200])

    build_ui
    bind_events

    layout
    centre
  end

  private

  def build_ui
    @panel   = Wx::Panel.new(self)
    splitter = Wx::SplitterWindow.new(@panel, style: Wx::SP_3DSASH | Wx::SP_LIVE_UPDATE)

    @output = OutputPanel.new(splitter)
    @input  = InputPanel.new(splitter, on_greet: method(:on_greet))

    splitter.split_vertically(@input, @output, 200)
    splitter.set_minimum_pane_size(150)

    sizer = Wx::VBoxSizer.new
    sizer.add(splitter, 1, Wx::EXPAND)
    @panel.set_sizer(sizer)
  end

  def bind_events
    evt_close { |event| on_close(event) }
  end

  def on_close(event)
    event.skip
  end

  def on_greet(name)
    @output.show_greeting(name)
  end
end

Run it with ruby main.rb. Type a name and click Greet — the output panel updates.

Notice what each class knows and does not know:

  • InputPanel knows about its text field and button. It does not know OutputPanel exists.
  • OutputPanel knows about its greeting display. It does not know InputPanel exists.
  • AppFrame knows both panels exist and connects them. It does not know the internal details of either.
  • main.rb knows only that AppFrame exists.

Each class has one job. This is the structure every Module 6 app follows.

Step 2 — Communication between panels

The hello app uses a callback to connect the panels. The frame passes a named method to InputPanel as a keyword argument:

1
@input = InputPanel.new(splitter, on_greet: method(:on_greet))

InputPanel stores it and calls it when the button is clicked:

1
evt_button(greet_btn.id) { @on_greet&.call(@name.value) }

The frame’s on_greet method receives the name and tells the output panel to update:

1
2
3
def on_greet(name)
  @output.show_greeting(name)
end

The input and output panels remain completely unaware of each other. All coordination happens in the frame.

Why not use a block? You might expect to write InputPanel.new(splitter) { |name| ... } — but wxRuby3 intercepts blocks passed to widget constructors for its own initialisation, yielding the panel object itself to your block. Always pass callbacks as explicit keyword arguments to avoid this.

There are two other patterns worth knowing:

Direct method call — the panel holds a reference to the frame and calls a method on it:

1
2
3
4
5
6
7
def initialize(parent, frame:)
  @frame = frame
  ...
end

# Panel calls:
@frame.on_greet(@name.value)

This works but creates a tight coupling — the panel needs to know the frame’s API. The keyword argument approach avoids this.

Custom events — the panel posts a custom event that the frame handles. This is the most decoupled approach and is covered in lesson 3.5. For straightforward inter-panel communication, keyword argument callbacks are simpler and clearer.

Step 3 — Model classes

Business logic and data belong in model classes, not in UI classes. A frame that also manages its own data is doing two jobs — split them.

The pattern is straightforward: create a model class that holds the data, and a frame that displays it. The model knows nothing about widgets. The frame knows nothing about where the data came from.

Create lib/models/document.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# lib/models/document.rb

class Document
  attr_reader :title, :author, :content

  def initialize
    @title   = 'Getting Started with wxRuby3'
    @author  = 'Tutorial Series'
    @content = <<~TEXT
        wxRuby3 is a Ruby binding for the wxWidgets C++ toolkit.
        It lets you build native desktop applications in Ruby,
        using real platform widgets on macOS, Windows, and Linux.

        This is a second paragraph showing that heredocs
        handle multiple lines cleanly.
    TEXT
  end
end

Create lib/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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# lib/app_frame.rb

require_relative 'models/document'

class AppFrame < Wx::Frame
  def initialize
    super(nil, title: 'Document Viewer', size: [500, 300])

    @document = Document.new

    build_ui
    bind_events

    layout
    centre
  end

  private

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

    title_label   = Wx::StaticText.new(@panel, label: 'Title:')
    @title_value  = Wx::StaticText.new(@panel, label: @document.title)

    author_label  = Wx::StaticText.new(@panel, label: 'Author:')
    @author_value = Wx::StaticText.new(@panel, label: @document.author)

    @content = Wx::TextCtrl.new(@panel, value: @document.content,
                                 style: Wx::TE_MULTILINE | Wx::TE_READONLY)

    grid = Wx::FlexGridSizer.new(0, 2, 8, 12)
    grid.add_growable_col(1)
    grid.add(title_label,   0, Wx::ALIGN_CENTER_VERTICAL)
    grid.add(@title_value,  1, Wx::EXPAND)
    grid.add(author_label,  0, Wx::ALIGN_CENTER_VERTICAL)
    grid.add(@author_value, 1, Wx::EXPAND)

    sizer = Wx::VBoxSizer.new
    sizer.add(grid,     0, Wx::EXPAND | Wx::ALL, 12)
    sizer.add(@content, 1, Wx::EXPAND | Wx::LEFT | Wx::RIGHT | Wx::BOTTOM, 12)
    @panel.set_sizer(sizer)
  end

  def bind_events
    evt_close { |event| on_close(event) }
  end

  def on_close(event)
    event.skip
  end
end

And main.rb:

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

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

Run it. The frame displays the document’s title, author, and content — but it does not know or care how Document stores that data. If Document later loads from a file or a database, the frame code does not change.

The key point: Document has no require 'wx', no widgets, no UI knowledge whatsoever. It is plain Ruby. The frame reads from it and displays what it finds. That separation is the pattern.

A more complete example

The city browser app shows these same patterns applied to a more realistic scenario — a sidebar list of cities connected to a detail panel, with Add and Remove functionality. Download it, run it, and read through the source to see how the structure scales.

Download city_app.zip

What to take forward

The structural principles used in every Module 6 app:

  • main.rb contains only require and Wx::App.run
  • Each class lives in its own file under lib/
  • Panels are separate classes inheriting from Wx::Panel
  • Data and business logic live in model classes with no knowledge of widgets
  • Inter-panel communication uses keyword argument callbacks
  • Never pass callbacks as blocks to widget constructors — wxRuby3 intercepts them

Previous: Event handling in depth | Next: Data-driven widgets