Cargo.lock 🔗
@@ -14376,6 +14376,8 @@ dependencies = [
"paths",
"pretty_assertions",
"project",
+ "schemars 1.0.1",
+ "search",
"serde",
"session",
"settings",
Ben Kunkle and Mikayla created
Closes #ISSUE
Release Notes:
- N/A *or* Added/Fixed/Improved ...
---------
Co-authored-by: Mikayla <mikayla@zed.dev>
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(-)
@@ -14376,6 +14376,8 @@ dependencies = [
"paths",
"pretty_assertions",
"project",
+ "schemars 1.0.1",
+ "search",
"serde",
"session",
"settings",
@@ -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"
+ }
}
]
@@ -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"
+ }
}
]
@@ -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"
+ }
}
]
@@ -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);
@@ -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 {
@@ -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
@@ -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()
@@ -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();
@@ -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 {
@@ -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()
@@ -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 {
@@ -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 {
@@ -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)
@@ -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 {
@@ -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",