settings_ui: Keyboard navigation (#39652)

Closes #ISSUE

Release Notes:

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

---------

Co-authored-by: Mikayla <mikayla@zed.dev>
This commit is contained in:
Ben Kunkle 2025-10-07 13:23:11 -05:00 committed by GitHub
parent 3106472bf3
commit 391e304c9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 394 additions and 106 deletions

2
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

View file

@ -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);

View file

@ -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 {

View file

@ -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

View file

@ -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()

View file

@ -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();

View file

@ -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 {

View file

@ -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()

View file

@ -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 {

View file

@ -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 {

View file

@ -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)

View file

@ -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 {

View file

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