From 6b77654f66c2a5425a8cea2fb6079e6b2ff4d32d Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Tue, 5 Aug 2025 14:48:15 -0500 Subject: [PATCH] onboarding: Wire up tab index (#35659) Closes #ISSUE Allows tabbing through everything in all three pages. Until #35075 is merged it is not possible to actually "click" tab focused buttons with the keyboard. Additionally adds an action `onboarding::Finish` and displays the keybind. The action corresponds to both the "Skip all" and "Start Building" buttons, with the keybind displayed similar to how it is for the page nav buttons Release Notes: - N/A *or* Added/Fixed/Improved ... --------- Co-authored-by: MrSubidubi --- assets/keymaps/default-linux.json | 3 +- assets/keymaps/default-macos.json | 3 +- crates/ai_onboarding/src/ai_upsell_card.rs | 8 +- crates/gpui/src/window.rs | 9 + crates/onboarding/src/ai_setup_page.rs | 127 +++++++----- crates/onboarding/src/basics_page.rs | 196 ++++++++++-------- crates/onboarding/src/editing_page.rs | 147 ++++++++----- crates/onboarding/src/onboarding.rs | 141 ++++++++----- .../ui/src/components/button/toggle_button.rs | 15 ++ crates/ui/src/components/numeric_stepper.rs | 32 +++ crates/ui/src/components/toggle.rs | 104 ++++++---- 11 files changed, 505 insertions(+), 280 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index ef5354e82d87e9f1e91d5e6ba4b6bfb8e7080b39..81f5c695a2e8de9d9750bca33fdd786ecb6874fc 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1175,7 +1175,8 @@ "bindings": { "ctrl-1": "onboarding::ActivateBasicsPage", "ctrl-2": "onboarding::ActivateEditingPage", - "ctrl-3": "onboarding::ActivateAISetupPage" + "ctrl-3": "onboarding::ActivateAISetupPage", + "ctrl-escape": "onboarding::Finish" } } ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 3287e50acb454799c7e2797c0414f1ff45e1a023..69958fd1f8f4ca1b5b7ee851a4e9af518cecfc6c 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1277,7 +1277,8 @@ "bindings": { "cmd-1": "onboarding::ActivateBasicsPage", "cmd-2": "onboarding::ActivateEditingPage", - "cmd-3": "onboarding::ActivateAISetupPage" + "cmd-3": "onboarding::ActivateAISetupPage", + "cmd-escape": "onboarding::Finish" } } ] diff --git a/crates/ai_onboarding/src/ai_upsell_card.rs b/crates/ai_onboarding/src/ai_upsell_card.rs index 2408b6aa371921fd5b4ca0870665476a9e596854..89a782a7c2d807cdd6a0719fad093d3d492bc5e8 100644 --- a/crates/ai_onboarding/src/ai_upsell_card.rs +++ b/crates/ai_onboarding/src/ai_upsell_card.rs @@ -12,6 +12,7 @@ pub struct AiUpsellCard { pub sign_in_status: SignInStatus, pub sign_in: Arc, pub user_plan: Option, + pub tab_index: Option, } impl AiUpsellCard { @@ -28,6 +29,7 @@ impl AiUpsellCard { }) .detach_and_log_err(cx); }), + tab_index: None, } } } @@ -112,7 +114,8 @@ impl RenderOnce for AiUpsellCard { .on_click(move |_, _window, cx| { telemetry::event!("Start Trial Clicked", state = "post-sign-in"); cx.open_url(&zed_urls::start_trial_url(cx)) - }), + }) + .when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index)), ) .child( Label::new("No credit card required") @@ -123,6 +126,7 @@ impl RenderOnce for AiUpsellCard { _ => Button::new("sign_in", "Sign In") .full_width() .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index)) .on_click({ let callback = self.sign_in.clone(); move |_, window, cx| { @@ -193,6 +197,7 @@ impl Component for AiUpsellCard { sign_in_status: SignInStatus::SignedOut, sign_in: Arc::new(|_, _| {}), user_plan: None, + tab_index: Some(0), } .into_any_element(), ), @@ -202,6 +207,7 @@ impl Component for AiUpsellCard { sign_in_status: SignInStatus::SignedIn, sign_in: Arc::new(|_, _| {}), user_plan: None, + tab_index: Some(1), } .into_any_element(), ), diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 6ebb1cac40c550680b81cbe5b05180b3009b8475..9e4c1c26c57b1a2e2c45875d9d7c886f9e603382 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -4699,6 +4699,8 @@ pub enum ElementId { Path(Arc), /// A code location. CodeLocation(core::panic::Location<'static>), + /// A labeled child of an element. + NamedChild(Box, SharedString), } impl ElementId { @@ -4719,6 +4721,7 @@ impl Display for ElementId { ElementId::Uuid(uuid) => write!(f, "{}", uuid)?, ElementId::Path(path) => write!(f, "{}", path.display())?, ElementId::CodeLocation(location) => write!(f, "{}", location)?, + ElementId::NamedChild(id, name) => write!(f, "{}-{}", id, name)?, } Ok(()) @@ -4809,6 +4812,12 @@ impl From<(&'static str, u32)> for ElementId { } } +impl> From<(ElementId, T)> for ElementId { + fn from((id, name): (ElementId, T)) -> Self { + ElementId::NamedChild(Box::new(id), name.into()) + } +} + /// A rectangle to be rendered in the window at the given position and size. /// Passed as an argument [`Window::paint_quad`]. #[derive(Clone)] diff --git a/crates/onboarding/src/ai_setup_page.rs b/crates/onboarding/src/ai_setup_page.rs index b5dda7601f9111d0d90452cd2578f4cf826d5afa..098907870b43152e9981e3bcd769f757af6de1dd 100644 --- a/crates/onboarding/src/ai_setup_page.rs +++ b/crates/onboarding/src/ai_setup_page.rs @@ -1,9 +1,11 @@ use std::sync::Arc; use ai_onboarding::{AiUpsellCard, SignInStatus}; +use client::UserStore; use fs::Fs; use gpui::{ - Action, AnyView, App, DismissEvent, EventEmitter, FocusHandle, Focusable, Window, prelude::*, + Action, AnyView, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, WeakEntity, + Window, prelude::*, }; use itertools; use language_model::{LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry}; @@ -14,15 +16,14 @@ use ui::{ prelude::*, tooltip_container, }; use util::ResultExt; -use workspace::ModalView; +use workspace::{ModalView, Workspace}; use zed_actions::agent::OpenSettings; -use crate::Onboarding; - const FEATURED_PROVIDERS: [&'static str; 4] = ["anthropic", "google", "openai", "ollama"]; fn render_llm_provider_section( - onboarding: &Onboarding, + tab_index: &mut isize, + workspace: WeakEntity, disabled: bool, window: &mut Window, cx: &mut App, @@ -37,10 +38,10 @@ fn render_llm_provider_section( .color(Color::Muted), ), ) - .child(render_llm_provider_card(onboarding, disabled, window, cx)) + .child(render_llm_provider_card(tab_index, workspace, disabled, window, cx)) } -fn render_privacy_card(disabled: bool, cx: &mut App) -> impl IntoElement { +fn render_privacy_card(tab_index: &mut isize, disabled: bool, cx: &mut App) -> impl IntoElement { let privacy_badge = || { Badge::new("Privacy") .icon(IconName::ShieldCheck) @@ -98,6 +99,10 @@ fn render_privacy_card(disabled: bool, cx: &mut App) -> impl IntoElement { .icon_color(Color::Muted) .on_click(|_, _, cx| { cx.open_url("https://zed.dev/docs/ai/privacy-and-security"); + }) + .tab_index({ + *tab_index += 1; + *tab_index - 1 }), ), ), @@ -114,7 +119,8 @@ fn render_privacy_card(disabled: bool, cx: &mut App) -> impl IntoElement { } fn render_llm_provider_card( - onboarding: &Onboarding, + tab_index: &mut isize, + workspace: WeakEntity, disabled: bool, _: &mut Window, cx: &mut App, @@ -140,6 +146,10 @@ fn render_llm_provider_card( ButtonLike::new(("onboarding-ai-setup-buttons", index)) .size(ButtonSize::Large) + .tab_index({ + *tab_index += 1; + *tab_index - 1 + }) .child( h_flex() .group(&group_name) @@ -188,7 +198,7 @@ fn render_llm_provider_card( ), ) .on_click({ - let workspace = onboarding.workspace.clone(); + let workspace = workspace.clone(); move |_, window, cx| { workspace .update(cx, |workspace, cx| { @@ -219,57 +229,56 @@ fn render_llm_provider_card( .icon_size(IconSize::XSmall) .on_click(|_event, window, cx| { window.dispatch_action(OpenSettings.boxed_clone(), cx) + }) + .tab_index({ + *tab_index += 1; + *tab_index - 1 }), ) } pub(crate) fn render_ai_setup_page( - onboarding: &Onboarding, + workspace: WeakEntity, + user_store: Entity, window: &mut Window, cx: &mut App, ) -> impl IntoElement { + let mut tab_index = 0; let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai; - let backdrop = div() - .id("backdrop") - .size_full() - .absolute() - .inset_0() - .bg(cx.theme().colors().editor_background) - .opacity(0.8) - .block_mouse_except_scroll(); - v_flex() .gap_2() - .child(SwitchField::new( - "enable_ai", - "Enable AI features", - None, - if is_ai_disabled { - ToggleState::Unselected - } else { - ToggleState::Selected - }, - |toggle_state, _, cx| { - let enabled = match toggle_state { - ToggleState::Indeterminate => { - return; - } - ToggleState::Unselected => false, - ToggleState::Selected => true, - }; - - let fs = ::global(cx); - update_settings_file::( - fs, - cx, - move |ai_settings: &mut Option, _| { - *ai_settings = Some(!enabled); - }, - ); - }, - )) - .child(render_privacy_card(is_ai_disabled, cx)) + .child( + SwitchField::new( + "enable_ai", + "Enable AI features", + None, + if is_ai_disabled { + ToggleState::Unselected + } else { + ToggleState::Selected + }, + |&toggle_state, _, cx| { + let fs = ::global(cx); + update_settings_file::( + fs, + cx, + move |ai_settings: &mut Option, _| { + *ai_settings = match toggle_state { + ToggleState::Indeterminate => None, + ToggleState::Unselected => Some(true), + ToggleState::Selected => Some(false), + }; + }, + ); + }, + ) + .tab_index({ + tab_index += 1; + tab_index - 1 + }), + ) + .child(render_privacy_card(&mut tab_index, is_ai_disabled, cx)) .child( v_flex() .mt_2() @@ -277,15 +286,31 @@ pub(crate) fn render_ai_setup_page( .child(AiUpsellCard { sign_in_status: SignInStatus::SignedIn, sign_in: Arc::new(|_, _| {}), - user_plan: onboarding.user_store.read(cx).plan(), + user_plan: user_store.read(cx).plan(), + tab_index: Some({ + tab_index += 1; + tab_index - 1 + }), }) .child(render_llm_provider_section( - onboarding, + &mut tab_index, + workspace, is_ai_disabled, window, cx, )) - .when(is_ai_disabled, |this| this.child(backdrop)), + .when(is_ai_disabled, |this| { + this.child( + div() + .id("backdrop") + .size_full() + .absolute() + .inset_0() + .bg(cx.theme().colors().editor_background) + .opacity(0.8) + .block_mouse_except_scroll(), + ) + }), ) } diff --git a/crates/onboarding/src/basics_page.rs b/crates/onboarding/src/basics_page.rs index 21ea74f01c3fb1c890e16420f2fe144feac3a444..a4e402805117137da8d41fa461198da6b437e533 100644 --- a/crates/onboarding/src/basics_page.rs +++ b/crates/onboarding/src/basics_page.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use client::TelemetrySettings; use fs::Fs; -use gpui::{App, IntoElement, Window}; +use gpui::{App, IntoElement}; use settings::{BaseKeymap, Settings, update_settings_file}; use theme::{ Appearance, SystemAppearance, ThemeMode, ThemeName, ThemeRegistry, ThemeSelection, @@ -16,7 +16,7 @@ use vim_mode_setting::VimModeSetting; use crate::theme_preview::{ThemePreviewStyle, ThemePreviewTile}; -fn render_theme_section(_window: &mut Window, cx: &mut App) -> impl IntoElement { +fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement { let theme_selection = ThemeSettings::get_global(cx).theme_selection.clone(); let system_appearance = theme::SystemAppearance::global(cx); let theme_selection = theme_selection.unwrap_or_else(|| ThemeSelection::Dynamic { @@ -55,6 +55,7 @@ fn render_theme_section(_window: &mut Window, cx: &mut App) -> impl IntoElement ) }), ) + .tab_index(tab_index) .selected_index(theme_mode as usize) .style(ui::ToggleButtonGroupStyle::Outlined) .button_width(rems_from_px(64.)), @@ -64,10 +65,11 @@ fn render_theme_section(_window: &mut Window, cx: &mut App) -> impl IntoElement h_flex() .gap_4() .justify_between() - .children(render_theme_previews(&theme_selection, cx)), + .children(render_theme_previews(tab_index, &theme_selection, cx)), ); fn render_theme_previews( + tab_index: &mut isize, theme_selection: &ThemeSelection, cx: &mut App, ) -> [impl IntoElement; 3] { @@ -110,12 +112,12 @@ fn render_theme_section(_window: &mut Window, cx: &mut App) -> impl IntoElement let colors = cx.theme().colors(); v_flex() - .id(name.clone()) .w_full() .items_center() .gap_1() .child( h_flex() + .id(name.clone()) .relative() .w_full() .border_2() @@ -128,6 +130,20 @@ fn render_theme_section(_window: &mut Window, cx: &mut App) -> impl IntoElement this.opacity(0.8).hover(|s| s.border_color(colors.border)) } }) + .tab_index({ + *tab_index += 1; + *tab_index - 1 + }) + .focus(|mut style| { + style.border_color = Some(colors.border_focused); + style + }) + .on_click({ + let theme_name = theme.name.clone(); + move |_, _, cx| { + write_theme_change(theme_name.clone(), theme_mode, cx); + } + }) .map(|this| { if theme_mode == ThemeMode::System { let (light, dark) = ( @@ -151,12 +167,6 @@ fn render_theme_section(_window: &mut Window, cx: &mut App) -> impl IntoElement .color(Color::Muted) .size(LabelSize::Small), ) - .on_click({ - let theme_name = theme.name.clone(); - move |_, _, cx| { - write_theme_change(theme_name.clone(), theme_mode, cx); - } - }) }); theme_previews @@ -187,15 +197,7 @@ fn render_theme_section(_window: &mut Window, cx: &mut App) -> impl IntoElement } } -fn write_keymap_base(keymap_base: BaseKeymap, cx: &App) { - let fs = ::global(cx); - - update_settings_file::(fs, cx, move |setting, _| { - *setting = Some(keymap_base); - }); -} - -fn render_telemetry_section(cx: &App) -> impl IntoElement { +fn render_telemetry_section(tab_index: &mut isize, cx: &App) -> impl IntoElement { let fs = ::global(cx); v_flex() @@ -225,7 +227,10 @@ fn render_telemetry_section(cx: &App) -> impl IntoElement { move |setting, _| setting.metrics = Some(enabled), ); }}, - )) + ).tab_index({ + *tab_index += 1; + *tab_index + })) .child(SwitchField::new( "onboarding-telemetry-crash-reports", "Help Fix Zed", @@ -251,10 +256,13 @@ fn render_telemetry_section(cx: &App) -> impl IntoElement { ); } } - )) + ).tab_index({ + *tab_index += 1; + *tab_index + })) } -pub(crate) fn render_basics_page(window: &mut Window, cx: &mut App) -> impl IntoElement { +fn render_base_keymap_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement { let base_keymap = match BaseKeymap::get_global(cx) { BaseKeymap::VSCode => Some(0), BaseKeymap::JetBrains => Some(1), @@ -265,67 +273,89 @@ pub(crate) fn render_basics_page(window: &mut Window, cx: &mut App) -> impl Into BaseKeymap::TextMate | BaseKeymap::None => None, }; - v_flex() - .gap_6() - .child(render_theme_section(window, cx)) - .child( - v_flex().gap_2().child(Label::new("Base Keymap")).child( - ToggleButtonGroup::two_rows( - "multiple_row_test", - [ - ToggleButtonWithIcon::new("VS Code", IconName::EditorVsCode, |_, _, cx| { - write_keymap_base(BaseKeymap::VSCode, cx); - }), - ToggleButtonWithIcon::new("Jetbrains", IconName::EditorJetBrains, |_, _, cx| { - write_keymap_base(BaseKeymap::JetBrains, cx); - }), - ToggleButtonWithIcon::new("Sublime Text", IconName::EditorSublime, |_, _, cx| { - write_keymap_base(BaseKeymap::SublimeText, cx); - }), - ], - [ - ToggleButtonWithIcon::new("Atom", IconName::EditorAtom, |_, _, cx| { - write_keymap_base(BaseKeymap::Atom, cx); - }), - ToggleButtonWithIcon::new("Emacs", IconName::EditorEmacs, |_, _, cx| { - write_keymap_base(BaseKeymap::Emacs, cx); - }), - ToggleButtonWithIcon::new("Cursor (Beta)", IconName::EditorCursor, |_, _, cx| { - write_keymap_base(BaseKeymap::Cursor, cx); - }), - ], - ) - .when_some(base_keymap, |this, base_keymap| this.selected_index(base_keymap)) - .button_width(rems_from_px(216.)) - .size(ui::ToggleButtonGroupSize::Medium) - .style(ui::ToggleButtonGroupStyle::Outlined) - ), + return v_flex().gap_2().child(Label::new("Base Keymap")).child( + ToggleButtonGroup::two_rows( + "base_keymap_selection", + [ + ToggleButtonWithIcon::new("VS Code", IconName::EditorVsCode, |_, _, cx| { + write_keymap_base(BaseKeymap::VSCode, cx); + }), + ToggleButtonWithIcon::new("Jetbrains", IconName::EditorJetBrains, |_, _, cx| { + write_keymap_base(BaseKeymap::JetBrains, cx); + }), + ToggleButtonWithIcon::new("Sublime Text", IconName::EditorSublime, |_, _, cx| { + write_keymap_base(BaseKeymap::SublimeText, cx); + }), + ], + [ + ToggleButtonWithIcon::new("Atom", IconName::EditorAtom, |_, _, cx| { + write_keymap_base(BaseKeymap::Atom, cx); + }), + ToggleButtonWithIcon::new("Emacs", IconName::EditorEmacs, |_, _, cx| { + write_keymap_base(BaseKeymap::Emacs, cx); + }), + ToggleButtonWithIcon::new("Cursor (Beta)", IconName::EditorCursor, |_, _, cx| { + write_keymap_base(BaseKeymap::Cursor, cx); + }), + ], ) - .child(SwitchField::new( - "onboarding-vim-mode", - "Vim Mode", - Some("Coming from Neovim? Zed's first-class implementation of Vim Mode has got your back.".into()), - if VimModeSetting::get_global(cx).0 { - ui::ToggleState::Selected - } else { - ui::ToggleState::Unselected - }, - { - let fs = ::global(cx); - move |selection, _, cx| { - let enabled = match selection { - ToggleState::Selected => true, - ToggleState::Unselected => false, - ToggleState::Indeterminate => { return; }, - }; + .when_some(base_keymap, |this, base_keymap| { + this.selected_index(base_keymap) + }) + .tab_index(tab_index) + .button_width(rems_from_px(216.)) + .size(ui::ToggleButtonGroupSize::Medium) + .style(ui::ToggleButtonGroupStyle::Outlined), + ); - update_settings_file::( - fs.clone(), - cx, - move |setting, _| *setting = Some(enabled), - ); - } - }, - )) - .child(render_telemetry_section(cx)) + fn write_keymap_base(keymap_base: BaseKeymap, cx: &App) { + let fs = ::global(cx); + + update_settings_file::(fs, cx, move |setting, _| { + *setting = Some(keymap_base); + }); + } +} + +fn render_vim_mode_switch(tab_index: &mut isize, cx: &mut App) -> impl IntoElement { + let toggle_state = if VimModeSetting::get_global(cx).0 { + ui::ToggleState::Selected + } else { + ui::ToggleState::Unselected + }; + SwitchField::new( + "onboarding-vim-mode", + "Vim Mode", + Some( + "Coming from Neovim? Zed's first-class implementation of Vim Mode has got your back." + .into(), + ), + toggle_state, + { + let fs = ::global(cx); + move |&selection, _, cx| { + update_settings_file::(fs.clone(), cx, move |setting, _| { + *setting = match selection { + ToggleState::Selected => Some(true), + ToggleState::Unselected => Some(false), + ToggleState::Indeterminate => None, + } + }); + } + }, + ) + .tab_index({ + *tab_index += 1; + *tab_index - 1 + }) +} + +pub(crate) fn render_basics_page(cx: &mut App) -> impl IntoElement { + let mut tab_index = 0; + v_flex() + .gap_6() + .child(render_theme_section(&mut tab_index, cx)) + .child(render_base_keymap_section(&mut tab_index, cx)) + .child(render_vim_mode_switch(&mut tab_index, cx)) + .child(render_telemetry_section(&mut tab_index, cx)) } diff --git a/crates/onboarding/src/editing_page.rs b/crates/onboarding/src/editing_page.rs index 6dd272745a389d82fadece8e422333a902b6617b..a8f0265b6b5bfe8c4cd5b775fc3a4d96b3876566 100644 --- a/crates/onboarding/src/editing_page.rs +++ b/crates/onboarding/src/editing_page.rs @@ -171,6 +171,7 @@ fn write_format_on_save(format_on_save: bool, cx: &mut App) { } fn render_setting_import_button( + tab_index: isize, label: SharedString, icon_name: IconName, action: &dyn Action, @@ -182,6 +183,7 @@ fn render_setting_import_button( .full_width() .style(ButtonStyle::Outlined) .size(ButtonSize::Large) + .tab_index(tab_index) .child( h_flex() .w_full() @@ -214,7 +216,7 @@ fn render_setting_import_button( ) } -fn render_import_settings_section(cx: &App) -> impl IntoElement { +fn render_import_settings_section(tab_index: &mut isize, cx: &App) -> impl IntoElement { let import_state = SettingsImportState::global(cx); let imports: [(SharedString, IconName, &dyn Action, bool); 2] = [ ( @@ -232,7 +234,8 @@ fn render_import_settings_section(cx: &App) -> impl IntoElement { ]; let [vscode, cursor] = imports.map(|(label, icon_name, action, imported)| { - render_setting_import_button(label, icon_name, action, imported) + *tab_index += 1; + render_setting_import_button(*tab_index - 1, label, icon_name, action, imported) }); v_flex() @@ -248,7 +251,11 @@ fn render_import_settings_section(cx: &App) -> impl IntoElement { .child(h_flex().w_full().gap_4().child(vscode).child(cursor)) } -fn render_font_customization_section(window: &mut Window, cx: &mut App) -> impl IntoElement { +fn render_font_customization_section( + tab_index: &mut isize, + window: &mut Window, + cx: &mut App, +) -> impl IntoElement { let theme_settings = ThemeSettings::get_global(cx); let ui_font_size = theme_settings.ui_font_size(cx); let ui_font_family = theme_settings.ui_font.family.clone(); @@ -294,6 +301,10 @@ fn render_font_customization_section(window: &mut Window, cx: &mut App) -> impl .style(ButtonStyle::Outlined) .size(ButtonSize::Medium) .full_width() + .tab_index({ + *tab_index += 1; + *tab_index - 1 + }) .child( h_flex() .w_full() @@ -325,7 +336,11 @@ fn render_font_customization_section(window: &mut Window, cx: &mut App) -> impl write_ui_font_size(ui_font_size + px(1.), cx); }, ) - .style(ui::NumericStepperStyle::Outlined), + .style(ui::NumericStepperStyle::Outlined) + .tab_index({ + *tab_index += 2; + *tab_index - 2 + }), ), ), ) @@ -350,6 +365,10 @@ fn render_font_customization_section(window: &mut Window, cx: &mut App) -> impl .style(ButtonStyle::Outlined) .size(ButtonSize::Medium) .full_width() + .tab_index({ + *tab_index += 1; + *tab_index - 1 + }) .child( h_flex() .w_full() @@ -381,7 +400,11 @@ fn render_font_customization_section(window: &mut Window, cx: &mut App) -> impl write_buffer_font_size(buffer_font_size + px(1.), cx); }, ) - .style(ui::NumericStepperStyle::Outlined), + .style(ui::NumericStepperStyle::Outlined) + .tab_index({ + *tab_index += 2; + *tab_index - 2 + }), ), ), ) @@ -556,13 +579,17 @@ fn font_picker( .max_height(Some(rems(20.).into())) } -fn render_popular_settings_section(window: &mut Window, cx: &mut App) -> impl IntoElement { +fn render_popular_settings_section( + tab_index: &mut isize, + window: &mut Window, + cx: &mut App, +) -> impl IntoElement { const LIGATURE_TOOLTIP: &'static str = "Ligatures are when a font creates a special character out of combining two characters into one. For example, with ligatures turned on, =/= would become ≠."; v_flex() .gap_5() .child(Label::new("Popular Settings").size(LabelSize::Large).mt_8()) - .child(render_font_customization_section(window, cx)) + .child(render_font_customization_section(tab_index, window, cx)) .child( SwitchField::new( "onboarding-font-ligatures", @@ -577,47 +604,69 @@ fn render_popular_settings_section(window: &mut Window, cx: &mut App) -> impl In write_font_ligatures(toggle_state == &ToggleState::Selected, cx); }, ) + .tab_index({ + *tab_index += 1; + *tab_index - 1 + }) .tooltip(Tooltip::text(LIGATURE_TOOLTIP)), ) - .child(SwitchField::new( - "onboarding-format-on-save", - "Format on Save", - Some("Format code automatically when saving.".into()), - if read_format_on_save(cx) { - ui::ToggleState::Selected - } else { - ui::ToggleState::Unselected - }, - |toggle_state, _, cx| { - write_format_on_save(toggle_state == &ToggleState::Selected, cx); - }, - )) - .child(SwitchField::new( - "onboarding-enable-inlay-hints", - "Inlay Hints", - Some("See parameter names for function and method calls inline.".into()), - if read_inlay_hints(cx) { - ui::ToggleState::Selected - } else { - ui::ToggleState::Unselected - }, - |toggle_state, _, cx| { - write_inlay_hints(toggle_state == &ToggleState::Selected, cx); - }, - )) - .child(SwitchField::new( - "onboarding-git-blame-switch", - "Git Blame", - Some("See who committed each line on a given file.".into()), - if read_git_blame(cx) { - ui::ToggleState::Selected - } else { - ui::ToggleState::Unselected - }, - |toggle_state, _, cx| { - set_git_blame(toggle_state == &ToggleState::Selected, cx); - }, - )) + .child( + SwitchField::new( + "onboarding-format-on-save", + "Format on Save", + Some("Format code automatically when saving.".into()), + if read_format_on_save(cx) { + ui::ToggleState::Selected + } else { + ui::ToggleState::Unselected + }, + |toggle_state, _, cx| { + write_format_on_save(toggle_state == &ToggleState::Selected, cx); + }, + ) + .tab_index({ + *tab_index += 1; + *tab_index - 1 + }), + ) + .child( + SwitchField::new( + "onboarding-enable-inlay-hints", + "Inlay Hints", + Some("See parameter names for function and method calls inline.".into()), + if read_inlay_hints(cx) { + ui::ToggleState::Selected + } else { + ui::ToggleState::Unselected + }, + |toggle_state, _, cx| { + write_inlay_hints(toggle_state == &ToggleState::Selected, cx); + }, + ) + .tab_index({ + *tab_index += 1; + *tab_index - 1 + }), + ) + .child( + SwitchField::new( + "onboarding-git-blame-switch", + "Git Blame", + Some("See who committed each line on a given file.".into()), + if read_git_blame(cx) { + ui::ToggleState::Selected + } else { + ui::ToggleState::Unselected + }, + |toggle_state, _, cx| { + set_git_blame(toggle_state == &ToggleState::Selected, cx); + }, + ) + .tab_index({ + *tab_index += 1; + *tab_index - 1 + }), + ) .child( h_flex() .items_start() @@ -648,6 +697,7 @@ fn render_popular_settings_section(window: &mut Window, cx: &mut App) -> impl In ShowMinimap::Always => 1, ShowMinimap::Never => 2, }) + .tab_index(tab_index) .style(ToggleButtonGroupStyle::Outlined) .button_width(ui::rems_from_px(64.)), ), @@ -655,8 +705,9 @@ fn render_popular_settings_section(window: &mut Window, cx: &mut App) -> impl In } pub(crate) fn render_editing_page(window: &mut Window, cx: &mut App) -> impl IntoElement { + let mut tab_index = 0; v_flex() .gap_4() - .child(render_import_settings_section(cx)) - .child(render_popular_settings_section(window, cx)) + .child(render_import_settings_section(&mut tab_index, cx)) + .child(render_popular_settings_section(&mut tab_index, window, cx)) } diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index 42e75ac2f8829d25c0f418716fb1c0d7afeee047..c4d2b6847ce81d8158aecfec81d841e3258b24b3 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -75,6 +75,8 @@ actions!( ActivateEditingPage, /// Activates the AI Setup page. ActivateAISetupPage, + /// Finish the onboarding process. + Finish, ] ); @@ -261,40 +263,6 @@ impl Onboarding { cx.emit(ItemEvent::UpdateTab); } - fn go_to_welcome_page(&self, cx: &mut App) { - with_active_or_new_workspace(cx, |workspace, window, cx| { - let Some((onboarding_id, onboarding_idx)) = workspace - .active_pane() - .read(cx) - .items() - .enumerate() - .find_map(|(idx, item)| { - let _ = item.downcast::()?; - Some((item.item_id(), idx)) - }) - else { - return; - }; - - workspace.active_pane().update(cx, |pane, cx| { - // Get the index here to get around the borrow checker - let idx = pane.items().enumerate().find_map(|(idx, item)| { - let _ = item.downcast::()?; - Some(idx) - }); - - if let Some(idx) = idx { - pane.activate_item(idx, true, true, window, cx); - } else { - let item = Box::new(WelcomePage::new(window, cx)); - pane.add_item(item, true, true, Some(onboarding_idx), window, cx); - } - - pane.remove_item(onboarding_id, false, false, window, cx); - }); - }); - } - fn render_nav_buttons( &mut self, window: &mut Window, @@ -401,6 +369,13 @@ impl Onboarding { .children(self.render_nav_buttons(window, cx)), ) .map(|this| { + let keybinding = KeyBinding::for_action_in( + &Finish, + &self.focus_handle, + window, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))); if ai_setup_page { this.child( ButtonLike::new("start_building") @@ -412,23 +387,37 @@ impl Onboarding { .w_full() .justify_between() .child(Label::new("Start Building")) - .child( - Icon::new(IconName::Check) - .size(IconSize::Small), - ), + .child(keybinding.map_or_else( + || { + Icon::new(IconName::Check) + .size(IconSize::Small) + .into_any_element() + }, + IntoElement::into_any_element, + )), ) - .on_click(cx.listener(|this, _, _, cx| { - this.go_to_welcome_page(cx); - })), + .on_click(|_, window, cx| { + window.dispatch_action(Finish.boxed_clone(), cx); + }), ) } else { this.child( ButtonLike::new("skip_all") .size(ButtonSize::Medium) - .child(Label::new("Skip All").ml_1()) - .on_click(cx.listener(|this, _, _, cx| { - this.go_to_welcome_page(cx); - })), + .child( + h_flex() + .ml_1() + .w_full() + .justify_between() + .child(Label::new("Skip All")) + .child(keybinding.map_or_else( + || gpui::Empty.into_any_element(), + IntoElement::into_any_element, + )), + ) + .on_click(|_, window, cx| { + window.dispatch_action(Finish.boxed_clone(), cx); + }), ) } }), @@ -464,17 +453,23 @@ impl Onboarding { fn render_page(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { match self.selected_page { - SelectedPage::Basics => { - crate::basics_page::render_basics_page(window, cx).into_any_element() - } + SelectedPage::Basics => crate::basics_page::render_basics_page(cx).into_any_element(), SelectedPage::Editing => { crate::editing_page::render_editing_page(window, cx).into_any_element() } - SelectedPage::AiSetup => { - crate::ai_setup_page::render_ai_setup_page(&self, window, cx).into_any_element() - } + SelectedPage::AiSetup => crate::ai_setup_page::render_ai_setup_page( + self.workspace.clone(), + self.user_store.clone(), + window, + cx, + ) + .into_any_element(), } } + + fn on_finish(_: &Finish, _: &mut Window, cx: &mut App) { + go_to_welcome_page(cx); + } } impl Render for Onboarding { @@ -484,11 +479,13 @@ impl Render for Onboarding { .key_context({ let mut ctx = KeyContext::new_with_defaults(); ctx.add("Onboarding"); + ctx.add("menu"); ctx }) .track_focus(&self.focus_handle) .size_full() .bg(cx.theme().colors().editor_background) + .on_action(Self::on_finish) .on_action(cx.listener(|this, _: &ActivateBasicsPage, _, cx| { this.set_page(SelectedPage::Basics, cx); })) @@ -498,6 +495,14 @@ impl Render for Onboarding { .on_action(cx.listener(|this, _: &ActivateAISetupPage, _, cx| { this.set_page(SelectedPage::AiSetup, cx); })) + .on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| { + window.focus_next(); + cx.notify(); + })) + .on_action(cx.listener(|_, _: &menu::SelectPrevious, window, cx| { + window.focus_prev(); + cx.notify(); + })) .child( h_flex() .max_w(rems_from_px(1100.)) @@ -561,6 +566,40 @@ impl Item for Onboarding { } } +fn go_to_welcome_page(cx: &mut App) { + with_active_or_new_workspace(cx, |workspace, window, cx| { + let Some((onboarding_id, onboarding_idx)) = workspace + .active_pane() + .read(cx) + .items() + .enumerate() + .find_map(|(idx, item)| { + let _ = item.downcast::()?; + Some((item.item_id(), idx)) + }) + else { + return; + }; + + workspace.active_pane().update(cx, |pane, cx| { + // Get the index here to get around the borrow checker + let idx = pane.items().enumerate().find_map(|(idx, item)| { + let _ = item.downcast::()?; + Some(idx) + }); + + if let Some(idx) = idx { + pane.activate_item(idx, true, true, window, cx); + } else { + let item = Box::new(WelcomePage::new(window, cx)); + pane.add_item(item, true, true, Some(onboarding_idx), window, cx); + } + + pane.remove_item(onboarding_id, false, false, window, cx); + }); + }); +} + pub async fn handle_import_vscode_settings( workspace: WeakEntity, source: VsCodeSettingsSource, diff --git a/crates/ui/src/components/button/toggle_button.rs b/crates/ui/src/components/button/toggle_button.rs index d4d47da9b63e2ac096ec61f36a2ccb959df85169..6fbf8346670d7b156781a6a2a323b5d916ccb64b 100644 --- a/crates/ui/src/components/button/toggle_button.rs +++ b/crates/ui/src/components/button/toggle_button.rs @@ -412,6 +412,7 @@ where size: ToggleButtonGroupSize, button_width: Rems, selected_index: usize, + tab_index: Option, } impl ToggleButtonGroup { @@ -423,6 +424,7 @@ impl ToggleButtonGroup { size: ToggleButtonGroupSize::Default, button_width: rems_from_px(100.), selected_index: 0, + tab_index: None, } } } @@ -436,6 +438,7 @@ impl ToggleButtonGroup { size: ToggleButtonGroupSize::Default, button_width: rems_from_px(100.), selected_index: 0, + tab_index: None, } } } @@ -460,6 +463,15 @@ impl ToggleButtonGroup Self { + self.tab_index = Some(*tab_index); + *tab_index += (COLS * ROWS) as isize; + self + } } impl RenderOnce @@ -479,6 +491,9 @@ impl RenderOnce let entry_index = row_index * COLS + col_index; ButtonLike::new((self.group_name, entry_index)) + .when_some(self.tab_index, |this, tab_index| { + this.tab_index(tab_index + entry_index as isize) + }) .when(entry_index == self.selected_index || selected, |this| { this.toggle_state(true) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) diff --git a/crates/ui/src/components/numeric_stepper.rs b/crates/ui/src/components/numeric_stepper.rs index 0ec7111a0287f99df76349388aca049cc886cce0..2ddb86d9a0d595edffc76319b415f9f68f9c6b9c 100644 --- a/crates/ui/src/components/numeric_stepper.rs +++ b/crates/ui/src/components/numeric_stepper.rs @@ -19,6 +19,7 @@ pub struct NumericStepper { /// Whether to reserve space for the reset button. reserve_space_for_reset: bool, on_reset: Option>, + tab_index: Option, } impl NumericStepper { @@ -36,6 +37,7 @@ impl NumericStepper { on_increment: Box::new(on_increment), reserve_space_for_reset: false, on_reset: None, + tab_index: None, } } @@ -56,6 +58,11 @@ impl NumericStepper { self.on_reset = Some(Box::new(on_reset)); self } + + pub fn tab_index(mut self, tab_index: isize) -> Self { + self.tab_index = Some(tab_index); + self + } } impl RenderOnce for NumericStepper { @@ -64,6 +71,7 @@ impl RenderOnce for NumericStepper { let icon_size = IconSize::Small; let is_outlined = matches!(self.style, NumericStepperStyle::Outlined); + let mut tab_index = self.tab_index; h_flex() .id(self.id) @@ -74,6 +82,10 @@ impl RenderOnce for NumericStepper { IconButton::new("reset", IconName::RotateCcw) .shape(shape) .icon_size(icon_size) + .when_some(tab_index.as_mut(), |this, tab_index| { + *tab_index += 1; + this.tab_index(*tab_index - 1) + }) .on_click(on_reset), ) } else if self.reserve_space_for_reset { @@ -113,6 +125,12 @@ impl RenderOnce for NumericStepper { .border_r_1() .border_color(cx.theme().colors().border_variant) .child(Icon::new(IconName::Dash).size(IconSize::Small)) + .when_some(tab_index.as_mut(), |this, tab_index| { + *tab_index += 1; + this.tab_index(*tab_index - 1).focus(|style| { + style.bg(cx.theme().colors().element_hover) + }) + }) .on_click(self.on_decrement), ) } else { @@ -120,6 +138,10 @@ impl RenderOnce for NumericStepper { IconButton::new("decrement", IconName::Dash) .shape(shape) .icon_size(icon_size) + .when_some(tab_index.as_mut(), |this, tab_index| { + *tab_index += 1; + this.tab_index(*tab_index - 1) + }) .on_click(self.on_decrement), ) } @@ -137,6 +159,12 @@ impl RenderOnce for NumericStepper { .border_l_1() .border_color(cx.theme().colors().border_variant) .child(Icon::new(IconName::Plus).size(IconSize::Small)) + .when_some(tab_index.as_mut(), |this, tab_index| { + *tab_index += 1; + this.tab_index(*tab_index - 1).focus(|style| { + style.bg(cx.theme().colors().element_hover) + }) + }) .on_click(self.on_increment), ) } else { @@ -144,6 +172,10 @@ impl RenderOnce for NumericStepper { IconButton::new("increment", IconName::Dash) .shape(shape) .icon_size(icon_size) + .when_some(tab_index.as_mut(), |this, tab_index| { + *tab_index += 1; + this.tab_index(*tab_index - 1) + }) .on_click(self.on_increment), ) } diff --git a/crates/ui/src/components/toggle.rs b/crates/ui/src/components/toggle.rs index a3a3f23889f7a6221bd5c31195e0cc8d2907279e..53df4767b0d7ff5dc81b2c39c1baef9daa1d55ec 100644 --- a/crates/ui/src/components/toggle.rs +++ b/crates/ui/src/components/toggle.rs @@ -424,6 +424,7 @@ pub struct Switch { label: Option, key_binding: Option, color: SwitchColor, + tab_index: Option, } impl Switch { @@ -437,6 +438,7 @@ impl Switch { label: None, key_binding: None, color: SwitchColor::default(), + tab_index: None, } } @@ -472,6 +474,11 @@ impl Switch { self.key_binding = key_binding.into(); self } + + pub fn tab_index(mut self, tab_index: impl Into) -> Self { + self.tab_index = Some(tab_index.into()); + self + } } impl RenderOnce for Switch { @@ -501,6 +508,20 @@ impl RenderOnce for Switch { .w(DynamicSpacing::Base32.rems(cx)) .h(DynamicSpacing::Base20.rems(cx)) .group(group_id.clone()) + .border_1() + .p(px(1.0)) + .border_color(cx.theme().colors().border_transparent) + .rounded_full() + .id((self.id.clone(), "switch")) + .when_some( + self.tab_index.filter(|_| !self.disabled), + |this, tab_index| { + this.tab_index(tab_index).focus(|mut style| { + style.border_color = Some(cx.theme().colors().border_focused); + style + }) + }, + ) .child( h_flex() .when(is_on, |on| on.justify_end()) @@ -572,6 +593,7 @@ pub struct SwitchField { disabled: bool, color: SwitchColor, tooltip: Option AnyView>>, + tab_index: Option, } impl SwitchField { @@ -591,6 +613,7 @@ impl SwitchField { disabled: false, color: SwitchColor::Accent, tooltip: None, + tab_index: None, } } @@ -615,14 +638,33 @@ impl SwitchField { self.tooltip = Some(Rc::new(tooltip)); self } + + pub fn tab_index(mut self, tab_index: isize) -> Self { + self.tab_index = Some(tab_index); + self + } } impl RenderOnce for SwitchField { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { - let tooltip = self.tooltip; + let tooltip = self.tooltip.map(|tooltip_fn| { + h_flex() + .gap_0p5() + .child(Label::new(self.label.clone())) + .child( + IconButton::new("tooltip_button", IconName::Info) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .shape(crate::IconButtonShape::Square) + .tooltip({ + let tooltip = tooltip_fn.clone(); + move |window, cx| tooltip(window, cx) + }), + ) + }); h_flex() - .id(SharedString::from(format!("{}-container", self.id))) + .id((self.id.clone(), "container")) .when(!self.disabled, |this| { this.hover(|this| this.cursor_pointer()) }) @@ -630,25 +672,11 @@ impl RenderOnce for SwitchField { .gap_4() .justify_between() .flex_wrap() - .child(match (&self.description, &tooltip) { + .child(match (&self.description, tooltip) { (Some(description), Some(tooltip)) => v_flex() .gap_0p5() .max_w_5_6() - .child( - h_flex() - .gap_0p5() - .child(Label::new(self.label.clone())) - .child( - IconButton::new("tooltip_button", IconName::Info) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .shape(crate::IconButtonShape::Square) - .tooltip({ - let tooltip = tooltip.clone(); - move |window, cx| tooltip(window, cx) - }), - ), - ) + .child(tooltip) .child(Label::new(description.clone()).color(Color::Muted)) .into_any_element(), (Some(description), None) => v_flex() @@ -657,35 +685,23 @@ impl RenderOnce for SwitchField { .child(Label::new(self.label.clone())) .child(Label::new(description.clone()).color(Color::Muted)) .into_any_element(), - (None, Some(tooltip)) => h_flex() - .gap_0p5() - .child(Label::new(self.label.clone())) - .child( - IconButton::new("tooltip_button", IconName::Info) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .shape(crate::IconButtonShape::Square) - .tooltip({ - let tooltip = tooltip.clone(); - move |window, cx| tooltip(window, cx) - }), - ) - .into_any_element(), + (None, Some(tooltip)) => tooltip.into_any_element(), (None, None) => Label::new(self.label.clone()).into_any_element(), }) .child( - Switch::new( - SharedString::from(format!("{}-switch", self.id)), - self.toggle_state, - ) - .color(self.color) - .disabled(self.disabled) - .on_click({ - let on_click = self.on_click.clone(); - move |state, window, cx| { - (on_click)(state, window, cx); - } - }), + Switch::new((self.id.clone(), "switch"), self.toggle_state) + .color(self.color) + .disabled(self.disabled) + .when_some( + self.tab_index.filter(|_| !self.disabled), + |this, tab_index| this.tab_index(tab_index), + ) + .on_click({ + let on_click = self.on_click.clone(); + move |state, window, cx| { + (on_click)(state, window, cx); + } + }), ) .when(!self.disabled, |this| { this.on_click({