settings_ui: Keyboard navigation (#39652)

Ben Kunkle and Mikayla created

Closes #ISSUE

Release Notes:

- N/A *or* Added/Fixed/Improved ...

---------

Co-authored-by: Mikayla <mikayla@zed.dev>

Change summary

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 
crates/ui/src/components/button/button_like.rs   |  14 
crates/ui/src/components/button/icon_button.rs   |   5 
crates/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(-)

Detailed changes

Cargo.lock 🔗

@@ -14376,6 +14376,8 @@ dependencies = [
  "paths",
  "pretty_assertions",
  "project",
+ "schemars 1.0.1",
+ "search",
  "serde",
  "session",
  "settings",

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"
+    }
   }
 ]

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"
+    }
   }
 ]

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"
+    }
   }
 ]

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<isize>,
     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);

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 {

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

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<String>,
     placeholder: Option<&'static str>,
     confirm: Option<Box<dyn Fn(Option<String>, &mut App)>>,
+    tab_index: Option<isize>,
 }
 
 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()

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<T: 'static> {
     pick: fn(&SettingsContent) -> &Option<T>,
@@ -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::<OpenSettingsEditor>()];
+            let settings_ui_actions = [
+                TypeId::of::<OpenSettingsEditor>(),
+                TypeId::of::<ToggleFocusNav>(),
+                TypeId::of::<FocusFile>(),
+                TypeId::of::<FocusNextFile>(),
+                TypeId::of::<FocusPreviousFile>(),
+            ];
             let has_flag = cx.has_flag::<SettingsUiFeatureFlag>();
             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<SubPage>> {
 }
 
 pub struct SettingsWindow {
-    files: Vec<SettingsUiFile>,
+    files: Vec<(SettingsUiFile, FocusHandle)>,
     current_file: SettingsUiFile,
     pages: Vec<SettingsPage>,
     search_bar: Entity<Editor>,
@@ -418,6 +447,9 @@ pub struct SettingsWindow {
     list_handle: UniformListScrollHandle,
     search_matches: Vec<Vec<bool>>,
     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<SettingsWindow>) {
+        let prev_files = self.files.clone();
         let settings_store = cx.global::<SettingsStore>();
         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<SettingsWindow>) -> 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>) {
+        self.navbar_focus_handle.focus(window);
+        window.focus_next();
+        cx.notify();
+    }
+
+    fn focus_first_content_item(&self, window: &mut Window, cx: &mut Context<Self>) {
+        self.content_focus_handle.focus(window);
+        window.focus_next();
+        cx.notify();
     }
 
     fn page_items(&self) -> impl Iterator<Item = &SettingsPageItem> {
@@ -1121,43 +1224,50 @@ impl SettingsWindow {
         window: &mut Window,
         cx: &mut Context<SettingsWindow>,
     ) -> 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<Self>) -> 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<T: From<String> + Into<String> + AsRef<str> + 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<B: Into<bool> + From<bool> + 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<T: NumericStepperType + Send + Sync>(
                 .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();

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 {

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<isize>) -> 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<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
     on_right_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
     children: SmallVec<[AnyElement; 2]>,
+    focus_handle: Option<FocusHandle>,
 }
 
 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()

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 {

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 {

crates/ui/src/components/dropdown_menu.rs 🔗

@@ -29,6 +29,7 @@ pub struct DropdownMenu {
     handle: Option<PopoverMenuHandle<ContextMenu>>,
     attach: Option<Corner>,
     offset: Option<Point<Pixels>>,
+    tab_index: Option<isize>,
 }
 
 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)

crates/ui/src/components/tree_view_item.rs 🔗

@@ -20,6 +20,7 @@ pub struct TreeViewItem {
     on_hover: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
     on_toggle: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
     on_secondary_mouse_down: Option<Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>>,
+    tab_index: Option<isize>,
 }
 
 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 {

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",