From 391e304c9f8f3569b2fbe2cea6207d70981c2401 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Tue, 7 Oct 2025 13:23:11 -0500 Subject: [PATCH] settings_ui: Keyboard navigation (#39652) Closes #ISSUE Release Notes: - N/A *or* Added/Fixed/Improved ... --------- Co-authored-by: Mikayla --- Cargo.lock | 2 + assets/keymaps/default-linux.json | 29 +- assets/keymaps/default-macos.json | 29 +- assets/keymaps/default-windows.json | 29 +- crates/gpui/src/elements/div.rs | 23 +- crates/gpui/src/tab_stop.rs | 8 +- crates/settings_ui/Cargo.toml | 4 +- crates/settings_ui/src/components.rs | 14 +- crates/settings_ui/src/settings_ui.rs | 270 +++++++++++++++--- crates/ui/src/components/button/button.rs | 5 + .../ui/src/components/button/button_like.rs | 14 +- .../ui/src/components/button/icon_button.rs | 5 + .../ui/src/components/button/toggle_button.rs | 5 + crates/ui/src/components/dropdown_menu.rs | 11 +- crates/ui/src/components/tree_view_item.rs | 48 ++-- crates/zed/src/zed.rs | 4 +- 16 files changed, 394 insertions(+), 106 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 240ac1cbbb5c34b1f69b206b681d73781632086b..f5102607322bd639af2bab394e2c005d96dd9d78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14376,6 +14376,8 @@ dependencies = [ "paths", "pretty_assertions", "project", + "schemars 1.0.1", + "search", "serde", "session", "settings", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index b59a823816ffd81fa78deb1c8fac84f8c654d169..1176faf03f7c695568d3656ae20c804733f93bf7 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -374,13 +374,6 @@ "ctrl-w": "workspace::CloseWindow" } }, - { - "context": "SettingsWindow", - "use_key_equivalents": true, - "bindings": { - "ctrl-w": "workspace::CloseWindow" - } - }, { "context": "BufferSearchBar", "bindings": { @@ -1250,5 +1243,27 @@ "bindings": { "ctrl-shift-enter": "workspace::OpenWithSystem" } + }, + { + "context": "SettingsWindow", + "use_key_equivalents": true, + "bindings": { + "ctrl-w": "workspace::CloseWindow", + "ctrl-f": "search::FocusSearch", + "ctrl-shift-e": "settings_editor::ToggleFocusNav", + // todo(settings_ui): cut this down based on the max files and overflow UI + "ctrl-1": ["settings_editor::FocusFile", 0], + "ctrl-2": ["settings_editor::FocusFile", 1], + "ctrl-3": ["settings_editor::FocusFile", 2], + "ctrl-4": ["settings_editor::FocusFile", 3], + "ctrl-5": ["settings_editor::FocusFile", 4], + "ctrl-6": ["settings_editor::FocusFile", 5], + "ctrl-7": ["settings_editor::FocusFile", 6], + "ctrl-8": ["settings_editor::FocusFile", 7], + "ctrl-9": ["settings_editor::FocusFile", 8], + "ctrl-0": ["settings_editor::FocusFile", 9], + "ctrl-pageup": "settings_editor::FocusPreviousFile", + "ctrl-pagedown": "settings_editor::FocusNextFile" + } } ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index dc4d74f84d040576c1c02378472618c105f7aac8..f2b0c0d3187ee0e32b4846316216b154a37783e1 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -431,13 +431,6 @@ "cmd-w": "workspace::CloseWindow" } }, - { - "context": "SettingsWindow", - "use_key_equivalents": true, - "bindings": { - "cmd-w": "workspace::CloseWindow" - } - }, { "context": "BufferSearchBar", "use_key_equivalents": true, @@ -1355,5 +1348,27 @@ "bindings": { "ctrl-shift-enter": "workspace::OpenWithSystem" } + }, + { + "context": "SettingsWindow", + "use_key_equivalents": true, + "bindings": { + "cmd-w": "workspace::CloseWindow", + "cmd-f": "search::FocusSearch", + "cmd-shift-e": "settings_editor::ToggleFocusNav", + // todo(settings_ui): cut this down based on the max files and overflow UI + "ctrl-1": ["settings_editor::FocusFile", 0], + "ctrl-2": ["settings_editor::FocusFile", 1], + "ctrl-3": ["settings_editor::FocusFile", 2], + "ctrl-4": ["settings_editor::FocusFile", 3], + "ctrl-5": ["settings_editor::FocusFile", 4], + "ctrl-6": ["settings_editor::FocusFile", 5], + "ctrl-7": ["settings_editor::FocusFile", 6], + "ctrl-8": ["settings_editor::FocusFile", 7], + "ctrl-9": ["settings_editor::FocusFile", 8], + "ctrl-0": ["settings_editor::FocusFile", 9], + "cmd-{": "settings_editor::FocusPreviousFile", + "cmd-}": "settings_editor::FocusNextFile" + } } ] diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 3e513d8603f611b92abaff654ee5676d0be85ad8..d5c93e0da45bd0a07d6eef83a5e01ede95de2a85 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -383,13 +383,6 @@ "ctrl-w": "workspace::CloseWindow" } }, - { - "context": "SettingsWindow", - "use_key_equivalents": true, - "bindings": { - "ctrl-w": "workspace::CloseWindow" - } - }, { "context": "BufferSearchBar", "use_key_equivalents": true, @@ -1271,5 +1264,27 @@ "alt-shift-l": "onboarding::SignIn", "shift-alt-a": "onboarding::OpenAccount" } + }, + { + "context": "SettingsWindow", + "use_key_equivalents": true, + "bindings": { + "ctrl-w": "workspace::CloseWindow", + "ctrl-f": "search::FocusSearch", + "ctrl-shift-e": "settings_editor::ToggleFocusNav", + // todo(settings_ui): cut this down based on the max files and overflow UI + "ctrl-1": ["settings_editor::FocusFile", 0], + "ctrl-2": ["settings_editor::FocusFile", 1], + "ctrl-3": ["settings_editor::FocusFile", 2], + "ctrl-4": ["settings_editor::FocusFile", 3], + "ctrl-5": ["settings_editor::FocusFile", 4], + "ctrl-6": ["settings_editor::FocusFile", 5], + "ctrl-7": ["settings_editor::FocusFile", 6], + "ctrl-8": ["settings_editor::FocusFile", 7], + "ctrl-9": ["settings_editor::FocusFile", 8], + "ctrl-0": ["settings_editor::FocusFile", 9], + "ctrl-pageup": "settings_editor::FocusPreviousFile", + "ctrl-pagedown": "settings_editor::FocusNextFile" + } } ] diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 1a578cd14b015a5b40ee7502b7787f3e3082623e..58ce51e95bc707dc7eb7d335bd1dafaf8cb0eb40 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -618,17 +618,25 @@ pub trait InteractiveElement: Sized { self } - /// Designate this element as a tab stop, equivalent to `tab_index(0)`. - /// This should be the primary mechanism for tab navigation within the application. - fn tab_stop(mut self) -> Self { - self.tab_index(0) + /// Set whether this element is a tab stop. + /// + /// When false, the element remains in tab-index order but cannot be reached via keyboard navigation. + /// Useful for container elements: focus the container, then call `window.focus_next()` to focus + /// the first tab stop inside it while having the container element itself be unreachable via the keyboard. + /// Should only be used with `tab_index`. + fn tab_stop(mut self, tab_stop: bool) -> Self { + self.interactivity().tab_stop = tab_stop; + self } - /// Set index of the tab stop order. This should only be used in conjunction with `tab_group` + /// Set index of the tab stop order, and set this node as a tab stop. + /// This will default the element to being a tab stop. See [`Self::tab_stop`] for more information. + /// This should only be used in conjunction with `tab_group` /// in order to not interfere with the tab index of other elements. fn tab_index(mut self, index: isize) -> Self { self.interactivity().focusable = true; self.interactivity().tab_index = Some(index); + self.interactivity().tab_stop = true; self } @@ -1505,6 +1513,7 @@ pub struct Interactivity { pub(crate) hitbox_behavior: HitboxBehavior, pub(crate) tab_index: Option, pub(crate) tab_group: bool, + pub(crate) tab_stop: bool, #[cfg(any(feature = "inspector", debug_assertions))] pub(crate) source_location: Option<&'static core::panic::Location<'static>>, @@ -1569,10 +1578,10 @@ impl Interactivity { .focus_handle .get_or_insert_with(|| cx.focus_handle()) .clone() - .tab_stop(false); + .tab_stop(self.tab_stop); if let Some(index) = self.tab_index { - handle = handle.tab_index(index).tab_stop(true); + handle = handle.tab_index(index); } self.tracked_focus_handle = Some(handle); diff --git a/crates/gpui/src/tab_stop.rs b/crates/gpui/src/tab_stop.rs index ea69bd11304fc14dec3f0ce9d9eea78abfdb218e..8a95a3975af736d544e01cbf6e212994b8e7e8c6 100644 --- a/crates/gpui/src/tab_stop.rs +++ b/crates/gpui/src/tab_stop.rs @@ -120,7 +120,9 @@ impl TabStopMap { } }; - let node = self.tab_node_for_focus_id(focused_id)?; + let Some(node) = self.tab_node_for_focus_id(focused_id) else { + return self.next(None); + }; let item = self.next_inner(node); if let Some(item) = item { @@ -155,7 +157,9 @@ impl TabStopMap { } }; - let node = self.tab_node_for_focus_id(focused_id)?; + let Some(node) = self.tab_node_for_focus_id(focused_id) else { + return self.prev(None); + }; let item = self.prev_inner(node); if let Some(item) = item { diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 75467ef8d4f23ee3442bb43a483a0b07cbb17599..c2cb1ce62b3284c37e6a038f7f6f399acfb8aabf 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -26,12 +26,14 @@ gpui.workspace = true menu.workspace = true paths.workspace = true project.workspace = true +schemars.workspace = true +search.workspace = true serde.workspace = true settings.workspace = true strum.workspace = true theme.workspace = true -ui.workspace = true ui_input.workspace = true +ui.workspace = true util.workspace = true workspace-hack.workspace = true workspace.workspace = true diff --git a/crates/settings_ui/src/components.rs b/crates/settings_ui/src/components.rs index f2fc7e74dbd13d1af3a0d89bd522500e00933c07..a29aae3bb1f2ef086e6d3289b03fbe29000d0f45 100644 --- a/crates/settings_ui/src/components.rs +++ b/crates/settings_ui/src/components.rs @@ -1,5 +1,5 @@ use editor::Editor; -use gpui::div; +use gpui::{Focusable, div}; use ui::{ ActiveTheme as _, App, FluentBuilder as _, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Styled as _, Window, @@ -10,6 +10,7 @@ pub struct SettingsEditor { initial_text: Option, placeholder: Option<&'static str>, confirm: Option, &mut App)>>, + tab_index: Option, } impl SettingsEditor { @@ -18,6 +19,7 @@ impl SettingsEditor { initial_text: None, placeholder: None, confirm: None, + tab_index: None, } } @@ -35,6 +37,11 @@ impl SettingsEditor { self.confirm = Some(Box::new(confirm)); self } + + pub(crate) fn tab_index(mut self, arg: isize) -> Self { + self.tab_index = Some(arg); + self + } } impl RenderOnce for SettingsEditor { @@ -55,7 +62,12 @@ impl RenderOnce for SettingsEditor { } }); + if let Some(tab_index) = self.tab_index { + editor.focus_handle(cx).tab_index(tab_index); + } + let weak_editor = editor.downgrade(); + let theme_colors = cx.theme().colors(); div() diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 4b75fd269ccf84a8a8c8364745e4e13d96ecd0b6..d71743e2430cb3aed9b6b915f7c8066c8305cbfd 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -7,11 +7,13 @@ use editor::{Editor, EditorEvent}; use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; use fuzzy::StringMatchCandidate; use gpui::{ - App, Div, Entity, Focusable, FontWeight, Global, ReadGlobal as _, ScrollHandle, Task, - TitlebarOptions, UniformListScrollHandle, Window, WindowHandle, WindowOptions, div, point, - prelude::*, px, size, uniform_list, + Action, App, Div, Entity, FocusHandle, Focusable, FontWeight, Global, ReadGlobal as _, + ScrollHandle, Task, TitlebarOptions, UniformListScrollHandle, Window, WindowHandle, + WindowOptions, actions, div, point, prelude::*, px, size, uniform_list, }; use project::WorktreeId; +use schemars::JsonSchema; +use serde::Deserialize; use settings::{ BottomDockLayout, CloseWindowWhenNoItems, CodeFade, CursorShape, OnLastWindowClosed, RestoreOnStartupBehavior, SaturatingBool, SettingsContent, SettingsStore, @@ -26,8 +28,8 @@ use std::{ sync::{Arc, LazyLock, RwLock, atomic::AtomicBool}, }; use ui::{ - ButtonLike, ContextMenu, Divider, DropdownMenu, DropdownStyle, IconButtonShape, PopoverMenu, - Switch, SwitchColor, TreeViewItem, WithScrollbar, prelude::*, + ButtonLike, ContextMenu, Divider, DropdownMenu, DropdownStyle, IconButtonShape, + KeybindingPosition, PopoverMenu, Switch, SwitchColor, TreeViewItem, WithScrollbar, prelude::*, }; use ui_input::{NumericStepper, NumericStepperStyle, NumericStepperType}; use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath}; @@ -35,6 +37,27 @@ use zed_actions::OpenSettingsEditor; use crate::components::SettingsEditor; +const NAVBAR_CONTAINER_TAB_INDEX: isize = 0; +const NAVBAR_GROUP_TAB_INDEX: isize = 1; +const CONTENT_CONTAINER_TAB_INDEX: isize = 2; +const CONTENT_GROUP_TAB_INDEX: isize = 3; + +actions!( + settings_editor, + [ + /// Toggles focus between the navbar and the main content. + ToggleFocusNav, + /// Focuses the next file in the file list. + FocusNextFile, + /// Focuses the previous file in the file list. + FocusPreviousFile + ] +); + +#[derive(Action, PartialEq, Eq, Clone, Copy, Debug, JsonSchema, Deserialize)] +#[action(namespace = settings_editor)] +struct FocusFile(pub u32); + #[derive(Clone, Copy)] struct SettingField { pick: fn(&SettingsContent) -> &Option, @@ -176,7 +199,13 @@ pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut workspace::Workspace, _, _| { workspace.register_action_renderer(|div, _, _, cx| { - let settings_ui_actions = [std::any::TypeId::of::()]; + let settings_ui_actions = [ + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + ]; let has_flag = cx.has_flag::(); command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _| { if has_flag { @@ -408,7 +437,7 @@ fn sub_page_stack_mut() -> std::sync::RwLockWriteGuard<'static, Vec> { } pub struct SettingsWindow { - files: Vec, + files: Vec<(SettingsUiFile, FocusHandle)>, current_file: SettingsUiFile, pages: Vec, search_bar: Entity, @@ -418,6 +447,9 @@ pub struct SettingsWindow { list_handle: UniformListScrollHandle, search_matches: Vec>, scroll_handle: ScrollHandle, + navbar_focus_handle: FocusHandle, + content_focus_handle: FocusHandle, + files_focus_handle: FocusHandle, } struct SubPage { @@ -703,6 +735,15 @@ impl SettingsWindow { search_task: None, search_matches: vec![], scroll_handle: ScrollHandle::new(), + navbar_focus_handle: cx + .focus_handle() + .tab_index(NAVBAR_CONTAINER_TAB_INDEX) + .tab_stop(false), + content_focus_handle: cx + .focus_handle() + .tab_index(CONTENT_CONTAINER_TAB_INDEX) + .tab_stop(false), + files_focus_handle: cx.focus_handle().tab_stop(false), }; this.fetch_files(cx); @@ -903,6 +944,7 @@ impl SettingsWindow { } fn fetch_files(&mut self, cx: &mut Context) { + let prev_files = self.files.clone(); let settings_store = cx.global::(); let mut ui_files = vec![]; let all_files = settings_store.get_all_files(); @@ -910,11 +952,21 @@ impl SettingsWindow { let Some(settings_ui_file) = SettingsUiFile::from_settings(file) else { continue; }; - ui_files.push(settings_ui_file); + let focus_handle = prev_files + .iter() + .find_map(|(prev_file, handle)| { + (prev_file == &settings_ui_file).then(|| handle.clone()) + }) + .unwrap_or_else(|| cx.focus_handle()); + ui_files.push((settings_ui_file, focus_handle)); } ui_files.reverse(); self.files = ui_files; - if !self.files.contains(&self.current_file) { + let current_file_still_exists = self + .files + .iter() + .any(|(file, _)| file == &self.current_file); + if !current_file_still_exists { self.change_file(0, cx); } } @@ -924,23 +976,31 @@ impl SettingsWindow { self.current_file = SettingsUiFile::User; return; } - if self.files[ix] == self.current_file { + if self.files[ix].0 == self.current_file { return; } - self.current_file = self.files[ix].clone(); + self.current_file = self.files[ix].0.clone(); self.navbar_entry = 0; self.build_ui(cx); } fn render_files(&self, _window: &mut Window, cx: &mut Context) -> Div { - h_flex() - .gap_1() - .children(self.files.iter().enumerate().map(|(ix, file)| { + h_flex().gap_1().children(self.files.iter().enumerate().map( + |(ix, (file, focus_handle))| { Button::new(ix, file.name()) .toggle_state(file == &self.current_file) .selected_style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .on_click(cx.listener(move |this, _, _window, cx| this.change_file(ix, cx))) - })) + .track_focus(focus_handle) + .on_click( + cx.listener(move |this, evt: &gpui::ClickEvent, window, cx| { + this.change_file(ix, cx); + if evt.is_keyboard() { + this.focus_first_nav_item(window, cx); + } + }), + ) + }, + )) } fn render_search(&self, _window: &mut Window, cx: &mut App) -> Div { @@ -964,6 +1024,8 @@ impl SettingsWindow { let visible_entries: Vec<_> = self.visible_navbar_entries().collect(); let visible_count = visible_entries.len(); + let nav_background = cx.theme().colors().panel_background; + v_flex() .w_64() .p_2p5() @@ -972,11 +1034,14 @@ impl SettingsWindow { .flex_none() .border_r_1() .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().panel_background) + .bg(nav_background) .child(self.render_search(window, cx)) .child( v_flex() - .size_full() + .flex_grow() + .track_focus(&self.navbar_focus_handle) + .tab_group() + .tab_index(NAVBAR_GROUP_TAB_INDEX) .child( uniform_list( "settings-ui-nav-bar", @@ -990,6 +1055,7 @@ impl SettingsWindow { ("settings-ui-navbar-entry", ix), entry.title, ) + .tab_index(0) .root_item(entry.is_root) .toggle_state(this.is_navbar_entry_selected(ix)) .when(entry.is_root, |item| { @@ -1000,10 +1066,16 @@ impl SettingsWindow { }, )) }) - .on_click(cx.listener(move |this, _, _, cx| { - this.navbar_entry = ix; - cx.notify(); - })) + .on_click(cx.listener( + move |this, evt: &gpui::ClickEvent, window, cx| { + this.navbar_entry = ix; + if evt.is_keyboard() { + // todo(settings_ui): Focus the actual item and scroll to it + this.focus_first_content_item(window, cx); + } + cx.notify(); + }, + )) .into_any_element() }) .collect() @@ -1014,6 +1086,37 @@ impl SettingsWindow { ) .vertical_scrollbar_for(self.list_handle.clone(), window, cx), ) + .child( + h_flex().w_full().justify_center().bg(nav_background).child( + Button::new( + "nav-key-hint", + if self.navbar_focus_handle.contains_focused(window, cx) { + "Focus Content" + } else { + "Focus Navbar" + }, + ) + .key_binding(ui::KeyBinding::for_action_in( + &ToggleFocusNav, + &self.navbar_focus_handle, + window, + cx, + )) + .key_binding_position(KeybindingPosition::Start), + ), + ) + } + + fn focus_first_nav_item(&self, window: &mut Window, cx: &mut Context) { + self.navbar_focus_handle.focus(window); + window.focus_next(); + cx.notify(); + } + + fn focus_first_content_item(&self, window: &mut Window, cx: &mut Context) { + self.content_focus_handle.focus(window); + window.focus_next(); + cx.notify(); } fn page_items(&self) -> impl Iterator { @@ -1121,43 +1224,50 @@ impl SettingsWindow { window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let mut page = v_flex() - .w_full() - .pt_4() - .pb_6() - .px_6() - .gap_4() - .bg(cx.theme().colors().editor_background) - .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx); - + let page_header; let page_content; if sub_page_stack().len() == 0 { - page = page.child(self.render_files(window, cx)); + page_header = self.render_files(window, cx); page_content = self .render_page_items(self.page_items(), window, cx) .into_any_element(); } else { - page = page.child( - h_flex() - .ml_neg_1p5() - .gap_1() - .child( - IconButton::new("back-btn", IconName::ArrowLeft) - .icon_size(IconSize::Small) - .shape(IconButtonShape::Square) - .on_click(cx.listener(|this, _, _, cx| { - this.pop_sub_page(cx); - })), - ) - .child(self.render_sub_page_breadcrumbs()), - ); + page_header = h_flex() + .ml_neg_1p5() + .gap_1() + .child( + IconButton::new("back-btn", IconName::ArrowLeft) + .icon_size(IconSize::Small) + .shape(IconButtonShape::Square) + .on_click(cx.listener(|this, _, _, cx| { + this.pop_sub_page(cx); + })), + ) + .child(self.render_sub_page_breadcrumbs()); let active_page_render_fn = sub_page_stack().last().unwrap().link.render.clone(); page_content = (active_page_render_fn)(self, window, cx); } - return page.child(page_content); + return v_flex() + .w_full() + .pt_4() + .pb_6() + .px_6() + .gap_4() + .track_focus(&self.content_focus_handle) + .bg(cx.theme().colors().editor_background) + .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx) + .child(page_header) + .child( + div() + .size_full() + .track_focus(&self.content_focus_handle) + .tab_group() + .tab_index(CONTENT_GROUP_TAB_INDEX) + .child(page_content), + ); } fn current_page_index(&self) -> usize { @@ -1197,6 +1307,31 @@ impl SettingsWindow { sub_page_stack_mut().pop(); cx.notify(); } + + fn focus_file_at_index(&mut self, index: usize, window: &mut Window) { + if let Some((_, handle)) = self.files.get(index) { + handle.focus(window); + } + } + + fn focused_file_index(&self, window: &Window, cx: &Context) -> usize { + if self.files_focus_handle.contains_focused(window, cx) + && let Some(index) = self + .files + .iter() + .position(|(_, handle)| handle.is_focused(window)) + { + return index; + } + if let Some(current_file_index) = self + .files + .iter() + .position(|(file, _)| file == &self.current_file) + { + return current_file_index; + } + 0 + } } impl Render for SettingsWindow { @@ -1204,6 +1339,7 @@ impl Render for SettingsWindow { let ui_font = theme::setup_ui_font(window, cx); div() + .id("settings-window") .key_context("SettingsWindow") .flex() .flex_row() @@ -1211,6 +1347,38 @@ impl Render for SettingsWindow { .font(ui_font) .bg(cx.theme().colors().background) .text_color(cx.theme().colors().text) + .on_action(cx.listener(|this, _: &search::FocusSearch, window, cx| { + this.search_bar.focus_handle(cx).focus(window); + })) + .on_action(cx.listener(|this, _: &ToggleFocusNav, window, cx| { + if this.navbar_focus_handle.contains_focused(window, cx) { + this.focus_first_content_item(window, cx); + } else { + this.focus_first_nav_item(window, cx); + } + })) + .on_action( + cx.listener(|this, FocusFile(file_index): &FocusFile, window, _| { + this.focus_file_at_index(*file_index as usize, window); + }), + ) + .on_action(cx.listener(|this, _: &FocusNextFile, window, cx| { + let next_index = usize::min( + this.focused_file_index(window, cx) + 1, + this.files.len().saturating_sub(1), + ); + this.focus_file_at_index(next_index, window); + })) + .on_action(cx.listener(|this, _: &FocusPreviousFile, window, cx| { + let prev_index = this.focused_file_index(window, cx).saturating_sub(1); + this.focus_file_at_index(prev_index, window); + })) + .on_action(|_: &menu::SelectNext, window, _| { + window.focus_next(); + }) + .on_action(|_: &menu::SelectPrevious, window, _| { + window.focus_prev(); + }) .child(self.render_nav(window, cx)) .child(self.render_page(window, cx)) } @@ -1276,6 +1444,7 @@ fn render_text_field + Into + AsRef + Clone>( let initial_text = Some(initial_text.clone()).filter(|s| !s.as_ref().is_empty()); SettingsEditor::new() + .tab_index(0) .when_some(initial_text, |editor, text| { editor.with_initial_text(text.into()) }) @@ -1318,6 +1487,7 @@ fn render_toggle_button + From + Copy>( .log_err(); // todo(settings_ui) don't log err } }) + .tab_index(0_isize) .color(SwitchColor::Accent) .into_any_element() } @@ -1356,6 +1526,7 @@ fn render_font_picker( .style(ButtonStyle::Outlined) .size(ButtonSize::Medium) .full_width() + .tab_index(0_isize) .child( h_flex() .w_full() @@ -1397,6 +1568,7 @@ fn render_numeric_stepper( .log_err(); // todo(settings_ui) don't log err } }) + .tab_index(0) .style(NumericStepperStyle::Outlined) .into_any_element() } @@ -1450,6 +1622,7 @@ where x: px(0.0), y: px(2.0), }) + .tab_index(0) .into_any_element() } @@ -1623,6 +1796,9 @@ mod test { search_matches: vec![], search_task: None, scroll_handle: ScrollHandle::new(), + navbar_focus_handle: cx.focus_handle(), + content_focus_handle: cx.focus_handle(), + files_focus_handle: cx.focus_handle(), }; settings_window.build_search_matches(); diff --git a/crates/ui/src/components/button/button.rs b/crates/ui/src/components/button/button.rs index e0104bf54f59694eb96aa2ba43be658ebc182d2a..1c67e956525da6adb3ecfa50d6d95b2845ef178c 100644 --- a/crates/ui/src/components/button/button.rs +++ b/crates/ui/src/components/button/button.rs @@ -402,6 +402,11 @@ impl ButtonCommon for Button { self.base = self.base.layer(elevation); self } + + fn track_focus(mut self, focus_handle: &gpui::FocusHandle) -> Self { + self.base = self.base.track_focus(focus_handle); + self + } } impl RenderOnce for Button { diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index fec5da57f3dd68295ca592a27777adfaa5406a34..223a5e4949fe74250614c8ecb074bba7f75cee20 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -1,6 +1,6 @@ use documented::Documented; use gpui::{ - AnyElement, AnyView, ClickEvent, CursorStyle, DefiniteLength, Hsla, MouseButton, + AnyElement, AnyView, ClickEvent, CursorStyle, DefiniteLength, FocusHandle, Hsla, MouseButton, MouseClickEvent, MouseDownEvent, MouseUpEvent, Rems, StyleRefinement, relative, transparent_black, }; @@ -41,6 +41,8 @@ pub trait ButtonCommon: Clickable + Disableable { fn tab_index(self, tab_index: impl Into) -> Self; fn layer(self, elevation: ElevationIndex) -> Self; + + fn track_focus(self, focus_handle: &FocusHandle) -> Self; } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)] @@ -405,6 +407,7 @@ pub struct ButtonLike { on_click: Option>, on_right_click: Option>, children: SmallVec<[AnyElement; 2]>, + focus_handle: Option, } impl ButtonLike { @@ -428,6 +431,7 @@ impl ButtonLike { on_right_click: None, layer: None, tab_index: None, + focus_handle: None, } } @@ -549,6 +553,11 @@ impl ButtonCommon for ButtonLike { self.layer = Some(elevation); self } + + fn track_focus(mut self, focus_handle: &gpui::FocusHandle) -> Self { + self.focus_handle = Some(focus_handle.clone()); + self + } } impl VisibleOnHover for ButtonLike { @@ -575,6 +584,9 @@ impl RenderOnce for ButtonLike { .h_flex() .id(self.id.clone()) .when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index)) + .when_some(self.focus_handle, |this, focus_handle| { + this.track_focus(&focus_handle) + }) .font_ui(cx) .group("") .flex_none() diff --git a/crates/ui/src/components/button/icon_button.rs b/crates/ui/src/components/button/icon_button.rs index 74fc4851fe1cc5c6848fdca1208e59dbfba12540..961176ed6cee7e55c7a51cd52719c0eef8a8f181 100644 --- a/crates/ui/src/components/button/icon_button.rs +++ b/crates/ui/src/components/button/icon_button.rs @@ -173,6 +173,11 @@ impl ButtonCommon for IconButton { self.base = self.base.layer(elevation); self } + + fn track_focus(mut self, focus_handle: &gpui::FocusHandle) -> Self { + self.base = self.base.track_focus(focus_handle); + self + } } impl VisibleOnHover for IconButton { diff --git a/crates/ui/src/components/button/toggle_button.rs b/crates/ui/src/components/button/toggle_button.rs index a50918b1094eaf75661c91d070d2d8cd8b364eb9..36f1972cf9ad8a9a7eac92e8b2648db78f806347 100644 --- a/crates/ui/src/components/button/toggle_button.rs +++ b/crates/ui/src/components/button/toggle_button.rs @@ -132,6 +132,11 @@ impl ButtonCommon for ToggleButton { self.base = self.base.layer(elevation); self } + + fn track_focus(mut self, focus_handle: &gpui::FocusHandle) -> Self { + self.base = self.base.track_focus(focus_handle); + self + } } impl RenderOnce for ToggleButton { diff --git a/crates/ui/src/components/dropdown_menu.rs b/crates/ui/src/components/dropdown_menu.rs index f4a4b875cc887c226e5796cb2381d17ce4d6858f..8a1abc312748bdd1fdb087973708d58579ffbc1d 100644 --- a/crates/ui/src/components/dropdown_menu.rs +++ b/crates/ui/src/components/dropdown_menu.rs @@ -29,6 +29,7 @@ pub struct DropdownMenu { handle: Option>, attach: Option, offset: Option>, + tab_index: Option, } impl DropdownMenu { @@ -48,6 +49,7 @@ impl DropdownMenu { handle: None, attach: None, offset: None, + tab_index: None, } } @@ -67,6 +69,7 @@ impl DropdownMenu { handle: None, attach: None, offset: None, + tab_index: None, } } @@ -101,6 +104,11 @@ impl DropdownMenu { self.offset = Some(offset); self } + + pub fn tab_index(mut self, arg: isize) -> Self { + self.tab_index = Some(arg); + self + } } impl Disableable for DropdownMenu { @@ -140,7 +148,8 @@ impl RenderOnce for DropdownMenu { .when(full_width, |this| this.full_width()) .size(trigger_size) .disabled(self.disabled), - }; + } + .when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index)); PopoverMenu::new((self.id.clone(), "popover")) .full_width(self.full_width) diff --git a/crates/ui/src/components/tree_view_item.rs b/crates/ui/src/components/tree_view_item.rs index 73587e0e563d33d8d420fb6c8b5671bf5f150601..53539736e7fa1f98c845f869b6bee0e26f4415f8 100644 --- a/crates/ui/src/components/tree_view_item.rs +++ b/crates/ui/src/components/tree_view_item.rs @@ -20,6 +20,7 @@ pub struct TreeViewItem { on_hover: Option>, on_toggle: Option>, on_secondary_mouse_down: Option>, + tab_index: Option, } impl TreeViewItem { @@ -39,6 +40,7 @@ impl TreeViewItem { on_hover: None, on_toggle: None, on_secondary_mouse_down: None, + tab_index: None, } } @@ -73,6 +75,11 @@ impl TreeViewItem { self } + pub fn tab_index(mut self, tab_index: isize) -> Self { + self.tab_index = Some(tab_index); + self + } + pub fn expanded(mut self, toggle: bool) -> Self { self.expanded = toggle; self @@ -142,6 +149,7 @@ impl RenderOnce for TreeViewItem { .cursor_pointer() .size_full() .relative() + .when_some(self.tab_index, |this, index| this.tab_index(index)) .map(|this| { let label = self.label; if self.root_item { @@ -151,16 +159,10 @@ impl RenderOnce for TreeViewItem { .gap_2p5() .rounded_sm() .border_1() - .map(|this| { - if self.focused && self.selected { - this.border_color(focused_border).bg(selected_bg) - } else if self.focused { - this.border_color(focused_border) - } else if self.selected { - this.border_color(selected_border).bg(selected_bg) - } else { - this.border_color(transparent_border) - } + .focus(|s| s.border_color(focused_border)) + .border_color(transparent_border) + .when(self.selected, |this| { + this.border_color(selected_border).bg(selected_bg) }) .hover(|s| s.bg(cx.theme().colors().element_hover)) .child( @@ -181,21 +183,17 @@ impl RenderOnce for TreeViewItem { } else { this.child(indentation_line).child( h_flex() + .id("nested_inner_tree_view_item") .w_full() .flex_grow() .px_1() .rounded_sm() .border_1() - .map(|this| { - if self.focused && self.selected { - this.border_color(focused_border).bg(selected_bg) - } else if self.focused { - this.border_color(focused_border) - } else if self.selected { - this.border_color(selected_border).bg(selected_bg) - } else { - this.border_color(transparent_border) - } + .focusable() + .in_focus(|s| s.border_color(focused_border)) + .border_color(transparent_border) + .when(self.selected, |this| { + this.border_color(selected_border).bg(selected_bg) }) .hover(|s| s.bg(cx.theme().colors().element_hover)) .child( @@ -209,11 +207,13 @@ impl RenderOnce for TreeViewItem { .when_some( self.on_click.filter(|_| !self.disabled), |this, on_click| { - if self.root_item && self.on_toggle.is_some() { - let on_toggle = self.on_toggle.clone().unwrap(); - + if self.root_item + && let Some(on_toggle) = self.on_toggle.clone() + { this.on_click(move |event, window, cx| { - on_click(event, window, cx); + if !event.is_keyboard() { + on_click(event, window, cx); + } on_toggle(event, window, cx); }) } else { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index a39b92e83591949ed37d242bbc6a91a1232f09e6..cc2d0086c21630213ab768a36fa4183ffb5ca957 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4461,7 +4461,8 @@ mod tests { | "agent::NewNativeAgentThreadFromSummary" | "action::Sequence" | "zed::OpenBrowser" - | "zed::OpenZedUrl" => {} + | "zed::OpenZedUrl" + | "settings_editor::FocusFile" => {} _ => { let result = cx.build_action(action, None); match &result { @@ -4576,6 +4577,7 @@ mod tests { "repl", "rules_library", "search", + "settings_editor", "settings_profile_selector", "snippets", "stash_picker",