Skip to content

Menus and toolbars

Menus and toolbars are how desktop apps expose commands — actions the user can trigger regardless of what they are doing in the main window. A well-designed menu bar is predictable: users know where to look for File, Edit, and Help without thinking about it. A toolbar puts the most frequent commands within a single click.

This lesson builds both from scratch on a fresh app. By the end you will have a frame with a complete menu bar, keyboard shortcuts, and a toolbar — the kind of chrome that makes an app feel finished.

Start fresh

Create a new file called menu_app.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
require 'wx'

class MenuFrame < Wx::Frame
  def initialize
    super(nil, title: 'Menu Demo', size: [600, 400])

    build_ui
    bind_events

    layout
    centre
  end

  private

  def build_ui
    @panel = Wx::Panel.new(self)
    @output = Wx::TextCtrl.new(@panel,
      value: "Actions will appear here...\n",
      style: Wx::TE_MULTILINE | Wx::TE_READONLY)

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

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

  def on_close(event)
    event.skip
  end

  def log(message)
    @output.append_text("#{message}\n")
  end
end

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

Run it. A window with a read-only text area — we will use log to record each action as the user triggers it, so you can see every menu item and toolbar button working without building a full application around them.

Step 1 — A basic menu bar

A menu bar is built from three classes working together:

  • Wx::MenuBar — the bar itself, attached to the frame
  • Wx::Menu — a single drop-down menu (File, Edit, Help, etc.)
  • Wx::MenuItem — an individual item within a menu

Build a File menu and attach it to the frame. Add a build_menu method and call it from initialize before build_ui:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def initialize
  super(nil, title: 'Menu Demo', size: [600, 400])

  build_menu
  build_ui
  bind_events

  layout
  centre
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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_separator
  file_menu.append(Wx::ID_EXIT, "E&xit\tCtrl+Q")

  menu_bar.append(file_menu, "&File")
  set_menu_bar(menu_bar)
end

Run it. On Windows and Linux, Exit and the separator appear exactly where you placed them. On macOS you will see New, Open, and Save in the File menu — but not the separator or Exit. This is correct platform behaviour, not a bug.

macOS and stock menu items. wxRuby3 automatically moves items with certain stock IDs to the locations macOS expects. Wx::ID_EXIT becomes “Quit” in the application menu (the menu named after your app, to the left of File). Wx::ID_ABOUT is similarly relocated. When an item is moved, any separator immediately before it in the original menu is also removed — which is why the separator disappears. This is wxRuby3 doing the right thing: your menu code is platform-neutral, and wxRuby3 adapts it to each platform’s conventions.

The items do nothing yet — we will wire them up shortly.

Three things to notice in the code:

Stock IDs. Wx::ID_NEW, Wx::ID_OPEN, Wx::ID_SAVE, and Wx::ID_EXIT are stock identifiers. wxRuby3 knows about these and handles platform-specific placement automatically, as you just saw. Stock IDs give you correct platform behaviour for free.

Ampersands for keyboard access. The & before a letter underlines it (on Windows and Linux) and makes it the keyboard access key when the menu is open. "&New" means pressing N when the File menu is open activates New. The & is invisible on macOS.

Tab-separated shortcuts. The \t separates the menu label from the keyboard shortcut displayed on the right. "&New\tCtrl+N" shows “Ctrl+N” on the right side of the menu item, and wxRuby3 registers that key combination automatically. On macOS, Ctrl is displayed and handled as Cmd.

Step 2 — Handling menu events

Wire up the menu items in bind_events:

1
2
3
4
5
6
7
8
def bind_events
  evt_close { |event| on_close(event) }

  evt_menu(Wx::ID_NEW)  { log('File > New') }
  evt_menu(Wx::ID_OPEN) { log('File > Open') }
  evt_menu(Wx::ID_SAVE) { log('File > Save') }
  evt_menu(Wx::ID_EXIT) { close }
end

Run it. Click each menu item — the text area logs each action. Wx::ID_EXIT calls close on the frame, which triggers the close event and exits. Try the keyboard shortcuts too — Ctrl+N, Ctrl+O, Ctrl+S, Ctrl+Q all work without any extra wiring.

evt_menu is the event handler for menu items. It takes the menu item’s ID, which for stock items is the stock constant. For custom items you will use the ID returned by append.

Step 3 — A second menu

Add an Edit menu alongside File:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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_separator
  file_menu.append(Wx::ID_EXIT, "E&xit\tCtrl+Q")

  edit_menu = Wx::Menu.new
  edit_menu.append(Wx::ID_CUT,   "Cu&t\tCtrl+X")
  edit_menu.append(Wx::ID_COPY,  "&Copy\tCtrl+C")
  edit_menu.append(Wx::ID_PASTE, "&Paste\tCtrl+V")
  edit_menu.append_separator
  edit_menu.append(Wx::ID_SELECTALL, "Select &All\tCtrl+A")

  menu_bar.append(file_menu, "&File")
  menu_bar.append(edit_menu, "&Edit")
  set_menu_bar(menu_bar)
end

Standard editing commands. Cut, Copy, Paste, andSelect All work in the text area without any evt_menu handlers because wxRuby3’s text control handles these stock IDs natively. The focused widget processes them first and they never reach your handler. In a real app this is exactly what you want — standard editing just works. Try working with some text in the text pane now - you’ll find they all work with no code!

You only need evt_menu handlers for these IDs if you are doing something beyond the default behaviour, such as updating an undo history or enabling/disabling toolbar buttons in response to selection changes.

Run it. Two menus, all items working.

Step 4 — Custom menu items

Not every item has a stock ID. For custom items, append returns an ID you can use for event binding:

1
2
3
4
5
6
7
view_menu = Wx::Menu.new
@zoom_in_id  = view_menu.append(Wx::ID_ANY, "Zoom &In\tCtrl+=").id
@zoom_out_id = view_menu.append(Wx::ID_ANY, "Zoom &Out\tCtrl+-").id
view_menu.append_separator
@fullscreen_id = view_menu.append(Wx::ID_ANY, "&Full Screen\tCtrl+F").id

menu_bar.append(view_menu, "&View")

Wx::ID_ANY tells wxRuby3 to assign a unique ID automatically. The append method returns the Wx::MenuItem object — call .id on it to get the integer ID for event binding.

Add the handlers:

1
2
3
evt_menu(@zoom_in_id)    { log('View > Zoom In') }
evt_menu(@zoom_out_id)   { log('View > Zoom Out') }
evt_menu(@fullscreen_id) { log('View > Full Screen') }

Run it. Three menus, all custom items working.

Step 5 — Checked and radio menu items

Some menu items have state — they can be checked or unchecked. wxRuby3 supports two variants:

Check items toggle independently — like checkboxes:

1
@show_ruler_id = view_menu.append_check_item(Wx::ID_ANY, "Show &Ruler").id

Radio items are mutually exclusive within their group — like radio buttons:

1
2
3
4
view_menu.append_separator
@view_small_id  = view_menu.append_radio_item(Wx::ID_ANY, "Small Icons").id
@view_medium_id = view_menu.append_radio_item(Wx::ID_ANY, "Medium Icons").id
@view_large_id  = view_menu.append_radio_item(Wx::ID_ANY, "Large Icons").id

Add these to build_menu inside the view menu, before menu_bar.append(view_menu, "&View"). Then handle them:

1
2
3
4
5
6
7
8
evt_menu(@show_ruler_id) do
  checked = menu_bar.is_checked(@show_ruler_id)
  log("Show Ruler: #{checked}")
end

evt_menu(@view_small_id)  { log('Icons: Small') }
evt_menu(@view_medium_id) { log('Icons: Medium') }
evt_menu(@view_large_id)  { log('Icons: Large') }

To read or set a check item’s state programmatically:

1
2
menu_bar.check(@show_ruler_id, true)    # check it
menu_bar.is_checked(@show_ruler_id)     # true or false

Run it. The ruler item toggles its checkmark. The icon size items behave as a radio group — selecting one deselects the others.

Step 6 — The Help menu and About dialog

A Help menu with an About item is standard on every platform.

1
2
3
4
help_menu = Wx::Menu.new
help_menu.append(Wx::ID_ABOUT, "&About\tF1")

menu_bar.append(help_menu, "&Help")
1
2
3
4
5
6
7
8
9
evt_menu(Wx::ID_ABOUT) { on_about }

def on_about
  Wx::message_box(
    "Menu Demo\nVersion 1.0\n\nA wxRuby3 tutorial example.",
    'About Menu Demo',
    Wx::OK | Wx::ICON_INFORMATION
  )
end

Run it.

On macOS the About item appears in the application menu rather than a Help menu — that is correct platform behaviour, provided by the stock Wx::ID_ABOUT ID at no extra cost.


Step 7 — Toolbar

A toolbar sits below the menu bar and provides quick access to the most common actions. Add a build_toolbar method and call it from initialize after build_menu:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def initialize
  super(nil, title: 'Menu Demo', size: [600, 400])

  build_menu
  build_toolbar
  build_ui
  bind_events

  layout
  centre
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def build_toolbar
  toolbar = create_tool_bar(Wx::TB_FLAT | Wx::TB_TEXT)

  toolbar.add_tool(Wx::ID_NEW,  'New',  Wx::ArtProvider.get_bitmap(Wx::ART_NEW,       Wx::ART_TOOLBAR))
  toolbar.add_tool(Wx::ID_OPEN, 'Open', Wx::ArtProvider.get_bitmap(Wx::ART_FILE_OPEN, Wx::ART_TOOLBAR))
  toolbar.add_tool(Wx::ID_SAVE, 'Save', Wx::ArtProvider.get_bitmap(Wx::ART_FILE_SAVE, Wx::ART_TOOLBAR))
  toolbar.add_separator
  toolbar.add_tool(@zoom_in_id,  'Zoom In',  Wx::ArtProvider.get_bitmap(Wx::ART_PLUS,  Wx::ART_TOOLBAR))
  toolbar.add_tool(@zoom_out_id, 'Zoom Out', Wx::ArtProvider.get_bitmap(Wx::ART_MINUS, Wx::ART_TOOLBAR))

  toolbar.realize
end

Toolbar buttons fire evt_tool, not evt_menu. Add handlers in bind_events:

1
2
3
4
5
evt_tool(Wx::ID_NEW)   { log('Toolbar > New') }
evt_tool(Wx::ID_OPEN)  { log('Toolbar > Open') }
evt_tool(Wx::ID_SAVE)  { log('Toolbar > Save') }
evt_tool(@zoom_in_id)  { log('Toolbar > Zoom In') }
evt_tool(@zoom_out_id) { log('Toolbar > Zoom Out') }

Run it. The toolbar buttons all fire and log their actions. In a real app, the menu item and toolbar button for the same action would call the same handler method:

1
2
3
4
5
6
evt_menu(Wx::ID_NEW) { on_new }
evt_tool(Wx::ID_NEW) { on_new }

def on_new
  # one method, triggered from both menu and toolbar
end

toolbar.realize must be called after all tools are added — it finalises the toolbar layout. Forgetting it produces a blank toolbar.


Note that @zoom_in_id and @zoom_out_id are instance variables set in build_menu, so build_menu must be called before build_toolbar — which is already the case in initialize. Update the finished app at the bottom to match.

The finished app

  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
require 'wx'

class MenuFrame < Wx::Frame
  def initialize
    super(nil, title: 'Menu Demo', size: [600, 400])

    build_menu
    build_toolbar
    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_separator
    file_menu.append(Wx::ID_EXIT, "E&xit\tCtrl+Q")

    edit_menu = Wx::Menu.new
    edit_menu.append(Wx::ID_CUT,       "Cu&t\tCtrl+X")
    edit_menu.append(Wx::ID_COPY,      "&Copy\tCtrl+C")
    edit_menu.append(Wx::ID_PASTE,     "&Paste\tCtrl+V")
    edit_menu.append_separator
    edit_menu.append(Wx::ID_SELECTALL, "Select &All\tCtrl+A")

    view_menu = Wx::Menu.new
    @zoom_in_id      = view_menu.append(Wx::ID_ANY, "Zoom &In\tCtrl+=").id
    @zoom_out_id     = view_menu.append(Wx::ID_ANY, "Zoom &Out\tCtrl+-").id
    view_menu.append_separator
    @show_ruler_id   = view_menu.append_check_item(Wx::ID_ANY, "Show &Ruler").id
    view_menu.append_separator
    @view_small_id   = view_menu.append_radio_item(Wx::ID_ANY, "Small Icons").id
    @view_medium_id  = view_menu.append_radio_item(Wx::ID_ANY, "Medium Icons").id
    @view_large_id   = view_menu.append_radio_item(Wx::ID_ANY, "Large Icons").id

    help_menu = Wx::Menu.new
    help_menu.append(Wx::ID_ABOUT, "&About\tF1")

    menu_bar.append(file_menu, "&File")
    menu_bar.append(edit_menu, "&Edit")
    menu_bar.append(view_menu, "&View")
    menu_bar.append(help_menu, "&Help")
    set_menu_bar(menu_bar)
  end

  def build_toolbar
    toolbar = create_tool_bar(Wx::TB_FLAT | Wx::TB_TEXT)

    toolbar.add_tool(Wx::ID_NEW,   'New',   Wx::ArtProvider.get_bitmap(Wx::ART_NEW,       Wx::ART_TOOLBAR))
    toolbar.add_tool(Wx::ID_OPEN,  'Open',  Wx::ArtProvider.get_bitmap(Wx::ART_FILE_OPEN, Wx::ART_TOOLBAR))
    toolbar.add_tool(Wx::ID_SAVE,  'Save',  Wx::ArtProvider.get_bitmap(Wx::ART_FILE_SAVE, Wx::ART_TOOLBAR))
    toolbar.add_separator
    toolbar.add_tool(Wx::ID_CUT,   'Cut',   Wx::ArtProvider.get_bitmap(Wx::ART_CUT,   Wx::ART_TOOLBAR))
    toolbar.add_tool(Wx::ID_COPY,  'Copy',  Wx::ArtProvider.get_bitmap(Wx::ART_COPY,  Wx::ART_TOOLBAR))
    toolbar.add_tool(Wx::ID_PASTE, 'Paste', Wx::ArtProvider.get_bitmap(Wx::ART_PASTE, Wx::ART_TOOLBAR))

    toolbar.realize
  end

  def build_ui
    @panel = Wx::Panel.new(self)
    @output = Wx::TextCtrl.new(@panel,
      value: "Actions will appear here...\n",
      style: Wx::TE_MULTILINE | Wx::TE_READONLY)

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

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

    evt_menu(Wx::ID_NEW)  { log('File > New') }
    evt_menu(Wx::ID_OPEN) { log('File > Open') }
    evt_menu(Wx::ID_SAVE) { log('File > Save') }
    evt_menu(Wx::ID_EXIT) { close }

    evt_menu(Wx::ID_CUT)       { log('Edit > Cut') }
    evt_menu(Wx::ID_COPY)      { log('Edit > Copy') }
    evt_menu(Wx::ID_PASTE)     { log('Edit > Paste') }
    evt_menu(Wx::ID_SELECTALL) { log('Edit > Select All') }

    evt_menu(@zoom_in_id)    { log('View > Zoom In') }
    evt_menu(@zoom_out_id)   { log('View > Zoom Out') }

    evt_menu(@show_ruler_id) do
      checked = menu_bar.is_checked(@show_ruler_id)
      log("Show Ruler: #{checked}")
    end

    evt_menu(@view_small_id)  { log('Icons: Small') }
    evt_menu(@view_medium_id) { log('Icons: Medium') }
    evt_menu(@view_large_id)  { log('Icons: Large') }

    evt_menu(Wx::ID_ABOUT) { on_about }
  end

  def on_close(event)
    event.skip
  end

  def on_about
    Wx::message_box(
      "Menu Demo\nVersion 1.0\n\nA wxRuby3 tutorial example.",
      'About Menu Demo',
      Wx::OK | Wx::ICON_INFORMATION
    )
  end

  def log(message)
    @output.append_text("#{message}\n")
  end
end

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

Previous: Layout with sizers | Next: Dialogs