diff --git a/Cargo.lock b/Cargo.lock index e684783cad..b7868f2c67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14569,13 +14569,22 @@ dependencies = [ name = "settings_ui" version = "0.1.0" dependencies = [ + "collections", + "command_palette", "command_palette_hooks", + "component", + "db", "editor", "feature_flags", "fs", + "fuzzy", "gpui", "log", + "menu", + "paths", + "project", "schemars", + "search", "serde", "settings", "theme", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index ca94fd4853..c88df28b99 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1067,5 +1067,12 @@ "ctrl-tab": "pane::ActivateNextItem", "ctrl-shift-tab": "pane::ActivatePreviousItem" } + }, + { + "context": "KeymapEditor", + "use_key_equivalents": true, + "bindings": { + "ctrl-f": "search::FocusSearch" + } } ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index fa38480c37..7d6ce6e80a 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1167,5 +1167,12 @@ "ctrl-tab": "pane::ActivateNextItem", "ctrl-shift-tab": "pane::ActivatePreviousItem" } + }, + { + "context": "KeymapEditor", + "use_key_equivalents": true, + "bindings": { + "cmd-f": "search::FocusSearch" + } } ] diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 2e411fd139..abb8978d5a 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -41,7 +41,7 @@ pub struct CommandPalette { /// Removes subsequent whitespace characters and double colons from the query. /// /// This improves the likelihood of a match by either humanized name or keymap-style name. -fn normalize_query(input: &str) -> String { +pub fn normalize_action_query(input: &str) -> String { let mut result = String::with_capacity(input.len()); let mut last_char = None; @@ -297,7 +297,7 @@ impl PickerDelegate for CommandPaletteDelegate { let mut commands = self.all_commands.clone(); let hit_counts = self.hit_counts(); let executor = cx.background_executor().clone(); - let query = normalize_query(query.as_str()); + let query = normalize_action_query(query.as_str()); async move { commands.sort_by_key(|action| { ( @@ -311,29 +311,17 @@ impl PickerDelegate for CommandPaletteDelegate { .enumerate() .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name)) .collect::>(); - let matches = if query.is_empty() { - candidates - .into_iter() - .enumerate() - .map(|(index, candidate)| StringMatch { - candidate_id: index, - string: candidate.string, - positions: Vec::new(), - score: 0.0, - }) - .collect() - } else { - fuzzy::match_strings( - &candidates, - &query, - true, - true, - 10000, - &Default::default(), - executor, - ) - .await - }; + + let matches = fuzzy::match_strings( + &candidates, + &query, + true, + true, + 10000, + &Default::default(), + executor, + ) + .await; tx.send((commands, matches)).await.log_err(); } @@ -422,8 +410,8 @@ impl PickerDelegate for CommandPaletteDelegate { window: &mut Window, cx: &mut Context>, ) -> Option { - let r#match = self.matches.get(ix)?; - let command = self.commands.get(r#match.candidate_id)?; + let matching_command = self.matches.get(ix)?; + let command = self.commands.get(matching_command.candidate_id)?; Some( ListItem::new(ix) .inset(true) @@ -436,7 +424,7 @@ impl PickerDelegate for CommandPaletteDelegate { .justify_between() .child(HighlightedLabel::new( command.name.clone(), - r#match.positions.clone(), + matching_command.positions.clone(), )) .children(KeyBinding::for_action_in( &*command.action, @@ -512,19 +500,28 @@ mod tests { #[test] fn test_normalize_query() { - assert_eq!(normalize_query("editor: backspace"), "editor: backspace"); - assert_eq!(normalize_query("editor: backspace"), "editor: backspace"); - assert_eq!(normalize_query("editor: backspace"), "editor: backspace"); assert_eq!( - normalize_query("editor::GoToDefinition"), + normalize_action_query("editor: backspace"), + "editor: backspace" + ); + assert_eq!( + normalize_action_query("editor: backspace"), + "editor: backspace" + ); + assert_eq!( + normalize_action_query("editor: backspace"), + "editor: backspace" + ); + assert_eq!( + normalize_action_query("editor::GoToDefinition"), "editor:GoToDefinition" ); assert_eq!( - normalize_query("editor::::GoToDefinition"), + normalize_action_query("editor::::GoToDefinition"), "editor:GoToDefinition" ); assert_eq!( - normalize_query("editor: :GoToDefinition"), + normalize_action_query("editor: :GoToDefinition"), "editor: :GoToDefinition" ); } diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 5fee4c9047..2ecc3dadaa 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1334,6 +1334,11 @@ impl App { self.pending_effects.push_back(Effect::RefreshWindows); } + /// Get all key bindings in the app. + pub fn key_bindings(&self) -> Rc> { + self.keymap.clone() + } + /// Register a global listener for actions invoked via the keyboard. pub fn on_action(&mut self, listener: impl Fn(&A, &mut Self) + 'static) { self.global_action_listeners diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index bbc3454923..6e05b384e1 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -613,10 +613,10 @@ pub trait InteractiveElement: Sized { /// Track the focus state of the given focus handle on this element. /// If the focus handle is focused by the application, this element will /// apply its focused styles. - fn track_focus(mut self, focus_handle: &FocusHandle) -> FocusableWrapper { + fn track_focus(mut self, focus_handle: &FocusHandle) -> Self { self.interactivity().focusable = true; self.interactivity().tracked_focus_handle = Some(focus_handle.clone()); - FocusableWrapper { element: self } + self } /// Set the keymap context for this element. This will be used to determine @@ -980,15 +980,35 @@ pub trait InteractiveElement: Sized { self.interactivity().block_mouse_except_scroll(); self } + + /// Set the given styles to be applied when this element, specifically, is focused. + /// Requires that the element is focusable. Elements can be made focusable using [`InteractiveElement::track_focus`]. + fn focus(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self + where + Self: Sized, + { + self.interactivity().focus_style = Some(Box::new(f(StyleRefinement::default()))); + self + } + + /// Set the given styles to be applied when this element is inside another element that is focused. + /// Requires that the element is focusable. Elements can be made focusable using [`InteractiveElement::track_focus`]. + fn in_focus(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self + where + Self: Sized, + { + self.interactivity().in_focus_style = Some(Box::new(f(StyleRefinement::default()))); + self + } } /// A trait for elements that want to use the standard GPUI interactivity features /// that require state. pub trait StatefulInteractiveElement: InteractiveElement { /// Set this element to focusable. - fn focusable(mut self) -> FocusableWrapper { + fn focusable(mut self) -> Self { self.interactivity().focusable = true; - FocusableWrapper { element: self } + self } /// Set the overflow x and y to scroll. @@ -1118,27 +1138,6 @@ pub trait StatefulInteractiveElement: InteractiveElement { } } -/// A trait for providing focus related APIs to interactive elements -pub trait FocusableElement: InteractiveElement { - /// Set the given styles to be applied when this element, specifically, is focused. - fn focus(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self - where - Self: Sized, - { - self.interactivity().focus_style = Some(Box::new(f(StyleRefinement::default()))); - self - } - - /// Set the given styles to be applied when this element is inside another element that is focused. - fn in_focus(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self - where - Self: Sized, - { - self.interactivity().in_focus_style = Some(Box::new(f(StyleRefinement::default()))); - self - } -} - pub(crate) type MouseDownListener = Box; pub(crate) type MouseUpListener = @@ -2777,126 +2776,6 @@ impl GroupHitboxes { } } -/// A wrapper around an element that can be focused. -pub struct FocusableWrapper { - /// The element that is focusable - pub element: E, -} - -impl FocusableElement for FocusableWrapper {} - -impl InteractiveElement for FocusableWrapper -where - E: InteractiveElement, -{ - fn interactivity(&mut self) -> &mut Interactivity { - self.element.interactivity() - } -} - -impl StatefulInteractiveElement for FocusableWrapper {} - -impl Styled for FocusableWrapper -where - E: Styled, -{ - fn style(&mut self) -> &mut StyleRefinement { - self.element.style() - } -} - -impl FocusableWrapper
{ - /// Add a listener to be called when the children of this `Div` are prepainted. - /// This allows you to store the [`Bounds`] of the children for later use. - pub fn on_children_prepainted( - mut self, - listener: impl Fn(Vec>, &mut Window, &mut App) + 'static, - ) -> Self { - self.element = self.element.on_children_prepainted(listener); - self - } -} - -impl Element for FocusableWrapper -where - E: Element, -{ - type RequestLayoutState = E::RequestLayoutState; - type PrepaintState = E::PrepaintState; - - fn id(&self) -> Option { - self.element.id() - } - - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - self.element.source_location() - } - - fn request_layout( - &mut self, - id: Option<&GlobalElementId>, - inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - self.element.request_layout(id, inspector_id, window, cx) - } - - fn prepaint( - &mut self, - id: Option<&GlobalElementId>, - inspector_id: Option<&InspectorElementId>, - bounds: Bounds, - state: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> E::PrepaintState { - self.element - .prepaint(id, inspector_id, bounds, state, window, cx) - } - - fn paint( - &mut self, - id: Option<&GlobalElementId>, - inspector_id: Option<&InspectorElementId>, - bounds: Bounds, - request_layout: &mut Self::RequestLayoutState, - prepaint: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - self.element.paint( - id, - inspector_id, - bounds, - request_layout, - prepaint, - window, - cx, - ) - } -} - -impl IntoElement for FocusableWrapper -where - E: IntoElement, -{ - type Element = E::Element; - - fn into_element(self) -> Self::Element { - self.element.into_element() - } -} - -impl ParentElement for FocusableWrapper -where - E: ParentElement, -{ - fn extend(&mut self, elements: impl IntoIterator) { - self.element.extend(elements) - } -} - /// A wrapper around an element that can store state, produced after assigning an ElementId. pub struct Stateful { pub(crate) element: E, @@ -2927,8 +2806,6 @@ where } } -impl FocusableElement for Stateful {} - impl Element for Stateful where E: Element, diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index c613066777..993b319b69 100644 --- a/crates/gpui/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -25,7 +25,7 @@ use std::{ use thiserror::Error; use util::ResultExt; -use super::{FocusableElement, Stateful, StatefulInteractiveElement}; +use super::{Stateful, StatefulInteractiveElement}; /// The delay before showing the loading state. pub const LOADING_DELAY: Duration = Duration::from_millis(200); @@ -509,8 +509,6 @@ impl IntoElement for Img { } } -impl FocusableElement for Img {} - impl StatefulInteractiveElement for Img {} impl ImageSource { diff --git a/crates/gpui/src/keymap/binding.rs b/crates/gpui/src/keymap/binding.rs index ffc4656ff7..1d3f612c5b 100644 --- a/crates/gpui/src/keymap/binding.rs +++ b/crates/gpui/src/keymap/binding.rs @@ -2,7 +2,7 @@ use std::rc::Rc; use collections::HashMap; -use crate::{Action, InvalidKeystrokeError, KeyBindingContextPredicate, Keystroke}; +use crate::{Action, InvalidKeystrokeError, KeyBindingContextPredicate, Keystroke, SharedString}; use smallvec::SmallVec; /// A keybinding and its associated metadata, from the keymap. @@ -11,6 +11,8 @@ pub struct KeyBinding { pub(crate) keystrokes: SmallVec<[Keystroke; 2]>, pub(crate) context_predicate: Option>, pub(crate) meta: Option, + /// The json input string used when building the keybinding, if any + pub(crate) action_input: Option, } impl Clone for KeyBinding { @@ -20,6 +22,7 @@ impl Clone for KeyBinding { keystrokes: self.keystrokes.clone(), context_predicate: self.context_predicate.clone(), meta: self.meta, + action_input: self.action_input.clone(), } } } @@ -32,7 +35,7 @@ impl KeyBinding { } else { None }; - Self::load(keystrokes, Box::new(action), context_predicate, None).unwrap() + Self::load(keystrokes, Box::new(action), context_predicate, None, None).unwrap() } /// Load a keybinding from the given raw data. @@ -41,6 +44,7 @@ impl KeyBinding { action: Box, context_predicate: Option>, key_equivalents: Option<&HashMap>, + action_input: Option, ) -> std::result::Result { let mut keystrokes: SmallVec<[Keystroke; 2]> = keystrokes .split_whitespace() @@ -62,6 +66,7 @@ impl KeyBinding { action, context_predicate, meta: None, + action_input, }) } @@ -110,6 +115,11 @@ impl KeyBinding { pub fn meta(&self) -> Option { self.meta } + + /// Get the action input associated with the action for this binding + pub fn action_input(&self) -> Option { + self.action_input.clone() + } } impl std::fmt::Debug for KeyBinding { diff --git a/crates/gpui/src/prelude.rs b/crates/gpui/src/prelude.rs index 270f0a9341..191d0a0e6d 100644 --- a/crates/gpui/src/prelude.rs +++ b/crates/gpui/src/prelude.rs @@ -3,7 +3,7 @@ //! application to avoid having to import each trait individually. pub use crate::{ - AppContext as _, BorrowAppContext, Context, Element, FocusableElement, InteractiveElement, - IntoElement, ParentElement, Refineable, Render, RenderOnce, StatefulInteractiveElement, Styled, - StyledImage, VisualContext, util::FluentBuilder, + AppContext as _, BorrowAppContext, Context, Element, InteractiveElement, IntoElement, + ParentElement, Refineable, Render, RenderOnce, StatefulInteractiveElement, Styled, StyledImage, + VisualContext, util::FluentBuilder, }; diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 7ec34fde22..d97dff0880 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -3,7 +3,7 @@ use collections::{BTreeMap, HashMap, IndexMap}; use fs::Fs; use gpui::{ Action, ActionBuildError, App, InvalidKeystrokeError, KEYSTROKE_PARSE_EXPECTED_MESSAGE, - KeyBinding, KeyBindingContextPredicate, KeyBindingMetaIndex, NoAction, + KeyBinding, KeyBindingContextPredicate, KeyBindingMetaIndex, Keystroke, NoAction, SharedString, }; use schemars::{JsonSchema, json_schema}; use serde::Deserialize; @@ -399,7 +399,13 @@ impl KeymapFile { }, }; - let key_binding = match KeyBinding::load(keystrokes, action, context, key_equivalents) { + let key_binding = match KeyBinding::load( + keystrokes, + action, + context, + key_equivalents, + action_input_string.map(SharedString::from), + ) { Ok(key_binding) => key_binding, Err(InvalidKeystrokeError { keystroke }) => { return Err(format!( @@ -626,6 +632,13 @@ impl KeymapFile { continue; }; for (keystrokes, action) in bindings { + let Ok(keystrokes) = keystrokes + .split_whitespace() + .map(Keystroke::parse) + .collect::, _>>() + else { + continue; + }; if keystrokes != target.keystrokes { continue; } @@ -640,9 +653,9 @@ impl KeymapFile { if let Some(index) = found_index { let (replace_range, replace_value) = replace_top_level_array_value_in_json_text( &keymap_contents, - &["bindings", target.keystrokes], + &["bindings", &target.keystrokes_unparsed()], Some(&source_action_value), - Some(source.keystrokes), + Some(&source.keystrokes_unparsed()), index, tab_size, ) @@ -674,7 +687,7 @@ impl KeymapFile { value.insert("bindings".to_string(), { let mut bindings = serde_json::Map::new(); let action = keybinding.action_value()?; - bindings.insert(keybinding.keystrokes.into(), action); + bindings.insert(keybinding.keystrokes_unparsed(), action); bindings.into() }); @@ -701,11 +714,11 @@ pub enum KeybindUpdateOperation<'a> { } pub struct KeybindUpdateTarget<'a> { - context: Option<&'a str>, - keystrokes: &'a str, - action_name: &'a str, - use_key_equivalents: bool, - input: Option<&'a str>, + pub context: Option<&'a str>, + pub keystrokes: &'a [Keystroke], + pub action_name: &'a str, + pub use_key_equivalents: bool, + pub input: Option<&'a str>, } impl<'a> KeybindUpdateTarget<'a> { @@ -721,6 +734,16 @@ impl<'a> KeybindUpdateTarget<'a> { }; return Ok(value); } + + fn keystrokes_unparsed(&self) -> String { + let mut keystrokes = String::with_capacity(self.keystrokes.len() * 8); + for keystroke in self.keystrokes { + keystrokes.push_str(&keystroke.unparse()); + keystrokes.push(' '); + } + keystrokes.pop(); + keystrokes + } } #[derive(Clone, Copy, PartialEq, Eq)] @@ -804,6 +827,8 @@ mod tests { #[test] fn keymap_update() { + use gpui::Keystroke; + zlog::init_test(); #[track_caller] fn check_keymap_update( @@ -816,10 +841,18 @@ mod tests { pretty_assertions::assert_eq!(expected.to_string(), result); } + #[track_caller] + fn parse_keystrokes(keystrokes: &str) -> Vec { + return keystrokes + .split(' ') + .map(|s| Keystroke::parse(s).expect("Keystrokes valid")) + .collect(); + } + check_keymap_update( "[]", KeybindUpdateOperation::Add(KeybindUpdateTarget { - keystrokes: "ctrl-a", + keystrokes: &parse_keystrokes("ctrl-a"), action_name: "zed::SomeAction", context: None, use_key_equivalents: false, @@ -845,7 +878,7 @@ mod tests { ]"# .unindent(), KeybindUpdateOperation::Add(KeybindUpdateTarget { - keystrokes: "ctrl-b", + keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: None, use_key_equivalents: false, @@ -876,7 +909,7 @@ mod tests { ]"# .unindent(), KeybindUpdateOperation::Add(KeybindUpdateTarget { - keystrokes: "ctrl-b", + keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: None, use_key_equivalents: false, @@ -912,7 +945,7 @@ mod tests { ]"# .unindent(), KeybindUpdateOperation::Add(KeybindUpdateTarget { - keystrokes: "ctrl-b", + keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: Some("Zed > Editor && some_condition = true"), use_key_equivalents: true, @@ -951,14 +984,14 @@ mod tests { .unindent(), KeybindUpdateOperation::Replace { target: KeybindUpdateTarget { - keystrokes: "ctrl-a", + keystrokes: &parse_keystrokes("ctrl-a"), action_name: "zed::SomeAction", context: None, use_key_equivalents: false, input: None, }, source: KeybindUpdateTarget { - keystrokes: "ctrl-b", + keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: None, use_key_equivalents: false, @@ -997,14 +1030,14 @@ mod tests { .unindent(), KeybindUpdateOperation::Replace { target: KeybindUpdateTarget { - keystrokes: "ctrl-a", + keystrokes: &parse_keystrokes("ctrl-a"), action_name: "zed::SomeAction", context: None, use_key_equivalents: false, input: None, }, source: KeybindUpdateTarget { - keystrokes: "ctrl-b", + keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: None, use_key_equivalents: false, @@ -1038,14 +1071,14 @@ mod tests { .unindent(), KeybindUpdateOperation::Replace { target: KeybindUpdateTarget { - keystrokes: "ctrl-a", + keystrokes: &parse_keystrokes("ctrl-a"), action_name: "zed::SomeNonexistentAction", context: None, use_key_equivalents: false, input: None, }, source: KeybindUpdateTarget { - keystrokes: "ctrl-b", + keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: None, use_key_equivalents: false, @@ -1081,14 +1114,14 @@ mod tests { .unindent(), KeybindUpdateOperation::Replace { target: KeybindUpdateTarget { - keystrokes: "ctrl-a", + keystrokes: &parse_keystrokes("ctrl-a"), action_name: "zed::SomeAction", context: None, use_key_equivalents: false, input: None, }, source: KeybindUpdateTarget { - keystrokes: "ctrl-b", + keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: None, use_key_equivalents: false, diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index 0fe2c48e92..f690a2ea93 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -14,8 +14,8 @@ use util::asset_str; pub use editable_setting_control::*; pub use key_equivalents::*; pub use keymap_file::{ - KeyBindingValidator, KeyBindingValidatorRegistration, KeybindSource, KeymapFile, - KeymapFileLoadResult, + KeyBindingValidator, KeyBindingValidatorRegistration, KeybindSource, KeybindUpdateOperation, + KeybindUpdateTarget, KeymapFile, KeymapFileLoadResult, }; pub use settings_file::*; pub use settings_json::*; diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index bdc2320508..d2b78d03fc 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -618,7 +618,7 @@ impl SettingsStore { )); } - fn json_tab_size(&self) -> usize { + pub fn json_tab_size(&self) -> usize { const DEFAULT_JSON_TAB_SIZE: usize = 2; if let Some((setting_type_id, callback)) = &self.tab_size_callback { diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 84d77e3fdc..7b01fcc0e6 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -12,12 +12,21 @@ workspace = true path = "src/settings_ui.rs" [dependencies] +command_palette.workspace = true command_palette_hooks.workspace = true +component.workspace = true +collections.workspace = true +db.workspace = true editor.workspace = true feature_flags.workspace = true fs.workspace = true +fuzzy.workspace = true gpui.workspace = true log.workspace = true +menu.workspace = true +paths.workspace = true +project.workspace = true +search.workspace = true schemars.workspace = true serde.workspace = true settings.workspace = true diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs new file mode 100644 index 0000000000..6021f74a46 --- /dev/null +++ b/crates/settings_ui/src/keybindings.rs @@ -0,0 +1,902 @@ +use std::{ops::Range, sync::Arc}; + +use collections::HashSet; +use db::anyhow::anyhow; +use editor::{Editor, EditorEvent}; +use feature_flags::FeatureFlagViewExt; +use fs::Fs; +use fuzzy::{StringMatch, StringMatchCandidate}; +use gpui::{ + AppContext as _, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + FontWeight, Global, KeyContext, Keystroke, ModifiersChangedEvent, ScrollStrategy, Subscription, + WeakEntity, actions, div, +}; +use settings::KeybindSource; +use util::ResultExt; + +use ui::{ + ActiveTheme as _, App, BorrowAppContext, ParentElement as _, Render, SharedString, Styled as _, + Window, prelude::*, +}; +use workspace::{Item, ModalView, SerializableItem, Workspace, register_serializable_item}; + +use crate::{ + SettingsUiFeatureFlag, + keybindings::persistence::KEYBINDING_EDITORS, + ui_components::table::{Table, TableInteractionState}, +}; + +actions!(zed, [OpenKeymapEditor]); + +pub fn init(cx: &mut App) { + let keymap_event_channel = KeymapEventChannel::new(); + cx.set_global(keymap_event_channel); + + cx.on_action(|_: &OpenKeymapEditor, cx| { + workspace::with_active_or_new_workspace(cx, move |workspace, window, cx| { + let existing = workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()); + + if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + } else { + let keymap_editor = + cx.new(|cx| KeymapEditor::new(workspace.weak_handle(), window, cx)); + workspace.add_item_to_active_pane(Box::new(keymap_editor), None, true, window, cx); + } + }); + }); + + cx.observe_new(|_workspace: &mut Workspace, window, cx| { + let Some(window) = window else { return }; + + let keymap_ui_actions = [std::any::TypeId::of::()]; + + command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _cx| { + filter.hide_action_types(&keymap_ui_actions); + }); + + cx.observe_flag::( + window, + move |is_enabled, _workspace, _, cx| { + if is_enabled { + command_palette_hooks::CommandPaletteFilter::update_global( + cx, + |filter, _cx| { + filter.show_action_types(keymap_ui_actions.iter()); + }, + ); + } else { + command_palette_hooks::CommandPaletteFilter::update_global( + cx, + |filter, _cx| { + filter.hide_action_types(&keymap_ui_actions); + }, + ); + } + }, + ) + .detach(); + }) + .detach(); + + register_serializable_item::(cx); +} + +pub struct KeymapEventChannel {} + +impl Global for KeymapEventChannel {} + +impl KeymapEventChannel { + fn new() -> Self { + Self {} + } + + pub fn trigger_keymap_changed(cx: &mut App) { + let Some(_event_channel) = cx.try_global::() else { + // don't panic if no global defined. This usually happens in tests + return; + }; + cx.update_global(|_event_channel: &mut Self, _| { + /* triggers observers in KeymapEditors */ + }); + } +} + +struct KeymapEditor { + workspace: WeakEntity, + focus_handle: FocusHandle, + _keymap_subscription: Subscription, + keybindings: Vec, + // corresponds 1 to 1 with keybindings + string_match_candidates: Arc>, + matches: Vec, + table_interaction_state: Entity, + filter_editor: Entity, + selected_index: Option, +} + +impl EventEmitter<()> for KeymapEditor {} + +impl Focusable for KeymapEditor { + fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { + return self.filter_editor.focus_handle(cx); + } +} + +impl KeymapEditor { + fn new(workspace: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + + let _keymap_subscription = + cx.observe_global::(Self::update_keybindings); + let table_interaction_state = TableInteractionState::new(window, cx); + + let filter_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Filter action names...", cx); + editor + }); + + cx.subscribe(&filter_editor, |this, _, e: &EditorEvent, cx| { + if !matches!(e, EditorEvent::BufferEdited) { + return; + } + + this.update_matches(cx); + }) + .detach(); + + let mut this = Self { + workspace, + keybindings: vec![], + string_match_candidates: Arc::new(vec![]), + matches: vec![], + focus_handle: focus_handle.clone(), + _keymap_subscription, + table_interaction_state, + filter_editor, + selected_index: None, + }; + + this.update_keybindings(cx); + + this + } + + fn update_matches(&mut self, cx: &mut Context) { + let query = self.filter_editor.read(cx).text(cx); + let string_match_candidates = self.string_match_candidates.clone(); + let executor = cx.background_executor().clone(); + let keybind_count = self.keybindings.len(); + let query = command_palette::normalize_action_query(&query); + let fuzzy_match = cx.background_spawn(async move { + fuzzy::match_strings( + &string_match_candidates, + &query, + true, + true, + keybind_count, + &Default::default(), + executor, + ) + .await + }); + + cx.spawn(async move |this, cx| { + let matches = fuzzy_match.await; + this.update(cx, |this, cx| { + this.selected_index.take(); + this.scroll_to_item(0, ScrollStrategy::Top, cx); + this.matches = matches; + cx.notify(); + }) + }) + .detach(); + } + + fn process_bindings( + cx: &mut Context, + ) -> (Vec, Vec) { + let key_bindings_ptr = cx.key_bindings(); + let lock = key_bindings_ptr.borrow(); + let key_bindings = lock.bindings(); + let mut unmapped_action_names = HashSet::from_iter(cx.all_action_names()); + + let mut processed_bindings = Vec::new(); + let mut string_match_candidates = Vec::new(); + + for key_binding in key_bindings { + let source = key_binding.meta().map(settings::KeybindSource::from_meta); + + let keystroke_text = ui::text_for_keystrokes(key_binding.keystrokes(), cx); + let ui_key_binding = Some( + ui::KeyBinding::new(key_binding.clone(), cx) + .vim_mode(source == Some(settings::KeybindSource::Vim)), + ); + + let context = key_binding + .predicate() + .map(|predicate| predicate.to_string()) + .unwrap_or_else(|| "".to_string()); + + let source = source.map(|source| (source, source.name().into())); + + let action_name = key_binding.action().name(); + unmapped_action_names.remove(&action_name); + + let index = processed_bindings.len(); + let string_match_candidate = StringMatchCandidate::new(index, &action_name); + processed_bindings.push(ProcessedKeybinding { + keystroke_text: keystroke_text.into(), + ui_key_binding, + action: action_name.into(), + action_input: key_binding.action_input(), + context: context.into(), + source, + }); + string_match_candidates.push(string_match_candidate); + } + + let empty = SharedString::new_static(""); + for action_name in unmapped_action_names.into_iter() { + let index = processed_bindings.len(); + let string_match_candidate = StringMatchCandidate::new(index, &action_name); + processed_bindings.push(ProcessedKeybinding { + keystroke_text: empty.clone(), + ui_key_binding: None, + action: (*action_name).into(), + action_input: None, + context: empty.clone(), + source: None, + }); + string_match_candidates.push(string_match_candidate); + } + + (processed_bindings, string_match_candidates) + } + + fn update_keybindings(self: &mut KeymapEditor, cx: &mut Context) { + let (key_bindings, string_match_candidates) = Self::process_bindings(cx); + self.keybindings = key_bindings; + self.string_match_candidates = Arc::new(string_match_candidates); + self.matches = self + .string_match_candidates + .iter() + .enumerate() + .map(|(ix, candidate)| StringMatch { + candidate_id: ix, + score: 0.0, + positions: vec![], + string: candidate.string.clone(), + }) + .collect(); + + self.update_matches(cx); + cx.notify(); + } + + fn dispatch_context(&self, _window: &Window, _cx: &Context) -> KeyContext { + let mut dispatch_context = KeyContext::new_with_defaults(); + dispatch_context.add("KeymapEditor"); + dispatch_context.add("menu"); + + dispatch_context + } + + fn scroll_to_item(&self, index: usize, strategy: ScrollStrategy, cx: &mut App) { + let index = usize::min(index, self.matches.len().saturating_sub(1)); + self.table_interaction_state.update(cx, |this, _cx| { + this.scroll_handle.scroll_to_item(index, strategy); + }); + } + + fn select_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) { + if let Some(selected) = self.selected_index { + let selected = selected + 1; + if selected >= self.matches.len() { + self.select_last(&Default::default(), window, cx); + } else { + self.selected_index = Some(selected); + self.scroll_to_item(selected, ScrollStrategy::Center, cx); + cx.notify(); + } + } else { + self.select_first(&Default::default(), window, cx); + } + } + + fn select_previous( + &mut self, + _: &menu::SelectPrevious, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(selected) = self.selected_index { + if selected == 0 { + return; + } + + let selected = selected - 1; + + if selected >= self.matches.len() { + self.select_last(&Default::default(), window, cx); + } else { + self.selected_index = Some(selected); + self.scroll_to_item(selected, ScrollStrategy::Center, cx); + cx.notify(); + } + } else { + self.select_last(&Default::default(), window, cx); + } + } + + fn select_first( + &mut self, + _: &menu::SelectFirst, + _window: &mut Window, + cx: &mut Context, + ) { + if self.matches.get(0).is_some() { + self.selected_index = Some(0); + self.scroll_to_item(0, ScrollStrategy::Center, cx); + cx.notify(); + } + } + + fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context) { + if self.matches.last().is_some() { + let index = self.matches.len() - 1; + self.selected_index = Some(index); + self.scroll_to_item(index, ScrollStrategy::Center, cx); + cx.notify(); + } + } + + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + let Some(index) = self.selected_index else { + return; + }; + let keybind = self.keybindings[self.matches[index].candidate_id].clone(); + + self.edit_keybinding(keybind, window, cx); + } + + fn edit_keybinding( + &mut self, + keybind: ProcessedKeybinding, + window: &mut Window, + cx: &mut Context, + ) { + self.workspace + .update(cx, |workspace, cx| { + let fs = workspace.app_state().fs.clone(); + workspace.toggle_modal(window, cx, |window, cx| { + let modal = KeybindingEditorModal::new(keybind, fs, window, cx); + window.focus(&modal.focus_handle(cx)); + modal + }); + }) + .log_err(); + } + + fn focus_search( + &mut self, + _: &search::FocusSearch, + window: &mut Window, + cx: &mut Context, + ) { + 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(); + } +} + +#[derive(Clone)] +struct ProcessedKeybinding { + keystroke_text: SharedString, + ui_key_binding: Option, + action: SharedString, + action_input: Option, + context: SharedString, + source: Option<(KeybindSource, SharedString)>, +} + +impl Item for KeymapEditor { + type Event = (); + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> ui::SharedString { + "Keymap Editor".into() + } +} + +impl Render for KeymapEditor { + fn render(&mut self, window: &mut Window, cx: &mut ui::Context) -> impl ui::IntoElement { + let row_count = self.matches.len(); + let theme = cx.theme(); + + div() + .key_context(self.dispatch_context(window, cx)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::focus_search)) + .on_action(cx.listener(Self::confirm)) + .size_full() + .bg(theme.colors().editor_background) + .id("keymap-editor") + .track_focus(&self.focus_handle) + .px_4() + .v_flex() + .pb_4() + .child( + h_flex() + .key_context({ + let mut context = KeyContext::new_with_defaults(); + context.add("BufferSearchBar"); + context + }) + .w_full() + .h_12() + .px_4() + .my_4() + .border_2() + .border_color(theme.colors().border) + .child(self.filter_editor.clone()), + ) + .child( + Table::new() + .interactable(&self.table_interaction_state) + .striped() + .column_widths([rems(24.), rems(16.), rems(32.), rems(8.)]) + .header(["Command", "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( + "keymap-editor-table", + row_count, + cx.processor(move |this, range: Range, _window, _cx| { + range + .filter_map(|index| { + let candidate_id = this.matches.get(index)?.candidate_id; + let binding = &this.keybindings[candidate_id]; + let action = h_flex() + .items_start() + .gap_1() + .child(binding.action.clone()) + .when_some( + binding.action_input.clone(), + |this, binding_input| this.child(binding_input), + ); + let keystrokes = binding.ui_key_binding.clone().map_or( + binding.keystroke_text.clone().into_any_element(), + IntoElement::into_any_element, + ); + let context = binding.context.clone(); + let source = binding + .source + .clone() + .map(|(_source, name)| name) + .unwrap_or_default(); + Some([ + action.into_any_element(), + keystrokes, + context.into_any_element(), + source.into_any_element(), + ]) + }) + .collect() + }), + ), + ) + } +} + +struct KeybindingEditorModal { + editing_keybind: ProcessedKeybinding, + keybind_editor: Entity, + fs: Arc, + error: Option, +} + +impl ModalView for KeybindingEditorModal {} + +impl EventEmitter for KeybindingEditorModal {} + +impl Focusable for KeybindingEditorModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.keybind_editor.focus_handle(cx) + } +} + +impl KeybindingEditorModal { + pub fn new( + editing_keybind: ProcessedKeybinding, + fs: Arc, + _window: &mut Window, + cx: &mut App, + ) -> Self { + let keybind_editor = cx.new(KeybindInput::new); + Self { + editing_keybind, + fs, + keybind_editor, + error: None, + } + } +} + +impl Render for KeybindingEditorModal { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().colors(); + return v_flex() + .gap_4() + .w(rems(36.)) + .child( + v_flex() + .items_center() + .text_center() + .bg(theme.background) + .border_color(theme.border) + .border_2() + .px_4() + .py_2() + .w_full() + .child( + div() + .text_lg() + .font_weight(FontWeight::BOLD) + .child("Input desired keybinding, then hit save"), + ) + .child( + h_flex() + .w_full() + .child(self.keybind_editor.clone()) + .child( + IconButton::new("backspace-btn", ui::IconName::Backspace).on_click( + cx.listener(|this, _event, _window, cx| { + this.keybind_editor.update(cx, |editor, cx| { + editor.keystrokes.pop(); + cx.notify(); + }) + }), + ), + ) + .child(IconButton::new("clear-btn", ui::IconName::Eraser).on_click( + cx.listener(|this, _event, _window, cx| { + this.keybind_editor.update(cx, |editor, cx| { + editor.keystrokes.clear(); + cx.notify(); + }) + }), + )), + ) + .child( + h_flex().w_full().items_center().justify_center().child( + Button::new("save-btn", "Save") + .label_size(LabelSize::Large) + .on_click(cx.listener(|this, _event, _window, cx| { + let existing_keybind = this.editing_keybind.clone(); + let fs = this.fs.clone(); + let new_keystrokes = this + .keybind_editor + .read_with(cx, |editor, _| editor.keystrokes.clone()); + if new_keystrokes.is_empty() { + this.error = Some("Keystrokes cannot be empty".to_string()); + cx.notify(); + return; + } + let tab_size = + cx.global::().json_tab_size(); + cx.spawn(async move |this, cx| { + if let Err(err) = save_keybinding_update( + existing_keybind, + &new_keystrokes, + &fs, + tab_size, + ) + .await + { + this.update(cx, |this, cx| { + this.error = Some(err); + cx.notify(); + }) + .log_err(); + } + }) + .detach(); + })), + ), + ), + ) + .when_some(self.error.clone(), |this, error| { + this.child( + div() + .bg(theme.background) + .border_color(theme.border) + .border_2() + .rounded_md() + .child(error), + ) + }); + } +} + +async fn save_keybinding_update( + existing: ProcessedKeybinding, + new_keystrokes: &[Keystroke], + fs: &Arc, + tab_size: usize, +) -> Result<(), String> { + let keymap_contents = settings::KeymapFile::load_keymap_file(fs) + .await + .map_err(|err| format!("Failed to load keymap file: {}", err))?; + let existing_keystrokes = existing + .ui_key_binding + .as_ref() + .map(|keybinding| keybinding.key_binding.keystrokes()) + .unwrap_or_default(); + let operation = if existing.ui_key_binding.is_some() { + settings::KeybindUpdateOperation::Replace { + target: settings::KeybindUpdateTarget { + context: Some(existing.context.as_ref()).filter(|context| !context.is_empty()), + keystrokes: existing_keystrokes, + action_name: &existing.action, + use_key_equivalents: false, + input: existing.action_input.as_ref().map(|input| input.as_ref()), + }, + target_source: existing + .source + .map(|(source, _name)| source) + .unwrap_or(KeybindSource::User), + source: settings::KeybindUpdateTarget { + context: Some(existing.context.as_ref()).filter(|context| !context.is_empty()), + keystrokes: new_keystrokes, + action_name: &existing.action, + use_key_equivalents: false, + input: existing.action_input.as_ref().map(|input| input.as_ref()), + }, + } + } else { + return Err( + "Not Implemented: Creating new bindings from unbound actions is not supported yet." + .to_string(), + ); + }; + let updated_keymap_contents = + settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size) + .map_err(|err| format!("Failed to update keybinding: {}", err))?; + fs.atomic_write(paths::keymap_file().clone(), updated_keymap_contents) + .await + .map_err(|err| format!("Failed to write keymap file: {}", err))?; + Ok(()) +} + +struct KeybindInput { + keystrokes: Vec, + focus_handle: FocusHandle, +} + +impl KeybindInput { + fn new(cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + Self { + keystrokes: Vec::new(), + focus_handle, + } + } + + fn on_modifiers_changed( + &mut self, + event: &ModifiersChangedEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(last) = self.keystrokes.last_mut() + && last.key.is_empty() + { + if !event.modifiers.modified() { + self.keystrokes.pop(); + } else { + last.modifiers = event.modifiers; + } + } else { + self.keystrokes.push(Keystroke { + modifiers: event.modifiers, + key: "".to_string(), + key_char: None, + }); + } + cx.stop_propagation(); + cx.notify(); + } + + fn on_key_down( + &mut self, + event: &gpui::KeyDownEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if event.is_held { + return; + } + if let Some(last) = self.keystrokes.last_mut() + && last.key.is_empty() + { + *last = event.keystroke.clone(); + } else { + self.keystrokes.push(event.keystroke.clone()); + } + cx.stop_propagation(); + cx.notify(); + } + + fn on_key_up( + &mut self, + event: &gpui::KeyUpEvent, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(last) = self.keystrokes.last_mut() + && !last.key.is_empty() + && last.modifiers == event.keystroke.modifiers + { + self.keystrokes.push(Keystroke { + modifiers: event.keystroke.modifiers, + key: "".to_string(), + key_char: None, + }); + } + cx.stop_propagation(); + cx.notify(); + } +} + +impl Focusable for KeybindInput { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for KeybindInput { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.theme().colors(); + return div() + .track_focus(&self.focus_handle) + .on_modifiers_changed(cx.listener(Self::on_modifiers_changed)) + .on_key_down(cx.listener(Self::on_key_down)) + .on_key_up(cx.listener(Self::on_key_up)) + .focus(|mut style| { + style.border_color = Some(colors.border_focused); + style + }) + .h_12() + .w_full() + .bg(colors.editor_background) + .border_2() + .border_color(colors.border) + .p_4() + .flex_row() + .text_center() + .justify_center() + .child(ui::text_for_keystrokes(&self.keystrokes, cx)); + } +} + +impl SerializableItem for KeymapEditor { + fn serialized_item_kind() -> &'static str { + "KeymapEditor" + } + + fn cleanup( + workspace_id: workspace::WorkspaceId, + alive_items: Vec, + _window: &mut Window, + cx: &mut App, + ) -> gpui::Task> { + workspace::delete_unloaded_items( + alive_items, + workspace_id, + "keybinding_editors", + &KEYBINDING_EDITORS, + cx, + ) + } + + fn deserialize( + _project: Entity, + workspace: WeakEntity, + workspace_id: workspace::WorkspaceId, + item_id: workspace::ItemId, + window: &mut Window, + cx: &mut App, + ) -> gpui::Task>> { + window.spawn(cx, async move |cx| { + if KEYBINDING_EDITORS + .get_keybinding_editor(item_id, workspace_id)? + .is_some() + { + cx.update(|window, cx| cx.new(|cx| KeymapEditor::new(workspace, window, cx))) + } else { + Err(anyhow!("No keybinding editor to deserialize")) + } + }) + } + + fn serialize( + &mut self, + workspace: &mut Workspace, + item_id: workspace::ItemId, + _closing: bool, + _window: &mut Window, + cx: &mut ui::Context, + ) -> Option>> { + let workspace_id = workspace.database_id()?; + Some(cx.background_spawn(async move { + KEYBINDING_EDITORS + .save_keybinding_editor(item_id, workspace_id) + .await + })) + } + + fn should_serialize(&self, _event: &Self::Event) -> bool { + false + } +} + +mod persistence { + use db::{define_connection, query, sqlez_macros::sql}; + use workspace::WorkspaceDb; + + define_connection! { + pub static ref KEYBINDING_EDITORS: KeybindingEditorDb = + &[sql!( + CREATE TABLE keybinding_editors ( + workspace_id INTEGER, + item_id INTEGER UNIQUE, + + PRIMARY KEY(workspace_id, item_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ) STRICT; + )]; + } + + impl KeybindingEditorDb { + query! { + pub async fn save_keybinding_editor( + item_id: workspace::ItemId, + workspace_id: workspace::WorkspaceId + ) -> Result<()> { + INSERT OR REPLACE INTO keybinding_editors(item_id, workspace_id) + VALUES (?, ?) + } + } + + query! { + pub fn get_keybinding_editor( + item_id: workspace::ItemId, + workspace_id: workspace::WorkspaceId + ) -> Result> { + SELECT item_id + FROM keybinding_editors + WHERE item_id = ? AND workspace_id = ? + } + } + } +} diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index dd6626a716..b3fb10c5e6 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -20,6 +20,9 @@ use workspace::{Workspace, with_active_or_new_workspace}; use crate::appearance_settings_controls::AppearanceSettingsControls; +pub mod keybindings; +pub mod ui_components; + pub struct SettingsUiFeatureFlag; impl FeatureFlag for SettingsUiFeatureFlag { @@ -121,6 +124,8 @@ pub fn init(cx: &mut App) { .detach(); }) .detach(); + + keybindings::init(cx); } async fn handle_import_vscode_settings( diff --git a/crates/settings_ui/src/ui_components/mod.rs b/crates/settings_ui/src/ui_components/mod.rs new file mode 100644 index 0000000000..13971b0a5d --- /dev/null +++ b/crates/settings_ui/src/ui_components/mod.rs @@ -0,0 +1 @@ +pub mod table; diff --git a/crates/settings_ui/src/ui_components/table.rs b/crates/settings_ui/src/ui_components/table.rs new file mode 100644 index 0000000000..62f597e148 --- /dev/null +++ b/crates/settings_ui/src/ui_components/table.rs @@ -0,0 +1,884 @@ +use std::{ops::Range, rc::Rc, time::Duration}; + +use editor::{EditorSettings, ShowScrollbar, scroll::ScrollbarAutoHide}; +use gpui::{ + AppContext, Axis, Context, Entity, FocusHandle, FontWeight, Length, + ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, Task, UniformListScrollHandle, + WeakEntity, transparent_black, uniform_list, +}; +use settings::Settings as _; +use ui::{ + ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component, + ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator, + InteractiveElement as _, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce, + Scrollbar, ScrollbarState, StatefulInteractiveElement as _, Styled, StyledExt as _, + StyledTypography, Window, div, example_group_with_title, h_flex, px, single_example, v_flex, +}; + +struct UniformListData { + render_item_fn: Box, &mut Window, &mut App) -> Vec<[AnyElement; COLS]>>, + element_id: ElementId, + row_count: usize, +} + +enum TableContents { + Vec(Vec<[AnyElement; COLS]>), + UniformList(UniformListData), +} + +impl TableContents { + fn rows_mut(&mut self) -> Option<&mut Vec<[AnyElement; COLS]>> { + match self { + TableContents::Vec(rows) => Some(rows), + TableContents::UniformList(_) => None, + } + } + + fn len(&self) -> usize { + match self { + TableContents::Vec(rows) => rows.len(), + TableContents::UniformList(data) => data.row_count, + } + } +} + +pub struct TableInteractionState { + pub focus_handle: FocusHandle, + pub scroll_handle: UniformListScrollHandle, + pub horizontal_scrollbar: ScrollbarProperties, + pub vertical_scrollbar: ScrollbarProperties, +} + +impl TableInteractionState { + pub fn new(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| { + let focus_handle = cx.focus_handle(); + + cx.on_focus_out(&focus_handle, window, |this: &mut Self, _, window, cx| { + this.hide_scrollbars(window, cx); + }) + .detach(); + + let scroll_handle = UniformListScrollHandle::new(); + let vertical_scrollbar = ScrollbarProperties { + axis: Axis::Vertical, + state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()), + show_scrollbar: false, + show_track: false, + auto_hide: false, + hide_task: None, + }; + + let horizontal_scrollbar = ScrollbarProperties { + axis: Axis::Horizontal, + state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()), + show_scrollbar: false, + show_track: false, + auto_hide: false, + hide_task: None, + }; + + let mut this = Self { + focus_handle, + scroll_handle, + horizontal_scrollbar, + vertical_scrollbar, + }; + + this.update_scrollbar_visibility(cx); + this + }) + } + + fn update_scrollbar_visibility(&mut self, cx: &mut Context) { + let show_setting = EditorSettings::get_global(cx).scrollbar.show; + + let scroll_handle = self.scroll_handle.0.borrow(); + + let autohide = |show: ShowScrollbar, cx: &mut Context| match show { + ShowScrollbar::Auto => true, + ShowScrollbar::System => cx + .try_global::() + .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0), + ShowScrollbar::Always => false, + ShowScrollbar::Never => false, + }; + + let longest_item_width = scroll_handle.last_item_size.and_then(|size| { + (size.contents.width > size.item.width).then_some(size.contents.width) + }); + + // is there an item long enough that we should show a horizontal scrollbar? + let item_wider_than_container = if let Some(longest_item_width) = longest_item_width { + longest_item_width > px(scroll_handle.base_handle.bounds().size.width.0) + } else { + true + }; + + let show_scrollbar = match show_setting { + ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always => true, + ShowScrollbar::Never => false, + }; + let show_vertical = show_scrollbar; + + let show_horizontal = item_wider_than_container && show_scrollbar; + + let show_horizontal_track = + show_horizontal && matches!(show_setting, ShowScrollbar::Always); + + // TODO: we probably should hide the scroll track when the list doesn't need to scroll + let show_vertical_track = show_vertical && matches!(show_setting, ShowScrollbar::Always); + + self.vertical_scrollbar = ScrollbarProperties { + axis: self.vertical_scrollbar.axis, + state: self.vertical_scrollbar.state.clone(), + show_scrollbar: show_vertical, + show_track: show_vertical_track, + auto_hide: autohide(show_setting, cx), + hide_task: None, + }; + + self.horizontal_scrollbar = ScrollbarProperties { + axis: self.horizontal_scrollbar.axis, + state: self.horizontal_scrollbar.state.clone(), + show_scrollbar: show_horizontal, + show_track: show_horizontal_track, + auto_hide: autohide(show_setting, cx), + hide_task: None, + }; + + cx.notify(); + } + + fn hide_scrollbars(&mut self, window: &mut Window, cx: &mut Context) { + self.horizontal_scrollbar.hide(window, cx); + self.vertical_scrollbar.hide(window, cx); + } + + // fn listener(this: Entity, fn: F) -> + + pub fn listener( + this: &Entity, + f: impl Fn(&mut Self, &E, &mut Window, &mut Context) + 'static, + ) -> impl Fn(&E, &mut Window, &mut App) + 'static { + let view = this.downgrade(); + move |e: &E, window: &mut Window, cx: &mut App| { + view.update(cx, |view, cx| f(view, e, window, cx)).ok(); + } + } + + fn render_vertical_scrollbar_track( + this: &Entity, + parent: Div, + scroll_track_size: Pixels, + cx: &mut App, + ) -> Div { + if !this.read(cx).vertical_scrollbar.show_track { + return parent; + } + let child = v_flex() + .h_full() + .flex_none() + .w(scroll_track_size) + .bg(cx.theme().colors().background) + .child( + div() + .size_full() + .flex_1() + .border_l_1() + .border_color(cx.theme().colors().border), + ); + parent.child(child) + } + + fn render_vertical_scrollbar(this: &Entity, parent: Div, cx: &mut App) -> Div { + if !this.read(cx).vertical_scrollbar.show_scrollbar { + return parent; + } + let child = div() + .id(("table-vertical-scrollbar", this.entity_id())) + .occlude() + .flex_none() + .h_full() + .cursor_default() + .absolute() + .right_0() + .top_0() + .bottom_0() + .w(px(12.)) + .on_mouse_move(Self::listener(this, |_, _, _, cx| { + cx.notify(); + cx.stop_propagation() + })) + .on_hover(|_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_up( + MouseButton::Left, + Self::listener(this, |this, _, window, cx| { + if !this.vertical_scrollbar.state.is_dragging() + && !this.focus_handle.contains_focused(window, cx) + { + this.vertical_scrollbar.hide(window, cx); + cx.notify(); + } + + cx.stop_propagation(); + }), + ) + .on_any_mouse_down(|_, _, cx| { + cx.stop_propagation(); + }) + .on_scroll_wheel(Self::listener(&this, |_, _, _, cx| { + cx.notify(); + })) + .children(Scrollbar::vertical( + this.read(cx).vertical_scrollbar.state.clone(), + )); + parent.child(child) + } + + /// Renders the horizontal scrollbar. + /// + /// The right offset is used to determine how far to the right the + /// scrollbar should extend to, useful for ensuring it doesn't collide + /// with the vertical scrollbar when visible. + fn render_horizontal_scrollbar( + this: &Entity, + parent: Div, + right_offset: Pixels, + cx: &mut App, + ) -> Div { + if !this.read(cx).horizontal_scrollbar.show_scrollbar { + return parent; + } + let child = div() + .id(("table-horizontal-scrollbar", this.entity_id())) + .occlude() + .flex_none() + .w_full() + .cursor_default() + .absolute() + .bottom_neg_px() + .left_0() + .right_0() + .pr(right_offset) + .on_mouse_move(Self::listener(this, |_, _, _, cx| { + cx.notify(); + cx.stop_propagation() + })) + .on_hover(|_, _, cx| { + cx.stop_propagation(); + }) + .on_any_mouse_down(|_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_up( + MouseButton::Left, + Self::listener(this, |this, _, window, cx| { + if !this.horizontal_scrollbar.state.is_dragging() + && !this.focus_handle.contains_focused(window, cx) + { + this.horizontal_scrollbar.hide(window, cx); + cx.notify(); + } + + cx.stop_propagation(); + }), + ) + .on_scroll_wheel(Self::listener(this, |_, _, _, cx| { + cx.notify(); + })) + .children(Scrollbar::horizontal( + // percentage as f32..end_offset as f32, + this.read(cx).horizontal_scrollbar.state.clone(), + )); + parent.child(child) + } + + fn render_horizontal_scrollbar_track( + this: &Entity, + parent: Div, + scroll_track_size: Pixels, + cx: &mut App, + ) -> Div { + if !this.read(cx).horizontal_scrollbar.show_track { + return parent; + } + let child = h_flex() + .w_full() + .h(scroll_track_size) + .flex_none() + .relative() + .child( + div() + .w_full() + .flex_1() + // for some reason the horizontal scrollbar is 1px + // taller than the vertical scrollbar?? + .h(scroll_track_size - px(1.)) + .bg(cx.theme().colors().background) + .border_t_1() + .border_color(cx.theme().colors().border), + ) + .when(this.read(cx).vertical_scrollbar.show_track, |parent| { + parent + .child( + div() + .flex_none() + // -1px prevents a missing pixel between the two container borders + .w(scroll_track_size - px(1.)) + .h_full(), + ) + .child( + // HACK: Fill the missing 1px 🥲 + div() + .absolute() + .right(scroll_track_size - px(1.)) + .bottom(scroll_track_size - px(1.)) + .size_px() + .bg(cx.theme().colors().border), + ) + }); + + parent.child(child) + } +} + +/// A table component +#[derive(RegisterComponent, IntoElement)] +pub struct Table { + striped: bool, + width: Option, + headers: Option<[AnyElement; COLS]>, + rows: TableContents, + interaction_state: Option>, + selected_item_index: Option, + column_widths: Option<[Length; COLS]>, + on_click_row: Option>, +} + +impl Table { + /// number of headers provided. + pub fn new() -> Self { + Table { + striped: false, + width: None, + headers: None, + rows: TableContents::Vec(Vec::new()), + interaction_state: None, + selected_item_index: None, + column_widths: None, + on_click_row: None, + } + } + + /// Enables uniform list rendering. + /// The provided function will be passed directly to the `uniform_list` element. + /// Therefore, if this method is called, any calls to [`Table::row`] before or after + /// this method is called will be ignored. + pub fn uniform_list( + mut self, + id: impl Into, + row_count: usize, + render_item_fn: impl Fn(Range, &mut Window, &mut App) -> Vec<[AnyElement; COLS]> + + 'static, + ) -> Self { + self.rows = TableContents::UniformList(UniformListData { + element_id: id.into(), + row_count: row_count, + render_item_fn: Box::new(render_item_fn), + }); + self + } + + /// Enables row striping. + pub fn striped(mut self) -> Self { + self.striped = true; + self + } + + /// Sets the width of the table. + /// Will enable horizontal scrolling if [`Self::interactable`] is also called. + pub fn width(mut self, width: impl Into) -> Self { + self.width = Some(width.into()); + self + } + + /// Enables interaction (primarily scrolling) with the table. + /// + /// Vertical scrolling will be enabled by default if the table is taller than its container. + /// + /// Horizontal scrolling will only be enabled if [`Self::width`] is also called, otherwise + /// the list will always shrink the table columns to fit their contents I.e. If [`Self::uniform_list`] + /// is used without a width and with [`Self::interactable`], the [`ListHorizontalSizingBehavior`] will + /// be set to [`ListHorizontalSizingBehavior::FitList`]. + pub fn interactable(mut self, interaction_state: &Entity) -> Self { + self.interaction_state = Some(interaction_state.downgrade()); + self + } + + pub fn selected_item_index(mut self, selected_item_index: Option) -> Self { + self.selected_item_index = selected_item_index; + self + } + + pub fn header(mut self, headers: [impl IntoElement; COLS]) -> Self { + self.headers = Some(headers.map(IntoElement::into_any_element)); + self + } + + pub fn row(mut self, items: [impl IntoElement; COLS]) -> Self { + if let Some(rows) = self.rows.rows_mut() { + rows.push(items.map(IntoElement::into_any_element)); + } + self + } + + pub fn column_widths(mut self, widths: [impl Into; COLS]) -> Self { + self.column_widths = Some(widths.map(Into::into)); + self + } + + pub fn on_click_row( + mut self, + callback: impl Fn(usize, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click_row = Some(Rc::new(callback)); + self + } +} + +fn base_cell_style(width: Option, cx: &App) -> Div { + div() + .px_1p5() + .when_some(width, |this, width| this.w(width)) + .when(width.is_none(), |this| this.flex_1()) + .justify_start() + .text_ui(cx) + .whitespace_nowrap() + .text_ellipsis() + .overflow_hidden() +} + +pub fn render_row( + row_index: usize, + items: [impl IntoElement; COLS], + table_context: TableRenderContext, + cx: &App, +) -> AnyElement { + let is_striped = table_context.striped; + let is_last = row_index == table_context.total_row_count - 1; + let bg = if row_index % 2 == 1 && is_striped { + Some(cx.theme().colors().text.opacity(0.05)) + } else { + None + }; + let column_widths = table_context + .column_widths + .map_or([None; COLS], |widths| widths.map(Some)); + let is_selected = table_context.selected_item_index == Some(row_index); + + let row = div() + .w_full() + .border_2() + .border_color(transparent_black()) + .when(is_selected, |row| { + row.border_color(cx.theme().colors().panel_focused_border) + }) + .child( + div() + .w_full() + .flex() + .flex_row() + .items_center() + .justify_between() + .px_1p5() + .py_1() + .when_some(bg, |row, bg| row.bg(bg)) + .when(!is_striped, |row| { + row.border_b_1() + .border_color(transparent_black()) + .when(!is_last, |row| row.border_color(cx.theme().colors().border)) + }) + .children( + items + .map(IntoElement::into_any_element) + .into_iter() + .zip(column_widths) + .map(|(cell, width)| base_cell_style(width, cx).child(cell)), + ), + ); + + if let Some(on_click) = table_context.on_click_row { + row.id(("table-row", row_index)) + .on_click(move |_, window, cx| on_click(row_index, window, cx)) + .into_any_element() + } else { + row.into_any_element() + } +} + +pub fn render_header( + headers: [impl IntoElement; COLS], + table_context: TableRenderContext, + cx: &mut App, +) -> impl IntoElement { + let column_widths = table_context + .column_widths + .map_or([None; COLS], |widths| widths.map(Some)); + div() + .flex() + .flex_row() + .items_center() + .justify_between() + .w_full() + .p_2() + .border_b_1() + .border_color(cx.theme().colors().border) + .children(headers.into_iter().zip(column_widths).map(|(h, width)| { + base_cell_style(width, cx) + .font_weight(FontWeight::SEMIBOLD) + .child(h) + })) +} + +#[derive(Clone)] +pub struct TableRenderContext { + pub striped: bool, + pub total_row_count: usize, + pub selected_item_index: Option, + pub column_widths: Option<[Length; COLS]>, + pub on_click_row: Option>, +} + +impl TableRenderContext { + fn new(table: &Table) -> Self { + Self { + striped: table.striped, + total_row_count: table.rows.len(), + column_widths: table.column_widths, + selected_item_index: table.selected_item_index, + on_click_row: table.on_click_row.clone(), + } + } +} + +impl RenderOnce for Table { + fn render(mut self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let table_context = TableRenderContext::new(&self); + let interaction_state = self.interaction_state.and_then(|state| state.upgrade()); + + let scroll_track_size = px(16.); + let h_scroll_offset = if interaction_state + .as_ref() + .is_some_and(|state| state.read(cx).vertical_scrollbar.show_scrollbar) + { + // magic number + px(3.) + } else { + px(0.) + }; + + let width = self.width; + + let table = div() + .when_some(width, |this, width| this.w(width)) + .h_full() + .v_flex() + .when_some(self.headers.take(), |this, headers| { + this.child(render_header(headers, table_context.clone(), cx)) + }) + .child( + div() + .flex_grow() + .w_full() + .relative() + .overflow_hidden() + .map(|parent| match self.rows { + TableContents::Vec(items) => { + parent.children(items.into_iter().enumerate().map(|(index, row)| { + render_row(index, row, table_context.clone(), cx) + })) + } + TableContents::UniformList(uniform_list_data) => parent.child( + uniform_list( + uniform_list_data.element_id, + uniform_list_data.row_count, + { + let render_item_fn = uniform_list_data.render_item_fn; + move |range: Range, window, cx| { + let elements = render_item_fn(range.clone(), window, cx); + elements + .into_iter() + .zip(range) + .map(|(row, row_index)| { + render_row( + row_index, + row, + table_context.clone(), + cx, + ) + }) + .collect() + } + }, + ) + .size_full() + .flex_grow() + .with_sizing_behavior(ListSizingBehavior::Auto) + .with_horizontal_sizing_behavior(if width.is_some() { + ListHorizontalSizingBehavior::Unconstrained + } else { + ListHorizontalSizingBehavior::FitList + }) + .when_some( + interaction_state.as_ref(), + |this, state| { + this.track_scroll( + state.read_with(cx, |s, _| s.scroll_handle.clone()), + ) + }, + ), + ), + }) + .when_some(interaction_state.as_ref(), |this, interaction_state| { + this.map(|this| { + TableInteractionState::render_vertical_scrollbar_track( + interaction_state, + this, + scroll_track_size, + cx, + ) + }) + .map(|this| { + TableInteractionState::render_vertical_scrollbar( + interaction_state, + this, + cx, + ) + }) + }), + ) + .when_some( + width.and(interaction_state.as_ref()), + |this, interaction_state| { + this.map(|this| { + TableInteractionState::render_horizontal_scrollbar_track( + interaction_state, + this, + scroll_track_size, + cx, + ) + }) + .map(|this| { + TableInteractionState::render_horizontal_scrollbar( + interaction_state, + this, + h_scroll_offset, + cx, + ) + }) + }, + ); + + if let Some(interaction_state) = interaction_state.as_ref() { + table + .track_focus(&interaction_state.read(cx).focus_handle) + .id(("table", interaction_state.entity_id())) + .on_hover({ + let interaction_state = interaction_state.downgrade(); + move |hovered, window, cx| { + interaction_state + .update(cx, |interaction_state, cx| { + if *hovered { + interaction_state.horizontal_scrollbar.show(cx); + interaction_state.vertical_scrollbar.show(cx); + cx.notify(); + } else if !interaction_state + .focus_handle + .contains_focused(window, cx) + { + interaction_state.hide_scrollbars(window, cx); + } + }) + .ok(); + } + }) + .into_any_element() + } else { + table.into_any_element() + } + } +} + +// computed state related to how to render scrollbars +// one per axis +// on render we just read this off the keymap editor +// we update it when +// - settings change +// - on focus in, on focus out, on hover, etc. +#[derive(Debug)] +pub struct ScrollbarProperties { + axis: Axis, + show_scrollbar: bool, + show_track: bool, + auto_hide: bool, + hide_task: Option>, + state: ScrollbarState, +} + +impl ScrollbarProperties { + // Shows the scrollbar and cancels any pending hide task + fn show(&mut self, cx: &mut Context) { + if !self.auto_hide { + return; + } + self.show_scrollbar = true; + self.hide_task.take(); + cx.notify(); + } + + fn hide(&mut self, window: &mut Window, cx: &mut Context) { + const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); + + if !self.auto_hide { + return; + } + + let axis = self.axis; + self.hide_task = Some(cx.spawn_in(window, async move |keymap_editor, cx| { + cx.background_executor() + .timer(SCROLLBAR_SHOW_INTERVAL) + .await; + + if let Some(keymap_editor) = keymap_editor.upgrade() { + keymap_editor + .update(cx, |keymap_editor, cx| { + match axis { + Axis::Vertical => { + keymap_editor.vertical_scrollbar.show_scrollbar = false + } + Axis::Horizontal => { + keymap_editor.horizontal_scrollbar.show_scrollbar = false + } + } + cx.notify(); + }) + .ok(); + } + })); + } +} + +impl Component for Table<3> { + fn scope() -> ComponentScope { + ComponentScope::Layout + } + + fn description() -> Option<&'static str> { + Some("A table component for displaying data in rows and columns with optional styling.") + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + Some( + v_flex() + .gap_6() + .children(vec![ + example_group_with_title( + "Basic Tables", + vec![ + single_example( + "Simple Table", + Table::new() + .width(px(400.)) + .header(["Name", "Age", "City"]) + .row(["Alice", "28", "New York"]) + .row(["Bob", "32", "San Francisco"]) + .row(["Charlie", "25", "London"]) + .into_any_element(), + ), + single_example( + "Two Column Table", + Table::new() + .header(["Category", "Value"]) + .width(px(300.)) + .row(["Revenue", "$100,000"]) + .row(["Expenses", "$75,000"]) + .row(["Profit", "$25,000"]) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Styled Tables", + vec![ + single_example( + "Default", + Table::new() + .width(px(400.)) + .header(["Product", "Price", "Stock"]) + .row(["Laptop", "$999", "In Stock"]) + .row(["Phone", "$599", "Low Stock"]) + .row(["Tablet", "$399", "Out of Stock"]) + .into_any_element(), + ), + single_example( + "Striped", + Table::new() + .width(px(400.)) + .striped() + .header(["Product", "Price", "Stock"]) + .row(["Laptop", "$999", "In Stock"]) + .row(["Phone", "$599", "Low Stock"]) + .row(["Tablet", "$399", "Out of Stock"]) + .row(["Headphones", "$199", "In Stock"]) + .into_any_element(), + ), + ], + ), + example_group_with_title( + "Mixed Content Table", + vec![single_example( + "Table with Elements", + Table::new() + .width(px(840.)) + .header(["Status", "Name", "Priority", "Deadline", "Action"]) + .row([ + Indicator::dot().color(Color::Success).into_any_element(), + "Project A".into_any_element(), + "High".into_any_element(), + "2023-12-31".into_any_element(), + Button::new("view_a", "View") + .style(ButtonStyle::Filled) + .full_width() + .into_any_element(), + ]) + .row([ + Indicator::dot().color(Color::Warning).into_any_element(), + "Project B".into_any_element(), + "Medium".into_any_element(), + "2024-03-15".into_any_element(), + Button::new("view_b", "View") + .style(ButtonStyle::Filled) + .full_width() + .into_any_element(), + ]) + .row([ + Indicator::dot().color(Color::Error).into_any_element(), + "Project C".into_any_element(), + "Low".into_any_element(), + "2024-06-30".into_any_element(), + Button::new("view_c", "View") + .style(ButtonStyle::Filled) + .full_width() + .into_any_element(), + ]) + .into_any_element(), + )], + ), + ]) + .into_any_element(), + ) + } +} diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index e85032b460..3439a5b7f8 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -196,7 +196,6 @@ impl TerminalElement { interactivity: Default::default(), } .track_focus(&focus) - .element } //Vec> -> Clip out the parts of the ranges diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 6e3c7c78ae..237403d4ba 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -32,7 +32,6 @@ mod settings_group; mod stack; mod tab; mod tab_bar; -mod table; mod toggle; mod tooltip; @@ -73,7 +72,6 @@ pub use settings_group::*; pub use stack::*; pub use tab::*; pub use tab_bar::*; -pub use table::*; pub use toggle::*; pub use tooltip::*; diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index b57454d7c1..6da3d03ea1 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -8,11 +8,12 @@ use itertools::Itertools; #[derive(Debug, IntoElement, Clone, RegisterComponent)] pub struct KeyBinding { - /// A keybinding consists of a key and a set of modifier keys. - /// More then one keybinding produces a chord. + /// A keybinding consists of a set of keystrokes, + /// where each keystroke is a key and a set of modifier keys. + /// More than one keystroke produces a chord. /// - /// This should always contain at least one element. - key_binding: gpui::KeyBinding, + /// This should always contain at least one keystroke. + pub key_binding: gpui::KeyBinding, /// The [`PlatformStyle`] to use when displaying this keybinding. platform_style: PlatformStyle, diff --git a/crates/ui/src/components/table.rs b/crates/ui/src/components/table.rs deleted file mode 100644 index 3f1b73e441..0000000000 --- a/crates/ui/src/components/table.rs +++ /dev/null @@ -1,271 +0,0 @@ -use crate::{Indicator, prelude::*}; -use gpui::{AnyElement, FontWeight, IntoElement, Length, div}; - -/// A table component -#[derive(IntoElement, RegisterComponent)] -pub struct Table { - column_headers: Vec, - rows: Vec>, - column_count: usize, - striped: bool, - width: Length, -} - -impl Table { - /// Create a new table with a column count equal to the - /// number of headers provided. - pub fn new(headers: Vec>) -> Self { - let column_count = headers.len(); - - Table { - column_headers: headers.into_iter().map(Into::into).collect(), - column_count, - rows: Vec::new(), - striped: false, - width: Length::Auto, - } - } - - /// Adds a row to the table. - /// - /// The row must have the same number of columns as the table. - pub fn row(mut self, items: Vec>) -> Self { - if items.len() == self.column_count { - self.rows.push(items.into_iter().map(Into::into).collect()); - } else { - // TODO: Log error: Row length mismatch - } - self - } - - /// Adds multiple rows to the table. - /// - /// Each row must have the same number of columns as the table. - /// Rows that don't match the column count are ignored. - pub fn rows(mut self, rows: Vec>>) -> Self { - for row in rows { - self = self.row(row); - } - self - } - - fn base_cell_style(cx: &mut App) -> Div { - div() - .px_1p5() - .flex_1() - .justify_start() - .text_ui(cx) - .whitespace_nowrap() - .text_ellipsis() - .overflow_hidden() - } - - /// Enables row striping. - pub fn striped(mut self) -> Self { - self.striped = true; - self - } - - /// Sets the width of the table. - pub fn width(mut self, width: impl Into) -> Self { - self.width = width.into(); - self - } -} - -impl RenderOnce for Table { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let header = div() - .flex() - .flex_row() - .items_center() - .justify_between() - .w_full() - .p_2() - .border_b_1() - .border_color(cx.theme().colors().border) - .children(self.column_headers.into_iter().map(|h| { - Self::base_cell_style(cx) - .font_weight(FontWeight::SEMIBOLD) - .child(h) - })); - - let row_count = self.rows.len(); - let rows = self.rows.into_iter().enumerate().map(|(ix, row)| { - let is_last = ix == row_count - 1; - let bg = if ix % 2 == 1 && self.striped { - Some(cx.theme().colors().text.opacity(0.05)) - } else { - None - }; - div() - .w_full() - .flex() - .flex_row() - .items_center() - .justify_between() - .px_1p5() - .py_1() - .when_some(bg, |row, bg| row.bg(bg)) - .when(!is_last, |row| { - row.border_b_1().border_color(cx.theme().colors().border) - }) - .children(row.into_iter().map(|cell| match cell { - TableCell::String(s) => Self::base_cell_style(cx).child(s), - TableCell::Element(e) => Self::base_cell_style(cx).child(e), - })) - }); - - div() - .w(self.width) - .overflow_hidden() - .child(header) - .children(rows) - } -} - -/// Represents a cell in a table. -pub enum TableCell { - /// A cell containing a string value. - String(SharedString), - /// A cell containing a UI element. - Element(AnyElement), -} - -/// Creates a `TableCell` containing a string value. -pub fn string_cell(s: impl Into) -> TableCell { - TableCell::String(s.into()) -} - -/// Creates a `TableCell` containing an element. -pub fn element_cell(e: impl Into) -> TableCell { - TableCell::Element(e.into()) -} - -impl From for TableCell -where - E: Into, -{ - fn from(e: E) -> Self { - TableCell::String(e.into()) - } -} - -impl Component for Table { - fn scope() -> ComponentScope { - ComponentScope::Layout - } - - fn description() -> Option<&'static str> { - Some("A table component for displaying data in rows and columns with optional styling.") - } - - fn preview(_window: &mut Window, _cx: &mut App) -> Option { - Some( - v_flex() - .gap_6() - .children(vec![ - example_group_with_title( - "Basic Tables", - vec![ - single_example( - "Simple Table", - Table::new(vec!["Name", "Age", "City"]) - .width(px(400.)) - .row(vec!["Alice", "28", "New York"]) - .row(vec!["Bob", "32", "San Francisco"]) - .row(vec!["Charlie", "25", "London"]) - .into_any_element(), - ), - single_example( - "Two Column Table", - Table::new(vec!["Category", "Value"]) - .width(px(300.)) - .row(vec!["Revenue", "$100,000"]) - .row(vec!["Expenses", "$75,000"]) - .row(vec!["Profit", "$25,000"]) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Styled Tables", - vec![ - single_example( - "Default", - Table::new(vec!["Product", "Price", "Stock"]) - .width(px(400.)) - .row(vec!["Laptop", "$999", "In Stock"]) - .row(vec!["Phone", "$599", "Low Stock"]) - .row(vec!["Tablet", "$399", "Out of Stock"]) - .into_any_element(), - ), - single_example( - "Striped", - Table::new(vec!["Product", "Price", "Stock"]) - .width(px(400.)) - .striped() - .row(vec!["Laptop", "$999", "In Stock"]) - .row(vec!["Phone", "$599", "Low Stock"]) - .row(vec!["Tablet", "$399", "Out of Stock"]) - .row(vec!["Headphones", "$199", "In Stock"]) - .into_any_element(), - ), - ], - ), - example_group_with_title( - "Mixed Content Table", - vec![single_example( - "Table with Elements", - Table::new(vec!["Status", "Name", "Priority", "Deadline", "Action"]) - .width(px(840.)) - .row(vec![ - element_cell( - Indicator::dot().color(Color::Success).into_any_element(), - ), - string_cell("Project A"), - string_cell("High"), - string_cell("2023-12-31"), - element_cell( - Button::new("view_a", "View") - .style(ButtonStyle::Filled) - .full_width() - .into_any_element(), - ), - ]) - .row(vec![ - element_cell( - Indicator::dot().color(Color::Warning).into_any_element(), - ), - string_cell("Project B"), - string_cell("Medium"), - string_cell("2024-03-15"), - element_cell( - Button::new("view_b", "View") - .style(ButtonStyle::Filled) - .full_width() - .into_any_element(), - ), - ]) - .row(vec![ - element_cell( - Indicator::dot().color(Color::Error).into_any_element(), - ), - string_cell("Project C"), - string_cell("Low"), - string_cell("2024-06-30"), - element_cell( - Button::new("view_c", "View") - .style(ButtonStyle::Filled) - .full_width() - .into_any_element(), - ), - ]) - .into_any_element(), - )], - ), - ]) - .into_any_element(), - ) - } -} diff --git a/crates/workspace/src/theme_preview.rs b/crates/workspace/src/theme_preview.rs index ded1a08437..f9aee26cdd 100644 --- a/crates/workspace/src/theme_preview.rs +++ b/crates/workspace/src/theme_preview.rs @@ -5,8 +5,8 @@ use theme::all_theme_colors; use ui::{ AudioStatus, Avatar, AvatarAudioStatusIndicator, AvatarAvailabilityIndicator, ButtonLike, Checkbox, CheckboxWithLabel, CollaboratorAvailability, ContentGroup, DecoratedIcon, - ElevationIndex, Facepile, IconDecoration, Indicator, KeybindingHint, Switch, Table, TintColor, - Tooltip, element_cell, prelude::*, string_cell, utils::calculate_contrast_ratio, + ElevationIndex, Facepile, IconDecoration, Indicator, KeybindingHint, Switch, TintColor, + Tooltip, prelude::*, utils::calculate_contrast_ratio, }; use crate::{Item, Workspace}; diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index ea3f327ff0..333282611b 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -1429,6 +1429,8 @@ fn reload_keymaps(cx: &mut App, mut user_key_bindings: Vec) { "New Window", workspace::NewWindow, )]); + // todo: nicer api here? + settings_ui::keybindings::KeymapEventChannel::trigger_keymap_changed(cx); } pub fn load_default_keymap(cx: &mut App) {