Skip to content

Layout with sizers

The form you built in the previous lesson works, but it looks rough. The widgets are positioned with hardcoded pos: coordinates — manual arithmetic that breaks the moment you resize the window, change a font, or add a new field. This is not how wxRuby3 apps are meant to be laid out.

Sizers are wxRuby3’s layout system. Rather than fixing pixel positions, sizers describe relationships between widgets — “these two controls should sit side by side”, “this widget should expand to fill available space”, “leave 8 pixels of padding around everything”. The sizer calculates the actual positions at runtime, and recalculates automatically whenever the window is resized.

In this lesson you will learn the two sizers you will reach for in almost every app, and then replace every pos: in your form with a proper sizer layout.

How sizers work

A sizer is an invisible layout container. You add widgets to it, tell it how each widget should behave, and attach it to a panel. The panel then delegates all size calculations to the sizer.

The basic pattern:

1
2
3
sizer = Wx::VBoxSizer.new                     # create the sizer
sizer.add(widget, proportion, flags, border)  # add widgets
panel.set_sizer(sizer)                        # attach to panel

Two calls complete the setup. panel.set_sizer attaches the sizer to the panel — without it the sizer exists but has no effect. Then layout called on the frame tells the frame to resize the panel to fill the window and trigger the sizer calculations. Without layout, the panel stays at its default size and widgets bunch up in the corner.

1
2
3
panel.set_sizer(sizer)
# ... in initialize, after build_ui:
layout

In the skeleton from lesson 2.1, layout belongs in initialize after build_ui is called.

VBoxSizer and HBoxSizer

Wx::VBoxSizer stacks widgets vertically, top to bottom. Wx::HBoxSizer arranges them horizontally, left to right. These two sizers, nested inside each other, can produce almost any layout you will need.

Create a new file sizer_demo.rb and type 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
require 'wx'

class SizerFrame < Wx::Frame
  def initialize
    super(nil, title: 'Sizer Demo', size: [400, 300])

    build_ui
    layout
    centre
  end

  private

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

    btn_a = Wx::Button.new(panel, label: 'Button A')
    btn_b = Wx::Button.new(panel, label: 'Button B')
    btn_c = Wx::Button.new(panel, label: 'Button C')

    vbox = Wx::VBoxSizer.new
    vbox.add(btn_a, 0, Wx::ALL, 8)
    vbox.add(btn_b, 0, Wx::ALL, 8)
    vbox.add(btn_c, 0, Wx::ALL, 8)

    panel.set_sizer(vbox)
  end
end

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

Run it. Three buttons stacked vertically, each with 8 pixels of space around them. Try resizing the window — the buttons stay at their natural size but the empty space adjusts. Now change VBoxSizer to HBoxSizer and run again — the same three buttons arranged in a row.

The add method

sizer.add(widget, proportion, flags, border) is the method you will type hundreds of times. Its four arguments control two distinct things — how much space a widget gets, and how it fills that space — and it is worth understanding them separately.

proportion — space along the main axis

In a VBoxSizer, the main axis is vertical. Proportion controls how much of the available vertical space each widget receives when the window is taller than the widgets need.

  • 0 — the widget takes only the height it needs; any leftover vertical space goes elsewhere
  • 1 — the widget gets a share of leftover vertical space
  • 2 — the widget gets twice as much leftover space as a widget with proportion 1

To see this clearly, update sizer_demo.rb — note there is no Wx::EXPAND here so we isolate proportion from width behaviour:

1
2
3
vbox.add(btn_a, 0, Wx::ALL, 8)
vbox.add(btn_b, 1, Wx::ALL, 8)
vbox.add(btn_c, 2, Wx::ALL, 8)

Run it and resize the window vertically. Button A stays at its natural height. Buttons B and C grow taller to fill the extra vertical space — C always gets twice as much as B. The buttons stay at their natural width because Wx::EXPAND is not in the flags.

Wx::EXPAND — filling perpendicular space

Wx::EXPAND is a flag, not a proportion argument. In a VBoxSizer it tells the widget to fill the full available width — perpendicular to the main axis. Without it, widgets sit at their natural width regardless of how wide the window is.

Update the demo to see the difference:

1
2
3
vbox.add(btn_a, 0, Wx::ALL,          8)   # natural width, fixed height
vbox.add(btn_b, 0, Wx::ALL | Wx::EXPAND, 8)   # full width, fixed height
vbox.add(btn_c, 1, Wx::ALL | Wx::EXPAND, 8)   # full width, grows vertically

Run it and resize in both directions. Button A stays small and fixed. Button B stretches to fill the window width but stays at natural height. Button C stretches in both directions — full width from Wx::EXPAND, growing height from proportion 1.

This is the combination you will use most in real forms: proportion 1 with Wx::EXPAND on input fields so they grow both ways with the window.

flags for borders and alignment

The remaining flags control which sides the border applies to, and how the widget aligns within its allocated space:

Flag Effect
Wx::ALL Border on all four sides
Wx::TOP, Wx::BOTTOM, Wx::LEFT, Wx::RIGHT Border on specific sides only
Wx::ALIGN_CENTER Centre the widget in its allocated space
Wx::ALIGN_CENTER_VERTICAL Centre vertically — useful in HBoxSizer rows
Wx::ALIGN_RIGHT Align to the right of allocated space

Flags are combined with |:

1
2
vbox.add(label,  0, Wx::ALIGN_CENTER_VERTICAL | Wx::RIGHT, 8)
vbox.add(field,  1, Wx::EXPAND | Wx::BOTTOM, 4)

border

The last argument is the number of pixels of padding to apply on whichever sides are specified by the flags. Wx::ALL, 8 means 8 pixels on every side. Wx::TOP | Wx::BOTTOM, 4 means 4 pixels top and bottom, none left or right.

FlexGridSizer

Wx::FlexGridSizer arranges widgets in a grid of rows and columns. It is the right tool for forms — the classic “label on the left, control on the right” layout.

1
grid = Wx::FlexGridSizer.new(rows, cols, vgap, hgap)
  • rows — number of rows. Use 0 to let the sizer calculate rows automatically from the number of items
  • cols — number of columns
  • vgap — vertical gap between rows in pixels
  • hgap — horizontal gap between columns in pixels

FlexGridSizer differs from the simpler GridSizer in one important way: columns (and rows) can have different widths. You can mark a column as growable, so it expands to fill available space while other columns stay fixed:

1
grid.add_growable_col(1)   # column 1 (the right column) expands

For a two-column form, this means the label column stays narrow and the input column stretches as the window widens.

Try it — replace sizer_demo.rb with 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
require 'wx'

class SizerFrame < Wx::Frame
  def initialize
    super(nil, title: 'Grid Demo', size: [400, 200])
    build_ui
    layout
    centre
  end

  private

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

    grid = Wx::FlexGridSizer.new(0, 2, 8, 12)
    grid.add_growable_col(1)

    [['Name:', ''], ['Email:', ''], ['City:', '']].each do |label_text, value|
      label = Wx::StaticText.new(panel, label: label_text)
      field = Wx::TextCtrl.new(panel, value: value)
      grid.add(label, 0, Wx::ALIGN_CENTER_VERTICAL)
      grid.add(field, 1, Wx::EXPAND)
    end

    outer = Wx::VBoxSizer.new
    outer.add(grid, 1, Wx::EXPAND | Wx::ALL, 16)
    panel.set_sizer(outer)
  end
end

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

Run it and resize the window. The label column holds its width; the input fields expand to fill the space. This is the pattern at the heart of every form in this series.

Notice the outer VBoxSizer wrapping the grid — this is the standard pattern. The FlexGridSizer handles the grid layout; an outer VBoxSizer adds the margin around it and positions it within the panel. Direct-to-panel without an outer sizer leaves no margin at the window edges.

Nested sizers

The real power of sizers comes from nesting them. An HBoxSizer inside a VBoxSizer row lets you put multiple widgets side by side within a vertically stacked layout — exactly what you need for the radio button rows in your form.

1
2
3
4
5
6
# A row containing a label and two buttons side by side
row = Wx::HBoxSizer.new
row.add(Wx::Button.new(panel, label: 'Yes'), 0, Wx::RIGHT, 8)
row.add(Wx::Button.new(panel, label: 'No'),  0)

vbox.add(row, 0, Wx::ALL, 8)

The vbox sees one item in that row — the HBoxSizer — and positions it like any other widget. Inside, the HBoxSizer arranges its two buttons horizontally.

Replacing pos: in your form

Now apply what you have learned. Open app.rb from the previous lesson. You are going to remove every pos: and size: argument and replace the whole layout with sizers.

The layout goal is a two-column form: labels on the left at fixed width, controls on the right expanding to fill space. Radio button rows need an inner HBoxSizer to arrange the buttons horizontally. The slider row needs an HBoxSizer to put the value label beside the slider.

The finished lesson app

Your complete app.rb with sizers replacing all absolute positioning:

  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
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
require 'wx'

class AppFrame < Wx::Frame
  def initialize
    super(nil, title: 'Widget Demo', size: [460, 340])

    build_ui
    bind_events

    layout
    centre
  end

  private

  def build_ui
    @panel = Wx::Panel.new(self)
    create_status_bar
    set_status_text('Ready')

    @name_label  = Wx::StaticText.new(@panel, label: 'Name:')
    @name_field  = Wx::TextCtrl.new(@panel, value: '')

    @email_label = Wx::StaticText.new(@panel, label: 'Email:')
    @email_field = Wx::TextCtrl.new(@panel, value: '')

    @subscribe_cb = Wx::CheckBox.new(@panel, label: 'Subscribe to newsletter')
    @subscribe_cb.value = true

    @size_buttons = [
      Wx::RadioButton.new(@panel, label: 'Small',  style: Wx::RB_GROUP),
      Wx::RadioButton.new(@panel, label: 'Medium'),
      Wx::RadioButton.new(@panel, label: 'Large'),
    ]
    @size_buttons[1].value = true

    @colour_buttons = [
      Wx::RadioButton.new(@panel, label: 'Red',   style: Wx::RB_GROUP),
      Wx::RadioButton.new(@panel, label: 'Green'),
      Wx::RadioButton.new(@panel, label: 'Blue'),
    ]
    @colour_buttons[0].value = true

    countries = ['Australia', 'Canada', 'New Zealand', 'United Kingdom', 'United States']
    @country = Wx::Choice.new(@panel, choices: countries)
    @country.selection = 0

    @age_slider      = Wx::Slider.new(@panel, value: 25, min_value: 18, max_value: 99)
    @age_value_label = Wx::StaticText.new(@panel, label: '25')

    @submit_btn = Wx::Button.new(@panel, label: 'Submit')
    @submit_btn.set_default

    grid = Wx::FlexGridSizer.new(0, 2, 10, 12)
    grid.add_growable_col(1)

    grid.add(@name_label,  0, Wx::ALIGN_CENTER_VERTICAL)
    grid.add(@name_field,  1, Wx::EXPAND)

    grid.add(@email_label, 0, Wx::ALIGN_CENTER_VERTICAL)
    grid.add(@email_field, 1, Wx::EXPAND)

    grid.add(Wx::StaticText.new(@panel, label: ''), 0)
    grid.add(@subscribe_cb, 0)

    size_row = Wx::HBoxSizer.new
    @size_buttons.each { |rb| size_row.add(rb, 0, Wx::RIGHT, 10) }
    grid.add(Wx::StaticText.new(@panel, label: 'Size:'),   0, Wx::ALIGN_CENTER_VERTICAL)
    grid.add(size_row, 0)

    colour_row = Wx::HBoxSizer.new
    @colour_buttons.each { |rb| colour_row.add(rb, 0, Wx::RIGHT, 10) }
    grid.add(Wx::StaticText.new(@panel, label: 'Colour:'), 0, Wx::ALIGN_CENTER_VERTICAL)
    grid.add(colour_row, 0)

    grid.add(Wx::StaticText.new(@panel, label: 'Country:'), 0, Wx::ALIGN_CENTER_VERTICAL)
    grid.add(@country, 1, Wx::EXPAND)

    age_row = Wx::HBoxSizer.new
    age_row.add(@age_slider,      1, Wx::EXPAND | Wx::RIGHT, 8)
    age_row.add(@age_value_label, 0, Wx::ALIGN_CENTER_VERTICAL)
    grid.add(Wx::StaticText.new(@panel, label: 'Age:'), 0, Wx::ALIGN_CENTER_VERTICAL)
    grid.add(age_row, 1, Wx::EXPAND)

    outer = Wx::VBoxSizer.new
    outer.add(grid,        1, Wx::EXPAND | Wx::ALL, 16)
    outer.add(@submit_btn, 0, Wx::ALIGN_RIGHT | Wx::RIGHT | Wx::BOTTOM, 16)

    @panel.set_sizer(outer)
    @panel.layout
  end

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

    evt_checkbox(@subscribe_cb.id) { |event| on_subscribe_changed(event) }

    @size_buttons.each   { |rb| evt_radiobutton(rb.id) { on_group_changed } }
    @colour_buttons.each { |rb| evt_radiobutton(rb.id) { on_group_changed } }

    evt_choice(@country.id) { on_country_changed }

    evt_slider(@age_slider.id) do |event|
      @age_value_label.label = event.int.to_s
    end

    evt_button(@submit_btn.id) { on_submit }
  end

  def on_close(event)
    event.skip
  end

  def on_subscribe_changed(event)
    set_status_text(event.checked? ? 'Subscribed' : 'Not subscribed')
  end

  def on_group_changed
    size   = @size_buttons.find(&:value).label
    colour = @colour_buttons.find(&:value).label
    set_status_text("Size: #{size}  Colour: #{colour}")
  end

  def on_country_changed
    set_status_text("Country: #{@country.string_selection}")
  end

  def on_submit
    size   = @size_buttons.find(&:value).label
    colour = @colour_buttons.find(&:value).label

    summary = [
      "Name:      #{@name_field.value}",
      "Email:     #{@email_field.value}",
      "Subscribe: #{@subscribe_cb.value ? 'Yes' : 'No'}",
      "Size:      #{size}",
      "Colour:    #{colour}",
      "Country:   #{@country.string_selection}",
      "Age:       #{@age_slider.value}",
    ].join("\n")

    Wx::message_box(summary, 'Form values', Wx::OK | Wx::ICON_INFORMATION)
  end
end

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

This is the same form as the end of lesson 2.2 — same widgets, same events, same handlers — but now it looks right, resizes correctly, and required no manual pixel arithmetic to produce.

What changed and why

A few things in the new layout are worth pausing on.

Empty StaticText as a spacer. The subscribe checkbox and the radio group labels need to occupy the right column, leaving the left column empty. Wx::StaticText.new(@panel, label: '') creates an invisible placeholder that occupies the left cell without displaying anything. This is the simplest way to skip a cell in a FlexGridSizer.

HBoxSizer inside a grid cell. The size and colour rows each contain multiple radio buttons side by side. The FlexGridSizer can only hold one item per cell, so you put an HBoxSizer in the cell and add the buttons to that. The grid positions the sizer; the sizer positions the buttons within it.

proportion on the age row. The age_row HBoxSizer is added to the grid with proportion 1 and Wx::EXPAND, so it stretches to fill the full right column. Inside, the slider has proportion 1 (it expands) and the label has proportion 0 (it stays fixed). This gives the slider all the growing space while keeping the number label pinned to its right edge.


Previous: Core widgets | Next: Menus and toolbars