PopupMenu

The Menu component provides both context menus (right-click menus) and popup menus with comprehensive features including icons, keyboard shortcuts, submenus, separators, checkable items, and custom elements. Built with accessibility and keyboard navigation in mind.

Install

#Import

use gpui_kit::component::{
    menu::{PopupMenu, PopupMenuItem, ContextMenuExt, DropdownMenu},
    Button
};
use gpui_kit::{actions, Action};
// Installed in ui/components. That directory is on the Slint include path, so import by file name:
import { MenuEntry, Menu } from "menu.slint";
use iced::widget::{column, container, mouse_area, scrollable, svg, text};
use iced::keyboard;
use iced::{Alignment, Element, Length, Subscription, Task, Theme};

#Usage

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// The whole of a menu: the items, the trigger, the caller's flag, and the
// one message that moves it. `on_chosen` is the contract `select` composes.
// `filled(true)` is the panel's one surface — `Menu::into_element` hands back
// the rows and their inset and draws none — and `padding(0.0)` keeps that
// surface on the body rather than a few pixels outside it.
let usage = Popover::new(trigger("Open menu"))
    .open(app.open[0])
    .panel_width(PANEL_WIDTH)
    .filled(true)
    .padding(0.0)
    .content(
        Menu::new(menu_items(0))
            .selected(app.chosen[0])
            .highlighted(app.lit(0))
            .on_chosen(|index| Message::Chosen { slot: 0, index })
            .on_highlight(move |index| Message::Highlighted { slot: 0, index })
            .on_leave(move |row| Message::Left { slot: 0, row })
            .into_element(),
    )
    .on_open_change(|open| Message::Opened { slot: 0, open });

let usage_section = column![
    usage,
    muted_line(
        "`on_chosen` reports the row by index; the caller owns the items and the open flag."
    ),
]
.spacing(8.0);

#ContextMenu

Context menus appear when right-clicking on an element:

use gpui_kit::component::menu::ContextMenuExt;

div()
    .id("my-element")
    .child("Right click me")
    .context_menu(|menu, window, cx| {
        menu.menu("Copy", Box::new(Copy))
            .menu("Paste", Box::new(Paste))
            .separator()
            .menu("Delete", Box::new(Delete))
    })
SlintNo Slint version of this example yet.

Dropdown menus are triggered by buttons or other interactive elements:

use gpui_kit::component::popup_menu::{PopupMenuExt as _, PopupMenuItem};

let view = cx.entity();
Button::new("menu-btn")
    .label("Open Menu")
    .dropdown_menu(|menu, window, cx| {
        menu.menu("New File", Box::new(NewFile))
            .menu("Open File", Box::new(OpenFile))
            .link("Documentation", "https://gpui-kit.com/")
            .separator()
            .item(PopupMenuItem::new("Custom Action")
                .on_click(window.listener_for(&view, |this, _, window, cx| {
                    // Custom action logic here
                    this.
                })
            )
            .separator()
            .menu("Exit", Box::new(Exit))
    })
Menu {
    items: [
        { label: "New file", icon: "file", shortcut: "⌘N", disabled: false, checked: false, separator: false },
        { label: "Open…", icon: "folder-open", shortcut: "⌘O", disabled: false, checked: false, separator: false },
        { label: "Save", icon: "download", shortcut: "⌘S", disabled: false, checked: false, separator: false },
        { label: "", icon: "", shortcut: "", disabled: false, checked: false, separator: true },
        { label: "Export as CSV", icon: "", shortcut: "", disabled: false, checked: false, separator: false },
    ];
}

#Anchor Position

Control where the dropdown menu appears relative to the trigger:

use gpui_kit::Anchor;

Button::new("menu-btn")
    .label("Options")
    .dropdown_menu_with_anchor(Anchor::TopRight, |menu, window, cx| {
        menu.menu("Option 1", Box::new(Action1))
            .menu("Option 2", Box::new(Action2))
    })
SlintNo Slint version of this example yet.

#Icons

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// Every row keeps the same four columns — a leading icon slot, the label,
// the shortcut, and a trailing check slot — so a row without an icon still
// leaves the slot empty rather than shifting its label left.
let icons = Popover::new(trigger("Open icons"))
    .open(app.open[1])
    .panel_width(PANEL_WIDTH)
    .filled(true)
    .padding(0.0)
    .content(
        Menu::new(menu_items(1))
            .selected(app.chosen[1])
            .highlighted(app.lit(1))
            .on_chosen(|index| Message::Chosen { slot: 1, index })
            .on_highlight(move |index| Message::Highlighted { slot: 1, index })
            .on_leave(move |row| Message::Left { slot: 1, row })
            .into_element(),
    )
    .on_open_change(|open| Message::Opened { slot: 1, open });

let icons_section = body_box(icons);

Add icons to menu items for better visual clarity:

use gpui_kit::component::IconName;

menu.menu_with_icon("Search", IconName::Search, Box::new(Search))
    .menu_with_icon("Settings", IconName::Settings, Box::new(OpenSettings))
    .separator()
    .menu_with_icon("Help", IconName::Help, Box::new(ShowHelp))
Menu {
    items: [
        { label: "Rename", icon: "pencil", shortcut: "F2", disabled: false, checked: false, separator: false },
        { label: "Duplicate", icon: "copy", shortcut: "⌘D", disabled: false, checked: false, separator: false },
        { label: "Move to…", icon: "folder", shortcut: "", disabled: false, checked: false, separator: false },
        { label: "Delete", icon: "trash", shortcut: "⌫", disabled: false, checked: false, separator: false },
    ];
}

#Disabled State

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// A disabled row is never highlighted and never chosen: the arrow keys step
// over it, and the row is drawn at `muted-foreground`.
let disabled = Popover::new(trigger("Open disabled"))
    .open(app.open[3])
    .panel_width(PANEL_WIDTH)
    .filled(true)
    .padding(0.0)
    .content(
        Menu::new(menu_items(3))
            .selected(app.chosen[3])
            .highlighted(app.lit(3))
            .on_chosen(|index| Message::Chosen { slot: 3, index })
            .on_highlight(move |index| Message::Highlighted { slot: 3, index })
            .on_leave(move |row| Message::Left { slot: 3, row })
            .into_element(),
    )
    .on_open_change(|open| Message::Opened { slot: 3, open });

let disabled_section = body_box(disabled);

#Destructive

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// `danger` draws the row in `destructive`, which is the one column GPUI's
// `PopupMenuItem` has and the Slint `MenuEntry` does not.
let destructive = Popover::new(trigger("Open destructive"))
    .open(app.open[4])
    .panel_width(PANEL_WIDTH)
    .filled(true)
    .padding(0.0)
    .content(
        Menu::new(menu_items(4))
            .selected(app.chosen[4])
            .highlighted(app.lit(4))
            .on_chosen(|index| Message::Chosen { slot: 4, index })
            .on_highlight(move |index| Message::Highlighted { slot: 4, index })
            .on_leave(move |row| Message::Left { slot: 4, row })
            .into_element(),
    )
    .on_open_change(|open| Message::Opened { slot: 4, open });

let destructive_section = body_box(destructive);

Create disabled menu items that cannot be activated:

menu.menu("Available Action", Box::new(Action1))
    .menu_with_disabled("Disabled Action", Box::new(Action2), true)
    .menu_with_icon_and_disabled(
        "Unavailable",
        IconName::Lock,
        Box::new(Action3),
        true
    )
Menu {
    items: [
        { label: "Cut", icon: "", shortcut: "⌘X", disabled: false, checked: false, separator: false },
        { label: "Copy", icon: "", shortcut: "⌘C", disabled: false, checked: false, separator: false },
        { label: "Paste", icon: "", shortcut: "⌘V", disabled: true, checked: false, separator: false },
    ];
}

#Check state

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// `checked` draws the check mark in the trailing slot; `selected` draws the
// whole row on the accent surface, which is what a menu of toggles wants.
let checks = Popover::new(trigger("Open checks"))
    .open(app.open[2])
    .panel_width(PANEL_WIDTH)
    .filled(true)
    .padding(0.0)
    .content(
        Menu::new(menu_items(2))
            .selected(app.chosen[2])
            .highlighted(app.lit(2))
            .on_chosen(|index| Message::Chosen { slot: 2, index })
            .on_highlight(move |index| Message::Highlighted { slot: 2, index })
            .on_leave(move |row| Message::Left { slot: 2, row })
            .into_element(),
    )
    .on_open_change(|open| Message::Opened { slot: 2, open });

let checks_section = body_box(checks);

Create menu items that show a check state:

let is_enabled = true;

menu.menu_with_check("Enable Feature", is_enabled, Box::new(ToggleFeature))
    .menu_with_check("Show Sidebar", sidebar_visible, Box::new(ToggleSidebar))
Menu {
    items: [
        { label: "Show minimap", icon: "", shortcut: "", disabled: false, checked: true, separator: false },
        { label: "Wrap lines", icon: "", shortcut: "⌥Z", disabled: false, checked: true, separator: false },
        { label: "Show whitespace", icon: "", shortcut: "", disabled: false, checked: false, separator: false },
    ];
}

By default, the check icon will be shown on the left side of the menu item, if this menu item has an icon, the check icon will replace the icon on the left side.

There also have a check_side option for you to config the check icon to be shown on the right side:

menu.check_size(Side::Right)
    .menu_with_check("Enable Feature", is_enabled, Box::new(ToggleFeature))
SlintNo Slint version of this example yet.

#Separators

Use separators to group related menu items:

menu.menu("New", Box::new(NewFile))
    .menu("Open", Box::new(OpenFile))
    .separator()  // Groups file operations
    .menu("Copy", Box::new(Copy))
    .menu("Paste", Box::new(Paste))
    .separator()  // Groups edit operations
    .menu("Exit", Box::new(Exit))
SlintNo Slint version of this example yet.

#Labels

Add non-interactive labels to organize menu sections:

menu.label("File Operations")
    .menu("New", Box::new(NewFile))
    .menu("Open", Box::new(OpenFile))
    .separator()
    .label("Edit Operations")
    .menu("Copy", Box::new(Copy))
    .menu("Paste", Box::new(Paste))
SlintNo Slint version of this example yet.

Create menu items that open external links:

menu.link("Documentation", "https://docs.example.com")
    .link_with_icon(
        "GitHub",
        IconName::GitHub,
        "https://github.com/example/repo"
    )
    .separator()
    .external_link_icon(false) // Hide external link icons
    .link("Support", "https://support.example.com")
SlintNo Slint version of this example yet.

#Custom Element

Create custom menu items with complex content:

use gpui_kit::component::{h_flex, v_flex};

menu.menu_element(Box::new(CustomAction), |window, cx| {
        v_flex()
            .child("Custom Element")
            .child(
                div()
                    .text_xs()
                    .text_color(cx.theme().muted_foreground)
                    .child("This is a subtitle")
            )
    })
    .menu_element_with_icon(
        IconName::Info,
        Box::new(InfoAction),
        |window, cx| {
            h_flex()
                .gap_1()
                .child("Status")
                .child(
                    div()
                        .text_sm()
                        .text_color(cx.theme().success)
                        .child("✓ Connected")
                )
        }
    )
SlintNo Slint version of this example yet.

#Keyboard Shortcuts

Menu items automatically display keyboard shortcuts if they’re bound to actions:

// First define your actions and key bindings
actions!(my_app, [Copy, Paste, Cut]);

// In your app initialization
cx.bind_keys([
    KeyBinding::new("ctrl-c", Copy, Some("editor")),
    KeyBinding::new("ctrl-v", Paste, Some("editor")),
    KeyBinding::new("ctrl-x", Cut, Some("editor")),
]);

// The menu will automatically show shortcuts
menu.action_context(focus_handle) // Set context for shortcuts
    .menu("Copy", Box::new(Copy))     // Will show "Ctrl+C"
    .menu("Paste", Box::new(Paste))   // Will show "Ctrl+V"
    .menu("Cut", Box::new(Cut))       // Will show "Ctrl+X"
SlintNo Slint version of this example yet.

A shortcut is shown where the item’s action will be dispatched: the action_context when one is set, otherwise the key contexts the menu’s trigger sits in. The hints appear on the same frame as the menu items.

Create nested menus with submenu support:

menu.submenu("File", window, cx, |submenu, window, cx| {
        submenu.menu("New", Box::new(NewFile))
            .menu("Open", Box::new(OpenFile))
            .separator()
            .menu("Recent", Box::new(ShowRecent))
    })
    .submenu("Edit", window, cx, |submenu, window, cx| {
        submenu.menu("Undo", Box::new(Undo))
            .menu("Redo", Box::new(Redo))
    })
SlintNo Slint version of this example yet.

Add icons to submenu headers:

menu.submenu_with_icon(
        Some(IconName::Folder.into()),
        "Project",
        window,
        cx,
        |submenu, window, cx| {
            submenu.menu("Open Project", Box::new(OpenProject))
                .menu("Close Project", Box::new(CloseProject))
        }
    )
SlintNo Slint version of this example yet.

#Scrollable Menus

For menus with many items, enable scrolling. Submenus open from a scrollable menu the same way as from any other menu:

Button::new("large-menu")
    .label("Many Options")
    .dropdown_menu(|menu, window, cx| {
        let mut menu = menu
            .scrollable(true)
            .max_h(px(300.))
            .label("Select an option");

        for i in 0..100 {
            menu = menu.menu(
                format!("Option {}", i),
                Box::new(SelectOption(i))
            );
        }
        menu
    })
SlintNo Slint version of this example yet.

Control menu dimensions:

menu.min_w(px(200.))      // Minimum width
    .max_w(px(400.))      // Maximum width
    .max_h(px(300.))      // Maximum height
    .scrollable(true)         // Enable scrolling when content exceeds max height
SlintNo Slint version of this example yet.

#Action Context

Set the focus context for handling menu actions:

let focus_handle = cx.focus_handle();

menu.action_context(focus_handle)
    .menu("Copy", Box::new(Copy))
    .menu("Paste", Box::new(Paste))
SlintNo Slint version of this example yet.

#File Manager Context Menu

div()
    .id("file-manager")
    .child("Right-click for options")
    .context_menu(|menu, window, cx| {
        menu.menu_with_icon("Open", IconName::FolderOpen, Box::new(Open))
            .separator()
            .menu_with_icon("Copy", IconName::Copy, Box::new(Copy))
            .menu_with_icon("Cut", IconName::Scissors, Box::new(Cut))
            .menu_with_icon("Paste", IconName::Clipboard, Box::new(Paste))
            .separator()
            .submenu("New", window, cx, |submenu, window, cx| {
                submenu.menu_with_icon("File", IconName::File, Box::new(NewFile))
                    .menu_with_icon("Folder", IconName::Folder, Box::new(NewFolder))
            })
            .separator()
            .menu_with_icon("Delete", IconName::Trash, Box::new(Delete))
            .separator()
            .menu("Properties", Box::new(ShowProperties))
    })
SlintNo Slint version of this example yet.

#Add MenuItem without action

Sometimes you may not like to define an action for a menu item, you just want add a on_click handler, in this case, the item and PopupMenuItem can help you:

use gpui_kit::component::{menu::PopupMenuItem, Button};

Button::new("custom-item-menu")
    .label("Options")
    .dropdown_menu(|menu, window, cx| {
        menu.item(
            PopupMenuItem::new("Custom Action")
                .disabled(false)
                .icon(IconName::Star)
                .on_click(|window, cx| {
                    // Custom click handler logic
                    println!("Custom Action Clicked!");
                })
        )
        .separator()
        .menu("Standard Action", Box::new(StandardAction))
    })
SlintNo Slint version of this example yet.

#Editor Menu with Shortcuts

// Define actions with keyboard shortcuts
actions!(editor, [Save, SaveAs, Find, Replace, ToggleWordWrap]);

// Set up key bindings
cx.bind_keys([
    KeyBinding::new("ctrl-s", Save, Some("editor")),
    KeyBinding::new("ctrl-shift-s", SaveAs, Some("editor")),
    KeyBinding::new("ctrl-f", Find, Some("editor")),
    KeyBinding::new("ctrl-h", Replace, Some("editor")),
]);

// Create menu with automatic shortcuts
let editor_focus = cx.focus_handle();

Button::new("editor-menu")
    .label("Edit")
    .dropdown_menu(|menu, window, cx| {
        menu.action_context(editor_focus)
            .menu("Save", Box::new(Save))           // Shows "Ctrl+S"
            .menu("Save As...", Box::new(SaveAs))   // Shows "Ctrl+Shift+S"
            .separator()
            .menu("Find", Box::new(Find))           // Shows "Ctrl+F"
            .menu("Replace", Box::new(Replace))     // Shows "Ctrl+H"
            .separator()
            .menu_with_check("Word Wrap", true, Box::new(ToggleWordWrap))
    })
SlintNo Slint version of this example yet.

#Settings Menu with Custom Elements

Button::new("settings")
    .label("Settings")
    .dropdown_menu(|menu, window, cx| {
        menu.label("Display")
            .menu_element_with_check(dark_mode, Box::new(ToggleDarkMode), |window, cx| {
                h_flex()
                    .gap_2()
                    .child("Dark Mode")
                    .child(
                        div()
                            .text_xs()
                            .text_color(cx.theme().muted_foreground)
                            .child(if dark_mode { "On" } else { "Off" })
                    )
            })
            .separator()
            .label("Account")
            .menu_element_with_icon(
                IconName::User,
                Box::new(ShowProfile),
                |window, cx| {
                    v_flex()
                        .child("John Doe")
                        .child(
                            div()
                                .text_xs()
                                .text_color(cx.theme().muted_foreground)
                                .child("john@example.com")
                        )
                }
            )
            .separator()
            .link_with_icon("Help Center", IconName::Help, "https://help.example.com")
            .menu("Sign Out", Box::new(SignOut))
    })
SlintNo Slint version of this example yet.
// One row per kind GPUI's menu draws: a search row whose leading mark is the
// magnifier, a sub-title's second line (`subtitle`), a check mark
// (`checked`), a disabled row, a submenu row's trailing chevron
// (`submenu`), a right-aligned shortcut, and an external-link mark
// (`external`). `selected` draws the row at `SETTINGS_SELECTED` on the accent
// surface until a choice replaces it.
let settings = Popover::new(trigger("Open settings"))
    .open(app.open[6])
    .panel_width(PANEL_WIDTH)
    .filled(true)
    .padding(0.0)
    .content(
        Menu::new(menu_items(6))
            .selected(app.chosen[6].or(Some(SETTINGS_SELECTED)))
            .highlighted(app.lit(6))
            .on_chosen(|index| Message::Chosen { slot: 6, index })
            .on_highlight(move |index| Message::Highlighted { slot: 6, index })
            .on_leave(move |row| Message::Left { slot: 6, row })
            .into_element(),
    )
    .on_open_change(|open| Message::Opened { slot: 6, open });

let settings_section = body_box(settings);

#Keyboard Shortcuts

GPUINo GPUI version of this example yet.
SlintNo Slint version of this example yet.
// `↑`/`↓` step to the previous and next actionable row, `Home`/`End` jump to
// the first and last, and `Enter` and `Space` choose the one highlighted.
// Escape is the popover's, and closes the panel from anywhere in it.
let keyboard = Popover::new(trigger("Open with the keyboard"))
    .open(app.open[5])
    .panel_width(PANEL_WIDTH)
    .filled(true)
    .padding(0.0)
    .content(
        Menu::new(menu_items(5))
            .selected(app.chosen[5])
            .highlighted(app.lit(5))
            .on_chosen(|index| Message::Chosen { slot: 5, index })
            .on_highlight(move |index| Message::Highlighted { slot: 5, index })
            .on_leave(move |row| Message::Left { slot: 5, row })
            .into_element(),
    )
    .on_open_change(|open| Message::Opened { slot: 5, open });

let keyboard_section = body_box(keyboard);
KeyAction
↑ / ↓Navigate menu items
← / →Navigate submenus
Enter / SpaceActivate menu item
EscapeClose menu
TabClose menu and focus next element

#Best Practices

  1. Group Related Items: Use separators to group related functionality
  2. Consistent Icons: Use consistent iconography across your application
  3. Logical Order: Place most common actions at the top
  4. Keyboard Shortcuts: Provide shortcuts for frequently used actions
  5. Context Awareness: Show only relevant items for the current context
  6. Progressive Disclosure: Use submenus for complex hierarchies
  7. Clear Labels: Use descriptive, action-oriented labels
  8. Reasonable Limits: Use scrollable menus for more than 10-15 items

#API Reference

#MenuEntry

NameKindTypeDefault
labelfieldstring
iconfieldstring
shortcutfieldstring
disabledfieldbool
checkedfieldbool
separatorfieldbool

#Menu

A list of actions. Mirrors GPUI Component's `Menu`.

NameKindTypeDefault
itemsin[MenuEntry]
chosencallback(index: int)