keymap_ui: Add context menu for table rows (#33747)

Closes #ISSUE

Adds a right click context menu to table rows, refactoring the table API
to support more general row rendering in the process, and creating
actions for the couple of operations available in the context menu.

Additionally includes an only partially related change to the context
menu API, which makes it easier to have actions that are disabled based
on a boolean value.

Release Notes:

- N/A *or* Added/Fixed/Improved ...
This commit is contained in:
Ben Kunkle 2025-07-01 22:06:45 -05:00 committed by GitHub
parent faca128304
commit 79f3cb1225
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 308 additions and 204 deletions

View file

@ -233,31 +233,25 @@ pub fn deploy_context_menu(
.action("Copy and Trim", Box::new(CopyAndTrim)) .action("Copy and Trim", Box::new(CopyAndTrim))
.action("Paste", Box::new(Paste)) .action("Paste", Box::new(Paste))
.separator() .separator()
.map(|builder| { .action_disabled_when(
let reveal_in_finder_label = if cfg!(target_os = "macos") { !has_reveal_target,
if cfg!(target_os = "macos") {
"Reveal in Finder" "Reveal in Finder"
} else { } else {
"Reveal in File Manager" "Reveal in File Manager"
}; },
const OPEN_IN_TERMINAL_LABEL: &str = "Open in Terminal"; Box::new(RevealInFileManager),
if has_reveal_target { )
builder .action_disabled_when(
.action(reveal_in_finder_label, Box::new(RevealInFileManager)) !has_reveal_target,
.action(OPEN_IN_TERMINAL_LABEL, Box::new(OpenInTerminal)) "Open in Terminal",
} else { Box::new(OpenInTerminal),
builder )
.disabled_action(reveal_in_finder_label, Box::new(RevealInFileManager)) .action_disabled_when(
.disabled_action(OPEN_IN_TERMINAL_LABEL, Box::new(OpenInTerminal)) !has_git_repo,
} "Copy Permalink",
}) Box::new(CopyPermalinkToLine),
.map(|builder| { );
const COPY_PERMALINK_LABEL: &str = "Copy Permalink";
if has_git_repo {
builder.action(COPY_PERMALINK_LABEL, Box::new(CopyPermalinkToLine))
} else {
builder.disabled_action(COPY_PERMALINK_LABEL, Box::new(CopyPermalinkToLine))
}
});
match focus { match focus {
Some(focus) => builder.context(focus), Some(focus) => builder.context(focus),
None => builder, None => builder,

View file

@ -122,40 +122,29 @@ fn git_panel_context_menu(
ContextMenu::build(window, cx, move |context_menu, _, _| { ContextMenu::build(window, cx, move |context_menu, _, _| {
context_menu context_menu
.context(focus_handle) .context(focus_handle)
.map(|menu| { .action_disabled_when(
if state.has_unstaged_changes { !state.has_unstaged_changes,
menu.action("Stage All", StageAll.boxed_clone()) "Stage All",
} else { StageAll.boxed_clone(),
menu.disabled_action("Stage All", StageAll.boxed_clone()) )
} .action_disabled_when(
}) !state.has_staged_changes,
.map(|menu| { "Unstage All",
if state.has_staged_changes { UnstageAll.boxed_clone(),
menu.action("Unstage All", UnstageAll.boxed_clone()) )
} else {
menu.disabled_action("Unstage All", UnstageAll.boxed_clone())
}
})
.separator() .separator()
.action("Open Diff", project_diff::Diff.boxed_clone()) .action("Open Diff", project_diff::Diff.boxed_clone())
.separator() .separator()
.map(|menu| { .action_disabled_when(
if state.has_tracked_changes { !state.has_tracked_changes,
menu.action("Discard Tracked Changes", RestoreTrackedFiles.boxed_clone())
} else {
menu.disabled_action(
"Discard Tracked Changes", "Discard Tracked Changes",
RestoreTrackedFiles.boxed_clone(), RestoreTrackedFiles.boxed_clone(),
) )
} .action_disabled_when(
}) !state.has_new_changes,
.map(|menu| { "Trash Untracked Files",
if state.has_new_changes { TrashUntrackedFiles.boxed_clone(),
menu.action("Trash Untracked Files", TrashUntrackedFiles.boxed_clone()) )
} else {
menu.disabled_action("Trash Untracked Files", TrashUntrackedFiles.boxed_clone())
}
})
}) })
} }

View file

@ -820,13 +820,11 @@ impl ProjectPanel {
.action("Copy", Box::new(Copy)) .action("Copy", Box::new(Copy))
.action("Duplicate", Box::new(Duplicate)) .action("Duplicate", Box::new(Duplicate))
// TODO: Paste should always be visible, cbut disabled when clipboard is empty // TODO: Paste should always be visible, cbut disabled when clipboard is empty
.map(|menu| { .action_disabled_when(
if self.clipboard.as_ref().is_some() { self.clipboard.as_ref().is_none(),
menu.action("Paste", Box::new(Paste)) "Paste",
} else { Box::new(Paste),
menu.disabled_action("Paste", Box::new(Paste)) )
}
})
.separator() .separator()
.action("Copy Path", Box::new(zed_actions::workspace::CopyPath)) .action("Copy Path", Box::new(zed_actions::workspace::CopyPath))
.action( .action(

View file

@ -9,7 +9,7 @@ use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{ use gpui::{
AppContext as _, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, AppContext as _, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
FontWeight, Global, KeyContext, Keystroke, ModifiersChangedEvent, ScrollStrategy, StyledText, FontWeight, Global, KeyContext, Keystroke, ModifiersChangedEvent, ScrollStrategy, StyledText,
Subscription, WeakEntity, actions, div, Subscription, WeakEntity, actions, div, transparent_black,
}; };
use language::{Language, LanguageConfig}; use language::{Language, LanguageConfig};
use settings::KeybindSource; use settings::KeybindSource;
@ -17,8 +17,8 @@ use settings::KeybindSource;
use util::ResultExt; use util::ResultExt;
use ui::{ use ui::{
ActiveTheme as _, App, BorrowAppContext, ParentElement as _, Render, SharedString, Styled as _, ActiveTheme as _, App, BorrowAppContext, ContextMenu, ParentElement as _, Render, SharedString,
Window, prelude::*, Styled as _, Window, prelude::*, right_click_menu,
}; };
use workspace::{Item, ModalView, SerializableItem, Workspace, register_serializable_item}; use workspace::{Item, ModalView, SerializableItem, Workspace, register_serializable_item};
@ -30,6 +30,9 @@ use crate::{
actions!(zed, [OpenKeymapEditor]); actions!(zed, [OpenKeymapEditor]);
const KEYMAP_EDITOR_NAMESPACE: &'static str = "keymap_editor";
actions!(keymap_editor, [EditBinding, CopyAction, CopyContext]);
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
let keymap_event_channel = KeymapEventChannel::new(); let keymap_event_channel = KeymapEventChannel::new();
cx.set_global(keymap_event_channel); cx.set_global(keymap_event_channel);
@ -59,6 +62,7 @@ pub fn init(cx: &mut App) {
command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _cx| { command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _cx| {
filter.hide_action_types(&keymap_ui_actions); filter.hide_action_types(&keymap_ui_actions);
filter.hide_namespace(KEYMAP_EDITOR_NAMESPACE);
}); });
cx.observe_flag::<SettingsUiFeatureFlag, _>( cx.observe_flag::<SettingsUiFeatureFlag, _>(
@ -69,6 +73,7 @@ pub fn init(cx: &mut App) {
cx, cx,
|filter, _cx| { |filter, _cx| {
filter.show_action_types(keymap_ui_actions.iter()); filter.show_action_types(keymap_ui_actions.iter());
filter.show_namespace(KEYMAP_EDITOR_NAMESPACE);
}, },
); );
} else { } else {
@ -76,6 +81,7 @@ pub fn init(cx: &mut App) {
cx, cx,
|filter, _cx| { |filter, _cx| {
filter.hide_action_types(&keymap_ui_actions); filter.hide_action_types(&keymap_ui_actions);
filter.hide_namespace(KEYMAP_EDITOR_NAMESPACE);
}, },
); );
} }
@ -231,8 +237,8 @@ impl KeymapEditor {
let context = key_binding let context = key_binding
.predicate() .predicate()
.map(|predicate| predicate.to_string()) .map(|predicate| KeybindContextString::Local(predicate.to_string().into()))
.unwrap_or_else(|| "<global>".to_string()); .unwrap_or(KeybindContextString::Global);
let source = source.map(|source| (source, source.name().into())); let source = source.map(|source| (source, source.name().into()));
@ -249,7 +255,7 @@ impl KeymapEditor {
ui_key_binding, ui_key_binding,
action: action_name.into(), action: action_name.into(),
action_input, action_input,
context: context.into(), context: Some(context),
source, source,
}); });
string_match_candidates.push(string_match_candidate); string_match_candidates.push(string_match_candidate);
@ -264,7 +270,7 @@ impl KeymapEditor {
ui_key_binding: None, ui_key_binding: None,
action: (*action_name).into(), action: (*action_name).into(),
action_input: None, action_input: None,
context: empty.clone(), context: None,
source: None, source: None,
}); });
string_match_candidates.push(string_match_candidate); string_match_candidates.push(string_match_candidate);
@ -345,6 +351,33 @@ impl KeymapEditor {
}); });
} }
fn focus_search(
&mut self,
_: &search::FocusSearch,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self
.filter_editor
.focus_handle(cx)
.contains_focused(window, cx)
{
window.focus(&self.filter_editor.focus_handle(cx));
} else {
self.filter_editor.update(cx, |editor, cx| {
editor.select_all(&Default::default(), window, cx);
});
}
self.selected_index.take();
}
fn selected_binding(&self) -> Option<&ProcessedKeybinding> {
self.selected_index
.and_then(|match_index| self.matches.get(match_index))
.map(|r#match| r#match.candidate_id)
.and_then(|keybind_index| self.keybindings.get(keybind_index))
}
fn select_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) { fn select_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context<Self>) {
if let Some(selected) = self.selected_index { if let Some(selected) = self.selected_index {
let selected = selected + 1; let selected = selected + 1;
@ -408,25 +441,18 @@ impl KeymapEditor {
} }
fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) { fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
let Some(index) = self.selected_index else { self.edit_selected_keybinding(window, cx);
return;
};
let keybind = self.keybindings[self.matches[index].candidate_id].clone();
self.edit_keybinding(keybind, window, cx);
} }
fn edit_keybinding( fn edit_selected_keybinding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
&mut self, let Some(keybind) = self.selected_binding() else {
keybind: ProcessedKeybinding, return;
window: &mut Window, };
cx: &mut Context<Self>,
) {
self.workspace self.workspace
.update(cx, |workspace, cx| { .update(cx, |workspace, cx| {
let fs = workspace.app_state().fs.clone(); let fs = workspace.app_state().fs.clone();
workspace.toggle_modal(window, cx, |window, cx| { workspace.toggle_modal(window, cx, |window, cx| {
let modal = KeybindingEditorModal::new(keybind, fs, window, cx); let modal = KeybindingEditorModal::new(keybind.clone(), fs, window, cx);
window.focus(&modal.focus_handle(cx)); window.focus(&modal.focus_handle(cx));
modal modal
}); });
@ -434,24 +460,40 @@ impl KeymapEditor {
.log_err(); .log_err();
} }
fn focus_search( fn edit_binding(&mut self, _: &EditBinding, window: &mut Window, cx: &mut Context<Self>) {
self.edit_selected_keybinding(window, cx);
}
fn copy_context_to_clipboard(
&mut self, &mut self,
_: &search::FocusSearch, _: &CopyContext,
window: &mut Window, _window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
if !self let context = self
.filter_editor .selected_binding()
.focus_handle(cx) .and_then(|binding| binding.context.as_ref())
.contains_focused(window, cx) .and_then(KeybindContextString::local_str)
{ .map(|context| context.to_string());
window.focus(&self.filter_editor.focus_handle(cx)); let Some(context) = context else {
} else { return;
self.filter_editor.update(cx, |editor, cx| { };
editor.select_all(&Default::default(), window, cx); cx.write_to_clipboard(gpui::ClipboardItem::new_string(context.clone()));
});
} }
self.selected_index.take();
fn copy_action_to_clipboard(
&mut self,
_: &CopyAction,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let action = self
.selected_binding()
.map(|binding| binding.action.to_string());
let Some(action) = action else {
return;
};
cx.write_to_clipboard(gpui::ClipboardItem::new_string(action.clone()));
} }
} }
@ -461,10 +503,43 @@ struct ProcessedKeybinding {
ui_key_binding: Option<ui::KeyBinding>, ui_key_binding: Option<ui::KeyBinding>,
action: SharedString, action: SharedString,
action_input: Option<TextWithSyntaxHighlighting>, action_input: Option<TextWithSyntaxHighlighting>,
context: SharedString, context: Option<KeybindContextString>,
source: Option<(KeybindSource, SharedString)>, source: Option<(KeybindSource, SharedString)>,
} }
#[derive(Clone, Debug, IntoElement)]
enum KeybindContextString {
Global,
Local(SharedString),
}
impl KeybindContextString {
const GLOBAL: SharedString = SharedString::new_static("<global>");
pub fn local(&self) -> Option<&SharedString> {
match self {
KeybindContextString::Global => None,
KeybindContextString::Local(name) => Some(name),
}
}
pub fn local_str(&self) -> Option<&str> {
match self {
KeybindContextString::Global => None,
KeybindContextString::Local(name) => Some(name),
}
}
}
impl RenderOnce for KeybindContextString {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
match self {
KeybindContextString::Global => KeybindContextString::GLOBAL.clone(),
KeybindContextString::Local(name) => name,
}
}
}
impl Item for KeymapEditor { impl Item for KeymapEditor {
type Event = (); type Event = ();
@ -486,6 +561,9 @@ impl Render for KeymapEditor {
.on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::select_last))
.on_action(cx.listener(Self::focus_search)) .on_action(cx.listener(Self::focus_search))
.on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::edit_binding))
.on_action(cx.listener(Self::copy_action_to_clipboard))
.on_action(cx.listener(Self::copy_context_to_clipboard))
.size_full() .size_full()
.bg(theme.colors().editor_background) .bg(theme.colors().editor_background)
.id("keymap-editor") .id("keymap-editor")
@ -514,10 +592,6 @@ impl Render for KeymapEditor {
.striped() .striped()
.column_widths([rems(16.), rems(16.), rems(16.), rems(32.), rems(8.)]) .column_widths([rems(16.), rems(16.), rems(16.), rems(32.), rems(8.)])
.header(["Action", "Arguments", "Keystrokes", "Context", "Source"]) .header(["Action", "Arguments", "Keystrokes", "Context", "Source"])
.selected_item_index(self.selected_index)
.on_click_row(cx.processor(|this, row_index, _window, _cx| {
this.selected_index = Some(row_index);
}))
.uniform_list( .uniform_list(
"keymap-editor-table", "keymap-editor-table",
row_count, row_count,
@ -538,7 +612,12 @@ impl Render for KeymapEditor {
.map_or(gpui::Empty.into_any_element(), |input| { .map_or(gpui::Empty.into_any_element(), |input| {
input.into_any_element() input.into_any_element()
}); });
let context = binding.context.clone().into_any_element(); let context = binding
.context
.clone()
.map_or(gpui::Empty.into_any_element(), |context| {
context.into_any_element()
});
let source = binding let source = binding
.source .source
.clone() .clone()
@ -549,6 +628,43 @@ impl Render for KeymapEditor {
}) })
.collect() .collect()
}), }),
)
.map_row(
cx.processor(|this, (row_index, row): (usize, Div), _window, cx| {
let is_selected = this.selected_index == Some(row_index);
let row = row
.id(("keymap-table-row", row_index))
.on_click(cx.listener(move |this, _event, _window, _cx| {
this.selected_index = Some(row_index);
}))
.border_2()
.border_color(transparent_black())
.when(is_selected, |row| {
row.border_color(cx.theme().colors().panel_focused_border)
});
right_click_menu(("keymap-table-row-menu", row_index))
.trigger({
let this = cx.weak_entity();
move |is_menu_open: bool, _window, cx| {
if is_menu_open {
this.update(cx, |this, cx| {
if this.selected_index != Some(row_index) {
this.selected_index = Some(row_index);
cx.notify();
}
})
.ok();
}
row
}
})
.menu({
let this = cx.weak_entity();
move |window, cx| build_keybind_context_menu(&this, window, cx)
})
.into_any_element()
}),
), ),
) )
} }
@ -712,7 +828,7 @@ impl Render for KeybindingEditorModal {
.await .await
{ {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.error = Some(err); this.error = Some(err.to_string());
cx.notify(); cx.notify();
}) })
.log_err(); .log_err();
@ -741,54 +857,55 @@ async fn save_keybinding_update(
new_keystrokes: &[Keystroke], new_keystrokes: &[Keystroke],
fs: &Arc<dyn Fs>, fs: &Arc<dyn Fs>,
tab_size: usize, tab_size: usize,
) -> Result<(), String> { ) -> anyhow::Result<()> {
let keymap_contents = settings::KeymapFile::load_keymap_file(fs) let keymap_contents = settings::KeymapFile::load_keymap_file(fs)
.await .await
.map_err(|err| format!("Failed to load keymap file: {}", err))?; .context("Failed to load keymap file")?;
let existing_keystrokes = existing let existing_keystrokes = existing
.ui_key_binding .ui_key_binding
.as_ref() .as_ref()
.map(|keybinding| keybinding.key_binding.keystrokes()) .map(|keybinding| keybinding.key_binding.keystrokes())
.unwrap_or_default(); .unwrap_or_default();
let context = existing
.context
.as_ref()
.and_then(KeybindContextString::local_str);
let input = existing
.action_input
.as_ref()
.map(|input| input.text.as_ref());
let operation = if existing.ui_key_binding.is_some() { let operation = if existing.ui_key_binding.is_some() {
settings::KeybindUpdateOperation::Replace { settings::KeybindUpdateOperation::Replace {
target: settings::KeybindUpdateTarget { target: settings::KeybindUpdateTarget {
context: Some(existing.context.as_ref()).filter(|context| !context.is_empty()), context,
keystrokes: existing_keystrokes, keystrokes: existing_keystrokes,
action_name: &existing.action, action_name: &existing.action,
use_key_equivalents: false, use_key_equivalents: false,
input: existing input,
.action_input
.as_ref()
.map(|input| input.text.as_ref()),
}, },
target_source: existing target_source: existing
.source .source
.map(|(source, _name)| source) .map(|(source, _name)| source)
.unwrap_or(KeybindSource::User), .unwrap_or(KeybindSource::User),
source: settings::KeybindUpdateTarget { source: settings::KeybindUpdateTarget {
context: Some(existing.context.as_ref()).filter(|context| !context.is_empty()), context,
keystrokes: new_keystrokes, keystrokes: new_keystrokes,
action_name: &existing.action, action_name: &existing.action,
use_key_equivalents: false, use_key_equivalents: false,
input: existing input,
.action_input
.as_ref()
.map(|input| input.text.as_ref()),
}, },
} }
} else { } else {
return Err( anyhow::bail!("Adding new bindings not implemented yet");
"Not Implemented: Creating new bindings from unbound actions is not supported yet."
.to_string(),
);
}; };
let updated_keymap_contents = let updated_keymap_contents =
settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size) settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size)
.map_err(|err| format!("Failed to update keybinding: {}", err))?; .context("Failed to update keybinding")?;
fs.atomic_write(paths::keymap_file().clone(), updated_keymap_contents) fs.atomic_write(paths::keymap_file().clone(), updated_keymap_contents)
.await .await
.map_err(|err| format!("Failed to write keymap file: {}", err))?; .context("Failed to write keymap file")?;
Ok(()) Ok(())
} }
@ -903,6 +1020,36 @@ impl Render for KeybindInput {
} }
} }
fn build_keybind_context_menu(
this: &WeakEntity<KeymapEditor>,
window: &mut Window,
cx: &mut App,
) -> Entity<ContextMenu> {
ContextMenu::build(window, cx, |menu, _window, cx| {
let Some(this) = this.upgrade() else {
return menu;
};
let selected_binding = this.read_with(cx, |this, _cx| this.selected_binding().cloned());
let Some(selected_binding) = selected_binding else {
return menu;
};
let selected_binding_has_context = selected_binding
.context
.as_ref()
.and_then(KeybindContextString::local)
.is_some();
menu.action("Edit Binding", Box::new(EditBinding))
.action("Copy action", Box::new(CopyAction))
.action_disabled_when(
!selected_binding_has_context,
"Copy Context",
Box::new(CopyContext),
)
})
}
impl SerializableItem for KeymapEditor { impl SerializableItem for KeymapEditor {
fn serialized_item_kind() -> &'static str { fn serialized_item_kind() -> &'static str {
"KeymapEditor" "KeymapEditor"

View file

@ -155,8 +155,6 @@ impl TableInteractionState {
self.vertical_scrollbar.hide(window, cx); self.vertical_scrollbar.hide(window, cx);
} }
// fn listener(this: Entity<Self>, fn: F) ->
pub fn listener<E: ?Sized>( pub fn listener<E: ?Sized>(
this: &Entity<Self>, this: &Entity<Self>,
f: impl Fn(&mut Self, &E, &mut Window, &mut Context<Self>) + 'static, f: impl Fn(&mut Self, &E, &mut Window, &mut Context<Self>) + 'static,
@ -353,9 +351,8 @@ pub struct Table<const COLS: usize = 3> {
headers: Option<[AnyElement; COLS]>, headers: Option<[AnyElement; COLS]>,
rows: TableContents<COLS>, rows: TableContents<COLS>,
interaction_state: Option<WeakEntity<TableInteractionState>>, interaction_state: Option<WeakEntity<TableInteractionState>>,
selected_item_index: Option<usize>,
column_widths: Option<[Length; COLS]>, column_widths: Option<[Length; COLS]>,
on_click_row: Option<Rc<dyn Fn(usize, &mut Window, &mut App)>>, map_row: Option<Rc<dyn Fn((usize, Div), &mut Window, &mut App) -> AnyElement>>,
} }
impl<const COLS: usize> Table<COLS> { impl<const COLS: usize> Table<COLS> {
@ -367,9 +364,8 @@ impl<const COLS: usize> Table<COLS> {
headers: None, headers: None,
rows: TableContents::Vec(Vec::new()), rows: TableContents::Vec(Vec::new()),
interaction_state: None, interaction_state: None,
selected_item_index: None,
column_widths: None, column_widths: None,
on_click_row: None, map_row: None,
} }
} }
@ -418,11 +414,6 @@ impl<const COLS: usize> Table<COLS> {
self self
} }
pub fn selected_item_index(mut self, selected_item_index: Option<usize>) -> Self {
self.selected_item_index = selected_item_index;
self
}
pub fn header(mut self, headers: [impl IntoElement; COLS]) -> Self { pub fn header(mut self, headers: [impl IntoElement; COLS]) -> Self {
self.headers = Some(headers.map(IntoElement::into_any_element)); self.headers = Some(headers.map(IntoElement::into_any_element));
self self
@ -440,11 +431,11 @@ impl<const COLS: usize> Table<COLS> {
self self
} }
pub fn on_click_row( pub fn map_row(
mut self, mut self,
callback: impl Fn(usize, &mut Window, &mut App) + 'static, callback: impl Fn((usize, Div), &mut Window, &mut App) -> AnyElement + 'static,
) -> Self { ) -> Self {
self.on_click_row = Some(Rc::new(callback)); self.map_row = Some(Rc::new(callback));
self self
} }
} }
@ -465,7 +456,8 @@ pub fn render_row<const COLS: usize>(
row_index: usize, row_index: usize,
items: [impl IntoElement; COLS], items: [impl IntoElement; COLS],
table_context: TableRenderContext<COLS>, table_context: TableRenderContext<COLS>,
cx: &App, window: &mut Window,
cx: &mut App,
) -> AnyElement { ) -> AnyElement {
let is_striped = table_context.striped; let is_striped = table_context.striped;
let is_last = row_index == table_context.total_row_count - 1; let is_last = row_index == table_context.total_row_count - 1;
@ -477,16 +469,8 @@ pub fn render_row<const COLS: usize>(
let column_widths = table_context let column_widths = table_context
.column_widths .column_widths
.map_or([None; COLS], |widths| widths.map(Some)); .map_or([None; COLS], |widths| widths.map(Some));
let is_selected = table_context.selected_item_index == Some(row_index);
let row = div() let row = div().w_full().child(
.w_full()
.border_2()
.border_color(transparent_black())
.when(is_selected, |row| {
row.border_color(cx.theme().colors().panel_focused_border)
})
.child(
div() div()
.w_full() .w_full()
.flex() .flex()
@ -510,10 +494,8 @@ pub fn render_row<const COLS: usize>(
), ),
); );
if let Some(on_click) = table_context.on_click_row { if let Some(map_row) = table_context.map_row {
row.id(("table-row", row_index)) map_row((row_index, row), window, cx)
.on_click(move |_, window, cx| on_click(row_index, window, cx))
.into_any_element()
} else { } else {
row.into_any_element() row.into_any_element()
} }
@ -547,9 +529,8 @@ pub fn render_header<const COLS: usize>(
pub struct TableRenderContext<const COLS: usize> { pub struct TableRenderContext<const COLS: usize> {
pub striped: bool, pub striped: bool,
pub total_row_count: usize, pub total_row_count: usize,
pub selected_item_index: Option<usize>,
pub column_widths: Option<[Length; COLS]>, pub column_widths: Option<[Length; COLS]>,
pub on_click_row: Option<Rc<dyn Fn(usize, &mut Window, &mut App)>>, pub map_row: Option<Rc<dyn Fn((usize, Div), &mut Window, &mut App) -> AnyElement>>,
} }
impl<const COLS: usize> TableRenderContext<COLS> { impl<const COLS: usize> TableRenderContext<COLS> {
@ -558,14 +539,13 @@ impl<const COLS: usize> TableRenderContext<COLS> {
striped: table.striped, striped: table.striped,
total_row_count: table.rows.len(), total_row_count: table.rows.len(),
column_widths: table.column_widths, column_widths: table.column_widths,
selected_item_index: table.selected_item_index, map_row: table.map_row.clone(),
on_click_row: table.on_click_row.clone(),
} }
} }
} }
impl<const COLS: usize> RenderOnce for Table<COLS> { impl<const COLS: usize> RenderOnce for Table<COLS> {
fn render(mut self, _window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let table_context = TableRenderContext::new(&self); let table_context = TableRenderContext::new(&self);
let interaction_state = self.interaction_state.and_then(|state| state.upgrade()); let interaction_state = self.interaction_state.and_then(|state| state.upgrade());
@ -598,7 +578,7 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
.map(|parent| match self.rows { .map(|parent| match self.rows {
TableContents::Vec(items) => { TableContents::Vec(items) => {
parent.children(items.into_iter().enumerate().map(|(index, row)| { parent.children(items.into_iter().enumerate().map(|(index, row)| {
render_row(index, row, table_context.clone(), cx) render_row(index, row, table_context.clone(), window, cx)
})) }))
} }
TableContents::UniformList(uniform_list_data) => parent.child( TableContents::UniformList(uniform_list_data) => parent.child(
@ -617,6 +597,7 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
row_index, row_index,
row, row,
table_context.clone(), table_context.clone(),
window,
cx, cx,
) )
}) })

View file

@ -503,8 +503,9 @@ impl ContextMenu {
self self
} }
pub fn disabled_action( pub fn action_disabled_when(
mut self, mut self,
disabled: bool,
label: impl Into<SharedString>, label: impl Into<SharedString>,
action: Box<dyn Action>, action: Box<dyn Action>,
) -> Self { ) -> Self {
@ -522,7 +523,7 @@ impl ContextMenu {
icon_size: IconSize::Small, icon_size: IconSize::Small,
icon_position: IconPosition::End, icon_position: IconPosition::End,
icon_color: None, icon_color: None,
disabled: true, disabled,
documentation_aside: None, documentation_aside: None,
end_slot_icon: None, end_slot_icon: None,
end_slot_title: None, end_slot_title: None,

View file

@ -9,7 +9,7 @@ use gpui::{
pub struct RightClickMenu<M: ManagedView> { pub struct RightClickMenu<M: ManagedView> {
id: ElementId, id: ElementId,
child_builder: Option<Box<dyn FnOnce(bool) -> AnyElement + 'static>>, child_builder: Option<Box<dyn FnOnce(bool, &mut Window, &mut App) -> AnyElement + 'static>>,
menu_builder: Option<Rc<dyn Fn(&mut Window, &mut App) -> Entity<M> + 'static>>, menu_builder: Option<Rc<dyn Fn(&mut Window, &mut App) -> Entity<M> + 'static>>,
anchor: Option<Corner>, anchor: Option<Corner>,
attach: Option<Corner>, attach: Option<Corner>,
@ -23,11 +23,11 @@ impl<M: ManagedView> RightClickMenu<M> {
pub fn trigger<F, E>(mut self, e: F) -> Self pub fn trigger<F, E>(mut self, e: F) -> Self
where where
F: FnOnce(bool) -> E + 'static, F: FnOnce(bool, &mut Window, &mut App) -> E + 'static,
E: IntoElement + 'static, E: IntoElement + 'static,
{ {
self.child_builder = Some(Box::new(move |is_menu_active| { self.child_builder = Some(Box::new(move |is_menu_active, window, cx| {
e(is_menu_active).into_any_element() e(is_menu_active, window, cx).into_any_element()
})); }));
self self
} }
@ -149,10 +149,9 @@ impl<M: ManagedView> Element for RightClickMenu<M> {
element element
}); });
let mut child_element = this let mut child_element = this.child_builder.take().map(|child_builder| {
.child_builder (child_builder)(element_state.menu.borrow().is_some(), window, cx)
.take() });
.map(|child_builder| (child_builder)(element_state.menu.borrow().is_some()));
let child_layout_id = child_element let child_layout_id = child_element
.as_mut() .as_mut()

View file

@ -47,12 +47,12 @@ impl Render for ContextMenuStory {
.justify_between() .justify_between()
.child( .child(
right_click_menu("test2") right_click_menu("test2")
.trigger(|_| Label::new("TOP LEFT")) .trigger(|_, _, _| Label::new("TOP LEFT"))
.menu(move |window, cx| build_menu(window, cx, "top left")), .menu(move |window, cx| build_menu(window, cx, "top left")),
) )
.child( .child(
right_click_menu("test1") right_click_menu("test1")
.trigger(|_| Label::new("BOTTOM LEFT")) .trigger(|_, _, _| Label::new("BOTTOM LEFT"))
.anchor(Corner::BottomLeft) .anchor(Corner::BottomLeft)
.attach(Corner::TopLeft) .attach(Corner::TopLeft)
.menu(move |window, cx| build_menu(window, cx, "bottom left")), .menu(move |window, cx| build_menu(window, cx, "bottom left")),
@ -65,13 +65,13 @@ impl Render for ContextMenuStory {
.justify_between() .justify_between()
.child( .child(
right_click_menu("test3") right_click_menu("test3")
.trigger(|_| Label::new("TOP RIGHT")) .trigger(|_, _, _| Label::new("TOP RIGHT"))
.anchor(Corner::TopRight) .anchor(Corner::TopRight)
.menu(move |window, cx| build_menu(window, cx, "top right")), .menu(move |window, cx| build_menu(window, cx, "top right")),
) )
.child( .child(
right_click_menu("test4") right_click_menu("test4")
.trigger(|_| Label::new("BOTTOM RIGHT")) .trigger(|_, _, _| Label::new("BOTTOM RIGHT"))
.anchor(Corner::BottomRight) .anchor(Corner::BottomRight)
.attach(Corner::TopRight) .attach(Corner::TopRight)
.menu(move |window, cx| build_menu(window, cx, "bottom right")), .menu(move |window, cx| build_menu(window, cx, "bottom right")),

View file

@ -902,7 +902,7 @@ impl Render for PanelButtons {
}) })
.anchor(menu_anchor) .anchor(menu_anchor)
.attach(menu_attach) .attach(menu_attach)
.trigger(move |is_active| { .trigger(move |is_active, _window, _cx| {
IconButton::new(name, icon) IconButton::new(name, icon)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
.toggle_state(is_active_button) .toggle_state(is_active_button)

View file

@ -2521,7 +2521,7 @@ impl Pane {
let pane = cx.entity().downgrade(); let pane = cx.entity().downgrade();
let menu_context = item.item_focus_handle(cx); let menu_context = item.item_focus_handle(cx);
right_click_menu(ix) right_click_menu(ix)
.trigger(|_| tab) .trigger(|_, _, _| tab)
.menu(move |window, cx| { .menu(move |window, cx| {
let pane = pane.clone(); let pane = pane.clone();
let menu_context = menu_context.clone(); let menu_context = menu_context.clone();

View file

@ -4311,6 +4311,7 @@ mod tests {
"icon_theme_selector", "icon_theme_selector",
"jj", "jj",
"journal", "journal",
"keymap_editor",
"language_selector", "language_selector",
"lsp_tool", "lsp_tool",
"markdown", "markdown",

View file

@ -258,18 +258,12 @@ impl Render for QuickActionBar {
.action("Next Problem", Box::new(GoToDiagnostic)) .action("Next Problem", Box::new(GoToDiagnostic))
.action("Previous Problem", Box::new(GoToPreviousDiagnostic)) .action("Previous Problem", Box::new(GoToPreviousDiagnostic))
.separator() .separator()
.map(|menu| { .action_disabled_when(!has_diff_hunks, "Next Hunk", Box::new(GoToHunk))
if has_diff_hunks { .action_disabled_when(
menu.action("Next Hunk", Box::new(GoToHunk)) !has_diff_hunks,
.action("Previous Hunk", Box::new(GoToPreviousHunk))
} else {
menu.disabled_action("Next Hunk", Box::new(GoToHunk))
.disabled_action(
"Previous Hunk", "Previous Hunk",
Box::new(GoToPreviousHunk), Box::new(GoToPreviousHunk),
) )
}
})
.separator() .separator()
.action("Move Line Up", Box::new(MoveLineUp)) .action("Move Line Up", Box::new(MoveLineUp))
.action("Move Line Down", Box::new(MoveLineDown)) .action("Move Line Down", Box::new(MoveLineDown))