{
- row_button(id, group, icon_name, label)
- .tooltip(widgets::tooltip(label, None))
- .on_click(on_click)
-}
-
-/// A row's overflow button: [`row_action`]'s look, but it stays visible
-/// and pressed while its menu is open, and shows no tooltip over the menu.
+/// A row's overflow button: [`row_button`]'s look, but it stays visible
+/// and pressed while its menu is open.
fn row_menu_button(
id: SharedString,
group: &SharedString,
label: &'static str,
open: bool,
) -> gpui::Stateful
{
- row_button(id, group, "ellipsis", label)
- .when(open, |button| {
- button
- .opacity(1.)
- .bg(gpui::rgb(theme::bg_sidebar_row_selected()))
- })
- .when(!open, |button| {
- button.tooltip(widgets::tooltip(label, None))
- })
+ row_button(id, group, "ellipsis", label).when(open, |button| {
+ button
+ .opacity(1.)
+ .bg(gpui::rgb(theme::bg_sidebar_row_selected()))
+ })
}
/// The small icon button a sidebar row shows while it is hovered.
@@ -3175,14 +3202,25 @@ fn menu_item(item: SidebarMenuItem) -> MenuItem
{
id,
icon,
label,
+ shortcut,
+ destructive,
on_click,
} = item;
- MenuItem::new(
+ let item = MenuItem::new(
id,
label,
move |this: &mut Sidebar, _: &mut Window, cx: &mut Context| on_click(this, cx),
)
- .icon(icon)
+ .icon(icon);
+ let item = match shortcut {
+ Some(key) => item.shortcut(key),
+ None => item,
+ };
+ if destructive {
+ item.destructive()
+ } else {
+ item
+ }
}
/// Field-wise equality for session rows; the summary type has no
diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs
index eeaead3f9..b14e0979f 100644
--- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs
+++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs
@@ -1271,9 +1271,11 @@ impl Sidebar {
row.aria_active_descendant()
.bg(gpui::rgb(theme::bg_sidebar_row_hover()))
})
- .hover(|s| {
- s.bg(gpui::rgb(theme::bg_sidebar_row_hover()))
- .cursor_pointer()
+ .when(!self.menu_open(), |row| {
+ row.hover(|s| {
+ s.bg(gpui::rgb(theme::bg_sidebar_row_hover()))
+ .cursor_pointer()
+ })
})
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(SidebarEvent::Navigate(request.clone()))
diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs
index 1dd1eaf02..c2c134729 100644
--- a/apps/maple-agent/app/src/ui/chat/tests.rs
+++ b/apps/maple-agent/app/src/ui/chat/tests.rs
@@ -5315,10 +5315,7 @@ mod state_tests {
});
}
- /// The hover button steps a task one rung forward along active,
- /// settled, archived, deleted. The overflow menu reaches every later
- /// rung and comes back only to active: no settle or unsettle on an
- /// archived task, no pin on a settled one.
+ /// Each rung offers only its available actions.
#[gpui::test]
fn test_task_ladder_actions_follow_the_rung(cx: &mut TestAppContext) {
cx.executor().allow_parking();
@@ -5336,28 +5333,32 @@ mod state_tests {
.update(cx, |sidebar, cx| sidebar.set_archived_expanded(true, cx));
let sidebar = this.sidebar.read(cx);
- assert_eq!(sidebar.row_step_label_for_test("s1"), Some("Settle"));
assert_eq!(
sidebar.task_menu_labels_for_test("s1"),
- vec![
- "Rename task",
- "Pin task",
- "Settle task",
- "Archive task",
- "Delete task"
+ [
+ "Rename",
+ "Pin",
+ "Mark as unread",
+ "Open folder",
+ "Settle",
+ "Archive",
+ "Delete",
]
);
-
- assert_eq!(sidebar.row_step_label_for_test("s2"), Some("Archive"));
assert_eq!(
sidebar.task_menu_labels_for_test("s2"),
- vec!["Rename task", "Reopen task", "Archive task", "Delete task"]
+ [
+ "Rename",
+ "Mark as unread",
+ "Open folder",
+ "Reopen",
+ "Archive",
+ "Delete",
+ ]
);
-
- assert_eq!(sidebar.row_step_label_for_test("s3"), Some("Delete"));
assert_eq!(
sidebar.task_menu_labels_for_test("s3"),
- vec!["Rename task", "Reopen task", "Delete task"]
+ ["Rename", "Open folder", "Unarchive", "Delete"]
);
});
}
@@ -5543,10 +5544,6 @@ mod state_tests {
this.sidebar
.update(cx, |sidebar, cx| sidebar.toggle_task_pin("s1", cx));
assert_eq!(this.sidebar_pinned(cx), vec![0]);
- assert_eq!(
- this.sidebar.read(cx).row_step_label_for_test("s1"),
- Some("Settle")
- );
// The pin drops at once; the row moves when the runtime's
// record lands.
@@ -5558,10 +5555,6 @@ mod state_tests {
this.settle_task("s1", cx);
assert_eq!(this.sidebar_settled(cx), vec![0]);
assert_eq!(this.sidebar_active(cx), vec![1]);
- assert_eq!(
- this.sidebar.read(cx).row_step_label_for_test("s1"),
- Some("Archive")
- );
this.unsettle_task("s1", cx);
assert!(this.sidebar_pinned(cx).is_empty());
@@ -5712,16 +5705,269 @@ mod state_tests {
cx.simulate_keystrokes(keys);
assert!(sidebar.update(cx, |sidebar, _| sidebar.task_menu().is_none()));
};
- // Rows: Rename, Pin, Settle, Archive, Delete.
- pick(cx, "down down down enter");
- // Rows: Rename, Reopen, Archive, Delete.
+ pick(cx, "down down down down down enter");
chat.update(cx, |this, cx| this.settle_task("s1", cx));
- pick(cx, "down down enter");
+ pick(cx, "down down down down enter");
chat.update(cx, |this, cx| this.unsettle_task("s1", cx));
pick(cx, "down down enter");
assert_eq!(chat.update(cx, |this, cx| this.sidebar_pinned(cx)), vec![0]);
}
+ /// Right-click opens at the pointer, survives release, and switches tasks.
+ #[gpui::test]
+ fn test_right_clicking_a_task_row_opens_its_menu(cx: &mut TestAppContext) {
+ let (chat, cx) = chat_window(cx, |this, cx| {
+ this.sessions = vec![summary("s1", "One"), summary("s2", "Two")];
+ this.sync_sidebar(cx);
+ });
+ for (row, menu, open) in [
+ ("session-s2", "task-menu-s2", "Task(s2)"),
+ ("session-s1", "task-menu-s1", "Task(s1)"),
+ ] {
+ let bounds = on_screen(cx, row);
+ let at = gpui::point(bounds.left() + px(20.), bounds.center().y);
+ cx.simulate_mouse_down(at, gpui::MouseButton::Right, gpui::Modifiers::default());
+ cx.executor()
+ .advance_clock(std::time::Duration::from_millis(16));
+ cx.run_until_parked();
+ let panel = on_screen(cx, menu);
+ assert!(
+ (panel.origin.x - at.x).abs() <= px(1.) && (panel.origin.y - at.y).abs() <= px(1.)
+ );
+ let released = at + gpui::point(px(1.), px(-1.));
+ cx.simulate_mouse_move(
+ released,
+ Some(gpui::MouseButton::Right),
+ gpui::Modifiers::default(),
+ );
+ cx.simulate_mouse_up(
+ released,
+ gpui::MouseButton::Right,
+ gpui::Modifiers::default(),
+ );
+ cx.executor()
+ .advance_clock(std::time::Duration::from_millis(160));
+ cx.run_until_parked();
+ assert_eq!(open_menu(&chat, cx), open);
+ }
+ cx.simulate_keystrokes("escape");
+ assert_eq!(open_menu(&chat, cx), "");
+ press(cx, "menu-session-s1");
+ assert_eq!(open_menu(&chat, cx), "Task(s1)");
+ cx.simulate_click(gpui::point(px(1100.), px(700.)), gpui::Modifiers::default());
+ cx.run_until_parked();
+ assert_eq!(open_menu(&chat, cx), "");
+ right_click(cx, "session-s1");
+ assert_eq!(open_menu(&chat, cx), "Task(s1)");
+ }
+
+ /// Menu letters dispatch the corresponding task actions.
+ #[gpui::test]
+ fn test_task_menu_letters_pick_their_items(cx: &mut TestAppContext) {
+ let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new()));
+ let sink = events.clone();
+ let (chat, cx) = chat_window(cx, move |this, cx| {
+ cx.subscribe(&this.sidebar, move |_, _, event: &SidebarEvent, _| {
+ let event = match event {
+ SidebarEvent::SetState { session_id, state } => {
+ format!("{session_id} {state:?}")
+ }
+ SidebarEvent::DeleteTask(session_id) => format!("{session_id} deleted"),
+ SidebarEvent::SetUnread { session_id, unread } => {
+ format!("{session_id} unread {unread}")
+ }
+ _ => return,
+ };
+ sink.borrow_mut().push(event);
+ })
+ .detach();
+ this.sessions = vec![summary("s1", "One"), summary("s2", "Archived")];
+ this.sessions[1].state = AgentTaskState::Archived;
+ this.sync_sidebar(cx);
+ this.sidebar
+ .update(cx, |sidebar, cx| sidebar.set_archived_expanded(true, cx));
+ });
+ let sidebar = chat.update(cx, |this, _| this.sidebar.clone());
+ let pick = |cx: &mut gpui::VisualTestContext, id: &str, key: &str| {
+ sidebar.update(cx, |sidebar, cx| sidebar.open_task_menu_for_test(id, cx));
+ cx.run_until_parked();
+ cx.simulate_keystrokes(key);
+ assert!(
+ sidebar.update(cx, |sidebar, _| sidebar.task_menu().is_none()),
+ "{key} closes the menu"
+ );
+ cx.run_until_parked();
+ std::mem::take(&mut *events.borrow_mut())
+ };
+
+ // R hands the keyboard to the rename field: menu letters typed
+ // into it are text, not actions.
+ assert!(pick(cx, "s1", "r").is_empty());
+ assert!(matches!(
+ sidebar.update(cx, |sidebar, _| sidebar.rename_target()),
+ Some(RenameTarget::Task(ref target)) if target == "s1"
+ ));
+ cx.simulate_input("Pause and drop");
+ cx.run_until_parked();
+ let field = sidebar.update(cx, |sidebar, _| sidebar.inputs().last().cloned());
+ assert_eq!(
+ field.map(|field| field.read_with(cx, |field, _| field.text())),
+ Some("Pause and drop".to_string())
+ );
+ assert!(events.borrow().is_empty(), "{:?}", events.borrow());
+ assert!(
+ chat.update(cx, |this, cx| this.sidebar_pinned(cx))
+ .is_empty()
+ );
+ chat.update(cx, |this, cx| this.cancel_rename(cx));
+
+ assert!(pick(cx, "s1", "p").is_empty());
+ assert_eq!(chat.update(cx, |this, cx| this.sidebar_pinned(cx)), vec![0]);
+ assert!(pick(cx, "s1", "p").is_empty());
+ assert!(
+ chat.update(cx, |this, cx| this.sidebar_pinned(cx))
+ .is_empty()
+ );
+ for (unread, label, event) in [
+ (true, "Mark as read", "s1 unread true"),
+ (false, "Mark as unread", "s1 unread false"),
+ ] {
+ assert_eq!(pick(cx, "s1", "u"), [event]);
+ assert_eq!(
+ chat.update(cx, |this, _| this.completed_unread_sessions.contains("s1")),
+ unread
+ );
+ assert!(sidebar.update(cx, |sidebar, _| {
+ sidebar.task_menu_labels_for_test("s1").contains(&label)
+ }));
+ }
+ assert_eq!(pick(cx, "s1", "s"), ["s1 Settled"]);
+ chat.update(cx, |this, cx| this.settle_task("s1", cx));
+ assert_eq!(pick(cx, "s1", "s"), ["s1 Active"]);
+ assert_eq!(pick(cx, "s1", "a"), ["s1 Archived"]);
+ assert_eq!(pick(cx, "s1", "d"), ["s1 deleted"]);
+ chat.update(cx, |this, _| {
+ assert_eq!(
+ this.confirm,
+ Some(dialogs::ConfirmDialog::DeleteTask("s1".into()))
+ );
+ assert_eq!(this.sessions.len(), 2);
+ this.confirm = None;
+ });
+ assert_eq!(pick(cx, "s2", "a"), ["s2 Active"]);
+
+ // A press picks the same items.
+ sidebar.update(cx, |sidebar, cx| sidebar.open_task_menu_for_test("s2", cx));
+ press(cx, "delete-task-s2");
+ cx.run_until_parked();
+ assert_eq!(*events.borrow(), ["s2 deleted"]);
+ }
+
+ /// Pressing a pinned row's Unpin button removes its pin without selecting it.
+ #[gpui::test]
+ fn test_a_pinned_rows_strip_unpins_it(cx: &mut TestAppContext) {
+ let (chat, cx) = chat_window(cx, |this, cx| {
+ this.sessions = vec![summary("s1", "One")];
+ this.sync_sidebar(cx);
+ this.sidebar
+ .update(cx, |sidebar, cx| sidebar.toggle_task_pin("s1", cx));
+ });
+ assert_eq!(chat.update(cx, |this, cx| this.sidebar_pinned(cx)), vec![0]);
+ let selected = chat.update(cx, |this, _| this.selected_session.clone());
+ let row = on_screen(cx, "session-s1");
+ cx.simulate_mouse_move(row.center(), None, gpui::Modifiers::default());
+ press(cx, "pin-session-s1");
+ cx.run_until_parked();
+ assert!(
+ chat.update(cx, |this, cx| this.sidebar_pinned(cx))
+ .is_empty()
+ );
+ assert_eq!(
+ chat.update(cx, |this, _| this.selected_session.clone()),
+ selected
+ );
+ assert_eq!(open_menu(&chat, cx), "");
+ }
+
+ /// Reordering keeps the same task's menu; removing the task closes it.
+ #[gpui::test]
+ fn test_task_menu_follows_its_task(cx: &mut TestAppContext) {
+ let (chat, cx) = chat_window(cx, |this, cx| {
+ this.sessions = vec![summary("s1", "One"), summary("s2", "Two")];
+ this.sync_sidebar(cx);
+ });
+ press(cx, "menu-session-s2");
+ let row_before = on_screen(cx, "session-s2");
+ let panel_before = on_screen(cx, "task-menu-s2");
+ chat.update(cx, |this, cx| {
+ this.sessions[1].updated_ms = 10;
+ this.sync_sidebar(cx);
+ assert_eq!(this.sidebar_active(cx), vec![1, 0]);
+ });
+ let row_after = on_screen(cx, "session-s2");
+ let panel_after = on_screen(cx, "task-menu-s2");
+ let row_movement = row_after.top() - row_before.top();
+ let panel_movement = panel_after.top() - panel_before.top();
+ assert!(row_movement < px(0.));
+ assert!((panel_movement - row_movement).abs() <= px(1.));
+ assert_eq!(open_menu(&chat, cx), "Task(s2)");
+ chat.update(cx, |this, cx| this.remove_session("s2", cx));
+ cx.update(|window, _| window.refresh());
+ cx.run_until_parked();
+ assert_eq!(open_menu(&chat, cx), "");
+ }
+
+ /// A wheel event can accompany a secondary click without moving the
+ /// list. Only a change in position dismisses the task menu.
+ #[gpui::test]
+ fn test_task_menu_stays_open_when_a_scroll_does_not_move_the_list(cx: &mut TestAppContext) {
+ let (chat, cx) = chat_window(cx, |this, cx| {
+ this.sessions = (1..=40)
+ .map(|ix| summary(&format!("s{ix}"), &format!("Task {ix}")))
+ .collect();
+ this.sync_sidebar(cx);
+ });
+ let list = chat.update(cx, |this, cx| this.sidebar.read(cx).list().clone());
+ right_click(cx, "session-s2");
+ let row = on_screen(cx, "session-s2");
+ let beside = gpui::point(row.left() + px(8.), row.center().y + px(120.));
+ let scroll = |cx: &mut gpui::VisualTestContext, x, y| {
+ cx.simulate_event(gpui::ScrollWheelEvent {
+ position: beside,
+ delta: gpui::ScrollDelta::Pixels(gpui::point(px(x), px(y))),
+ ..Default::default()
+ });
+ cx.run_until_parked();
+ };
+ let offset = || {
+ let top = list.logical_scroll_top();
+ (top.item_ix, top.offset_in_item)
+ };
+ let before = offset();
+ for (x, y) in [(0., 0.), (0., 20.), (20., 0.)] {
+ scroll(cx, x, y);
+ assert_eq!(offset(), before, "the list did not move");
+ assert_eq!(open_menu(&chat, cx), "Task(s2)");
+ on_screen(cx, "task-menu-s2");
+ }
+
+ // A single pixel within the same first item still counts as a scroll.
+ scroll(cx, 0., -1.);
+ assert_eq!(offset().0, before.0);
+ assert_ne!(offset().1, before.1);
+ assert_eq!(open_menu(&chat, cx), "");
+
+ right_click(cx, "session-s2");
+ let before = offset();
+ scroll(cx, 0., 0.);
+ assert_eq!(offset(), before);
+ assert_eq!(open_menu(&chat, cx), "Task(s2)");
+ on_screen(cx, "task-menu-s2");
+ chat.update(cx, |this, cx| this.step_task(1, cx));
+ cx.run_until_parked();
+ assert_eq!(open_menu(&chat, cx), "", "revealing a row closes the menu");
+ }
+
/// Opened from the keyboard, the switcher starts on the current scope;
/// Application Vim's j reaches a project and Enter scopes the sidebar
/// to it.
@@ -5776,14 +6022,34 @@ mod state_tests {
/// those bounds back; one extra frame covers a park that finished
/// before that paint.
fn press(cx: &mut gpui::VisualTestContext, selector: &'static str) {
- let bounds = (0..2)
+ let at = on_screen(cx, selector).center();
+ cx.simulate_click(at, gpui::Modifiers::default());
+ }
+
+ /// Right-click the middle of the element with `selector`.
+ fn right_click(cx: &mut gpui::VisualTestContext, selector: &'static str) {
+ let at = on_screen(cx, selector).center();
+ right_click_at(cx, at);
+ }
+
+ fn right_click_at(cx: &mut gpui::VisualTestContext, at: gpui::Point) {
+ cx.simulate_mouse_down(at, gpui::MouseButton::Right, gpui::Modifiers::default());
+ cx.simulate_mouse_up(at, gpui::MouseButton::Right, gpui::Modifiers::default());
+ }
+
+ /// The bounds of the element with `selector`, once a refreshed frame
+ /// has drawn it (see [`press`]).
+ fn on_screen(
+ cx: &mut gpui::VisualTestContext,
+ selector: &'static str,
+ ) -> gpui::Bounds {
+ (0..2)
.find_map(|_| {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
cx.debug_bounds(selector)
})
- .unwrap_or_else(|| panic!("{selector} is not on screen"));
- cx.simulate_click(bounds.center(), gpui::Modifiers::default());
+ .unwrap_or_else(|| panic!("{selector} is not on screen"))
}
#[gpui::test]
@@ -5928,8 +6194,7 @@ mod state_tests {
}
/// A task menu reaches over the rows below it. A press on one of its
- /// items lands on the item, not on the overflow button of the row
- /// underneath.
+ /// items lands on the item, not on the row underneath.
#[gpui::test]
fn test_a_press_on_a_menu_item_reaches_the_item(cx: &mut TestAppContext) {
let (chat, cx) = chat_window(cx, |this, cx| {
@@ -5941,14 +6206,19 @@ mod state_tests {
this.sync_sidebar(cx);
});
press(cx, "menu-session-s1");
- let beneath = cx.debug_bounds("menu-session-s2").expect("s2's button");
+ let beneath = cx.debug_bounds("session-s2").expect("s2's row");
let rename = cx.debug_bounds("rename-task-s1").expect("s1's Rename item");
assert!(
- rename.contains(&beneath.center()),
- "the fixture puts s2's button under s1's Rename item"
+ beneath.contains(&rename.center()),
+ "the fixture puts s1's Rename item over s2's row"
+ );
+ cx.simulate_click(rename.center(), gpui::Modifiers::default());
+ assert_eq!(open_menu(&chat, cx), "");
+ assert_ne!(
+ chat.update(cx, |this, _| this.selected_session.clone()),
+ Some("s2".to_string()),
+ "the press did not select s2"
);
- cx.simulate_click(beneath.center(), gpui::Modifiers::default());
- assert_eq!(open_menu(&chat, cx), "", "s2's menu did not open");
let sidebar = chat.update(cx, |this, _| this.sidebar.clone());
assert!(
matches!(
@@ -6483,8 +6753,8 @@ mod state_tests {
rename(cx, "Pressed");
cx.update(|window, _| window.refresh());
cx.run_until_parked();
- let other_row = cx.debug_bounds("menu-session-s2").expect("s2's row");
- let on_row = other_row.center() - gpui::point(gpui::px(80.), gpui::px(0.));
+ let other_row = cx.debug_bounds("session-s2").expect("s2's row");
+ let on_row = other_row.center();
cx.simulate_mouse_down(on_row, gpui::MouseButton::Left, gpui::Modifiers::default());
cx.simulate_mouse_up(on_row, gpui::MouseButton::Left, gpui::Modifiers::default());
cx.run_until_parked();
@@ -6566,13 +6836,13 @@ mod state_tests {
this.sync_sidebar(cx);
});
let sidebar = chat.update(cx, |this, _| this.sidebar.clone());
- let rename_button = |cx: &mut gpui::VisualTestContext| {
+ let menu_button = |cx: &mut gpui::VisualTestContext| {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
- cx.debug_bounds("rename-session-s1")
+ cx.debug_bounds("menu-session-s1")
};
assert!(
- rename_button(cx).is_some(),
+ menu_button(cx).is_some(),
"a listed row has its hover actions"
);
@@ -6580,7 +6850,7 @@ mod state_tests {
sidebar.begin_rename(RenameTarget::Task("s1".into()), cx)
});
cx.run_until_parked();
- assert!(rename_button(cx).is_none(), "a row being renamed has none");
+ assert!(menu_button(cx).is_none(), "a row being renamed has none");
cx.simulate_input("Fresh");
let field = sidebar.update(cx, |sidebar, _| sidebar.inputs().last().cloned());
assert_eq!(
@@ -6590,7 +6860,7 @@ mod state_tests {
);
chat.update(cx, |this, cx| this.cancel_rename(cx));
- assert!(rename_button(cx).is_some(), "the actions come back after");
+ assert!(menu_button(cx).is_some(), "the actions come back after");
}
/// A project's rename field sits in the switcher. Escape ends the
diff --git a/apps/maple-agent/app/src/ui/popup.rs b/apps/maple-agent/app/src/ui/popup.rs
index fb69e291f..87114874c 100644
--- a/apps/maple-agent/app/src/ui/popup.rs
+++ b/apps/maple-agent/app/src/ui/popup.rs
@@ -16,9 +16,10 @@
//! right-click menu) counts as inside.
//! - The open popup holds keyboard focus. Up and Down (`j`/`k` under
//! Application Vim) move the highlight, Home and End (`g g`/`G`) jump,
-//! Enter or Space picks, Escape closes, and a typed letter jumps to the
-//! next row that starts with it. The pointer moves the same highlight. A
-//! text field inside the menu keeps its own keys.
+//! Enter or Space picks, Escape closes, and a typed letter picks the row
+//! with that shortcut, or else jumps to the next row that starts with
+//! it. The pointer moves the same highlight. A text field inside the
+//! menu keeps its own keys.
//! - One popup per window. Whatever takes focus next (another popup, a
//! dialog, a text field) closes the open one. When a popup that holds
//! focus closes, focus returns to where it was before it opened, also
@@ -462,6 +463,7 @@ impl Popup {
enabled: item.enabled,
keep_open: item.keep_open,
initial: initial(&item.label),
+ shortcut: item.shortcut.and_then(initial),
child: children.len(),
});
children.push(item_row(item, highlighted, get, cx));
@@ -890,6 +892,10 @@ pub(crate) struct MenuItem {
state: Option,
enabled: bool,
keep_open: bool,
+ /// The key that picks the row while the menu is open, as shown.
+ shortcut: Option<&'static str>,
+ /// Show enabled labels and icons in the error color.
+ destructive: bool,
trailing: Option,
style: Option,
on_select: OnSelect,
@@ -917,6 +923,8 @@ impl MenuItem {
state: None,
enabled: true,
keep_open: false,
+ shortcut: None,
+ destructive: false,
trailing: None,
style: None,
on_select: Rc::new(on_select),
@@ -965,6 +973,18 @@ impl MenuItem {
self
}
+ /// Show a letter shortcut that takes precedence over type-ahead.
+ pub(crate) fn shortcut(mut self, key: &'static str) -> Self {
+ self.shortcut = Some(key);
+ self
+ }
+
+ /// Show the row as destructive, in the error color.
+ pub(crate) fn destructive(mut self) -> Self {
+ self.destructive = true;
+ self
+ }
+
/// A control of the row's own at its end, such as a "…" button. A
/// press on it is the control's alone.
pub(crate) fn trailing(mut self, element: impl IntoElement) -> Self {
@@ -988,6 +1008,7 @@ struct Pick {
enabled: bool,
keep_open: bool,
initial: Option,
+ shortcut: Option,
child: usize,
}
@@ -1012,6 +1033,8 @@ fn item_row(
state,
enabled,
keep_open,
+ shortcut,
+ destructive,
trailing,
style,
on_select,
@@ -1021,10 +1044,10 @@ fn item_row(
Some(ItemState::Current(_)) => Role::MenuItemRadio,
Some(ItemState::Switch(_)) => Role::MenuItemCheckBox,
};
- let text = if enabled {
- theme::text_primary()
- } else {
- theme::text_muted()
+ let (text, icon_color) = match (enabled, destructive) {
+ (true, true) => (theme::status_error(), theme::status_error()),
+ (true, false) => (theme::text_primary(), theme::text_secondary()),
+ (false, _) => (theme::text_muted(), theme::text_secondary()),
};
let hovered_id = id.clone();
let row_element = div()
@@ -1072,7 +1095,7 @@ fn item_row(
on_select(view, window, cx);
}))
})
- .children(icon_name.map(|name| icon(name, widgets::ROW_ICON, theme::text_secondary())))
+ .children(icon_name.map(|name| icon(name, widgets::ROW_ICON, icon_color)))
.map(|row| match content {
Some(content) => row.child(div().flex_1().min_w_0().child(content)),
None => row.child(
@@ -1102,6 +1125,12 @@ fn item_row(
})),
),
})
+ .children(shortcut.map(|key| {
+ div()
+ .flex_none()
+ .text_color(gpui::rgb(theme::text_muted()))
+ .child(key)
+ }))
.children(trailing.map(|trailing| {
// The row's own control, such as a "…" button. A press on it is
// the control's alone: the row neither shows as pressed nor
@@ -1190,10 +1219,11 @@ fn pick(
(item.on_select)(view, window, cx);
}
-/// A letter typed into the menu highlights the next item that starts with
-/// it. Other typing stops here too: it must not reach a text field under
-/// the menu. Only while the menu itself has focus, so a field inside the
-/// menu still takes its typing.
+/// A letter typed into the menu picks the item with that shortcut, or
+/// else highlights the next item that starts with it. Other typing stops
+/// here too: it must not reach a text field under the menu. Only while
+/// the menu itself has focus, so a field inside the menu still takes its
+/// typing.
fn type_ahead(
view: &mut V,
get: fn(&mut V) -> &mut Popup,
@@ -1215,6 +1245,13 @@ fn type_ahead(
};
cx.stop_propagation();
let typed = initial(typed);
+ if let Some(row) = picks
+ .iter()
+ .position(|item| item.shortcut.is_some() && item.shortcut == typed)
+ {
+ pick(view, get, picks, row, window, cx);
+ return;
+ }
let popup = get(view);
let rows = picks.len();
let start = popup.highlighted_row(picks).map_or(0, |row| row + 1);