From f6407fdff1710ee67a816fd54ccb0bb730ab3bf1 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Mon, 30 Jun 2025 21:08:51 -0400 Subject: [PATCH] Start on keyboard navigation story --- Cargo.lock | 1 + crates/onboarding_ui/Cargo.toml | 1 + crates/onboarding_ui/src/onboarding_ui.rs | 478 ++++++++++++++++++++-- 3 files changed, 443 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e91833f9965ea23f0178aed1d9d166457407e4d3..eab5741d25c0ac25120f24821a15188aaafbc649 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10826,6 +10826,7 @@ dependencies = [ "editor", "feature_flags", "gpui", + "menu", "project", "settings", "settings_ui", diff --git a/crates/onboarding_ui/Cargo.toml b/crates/onboarding_ui/Cargo.toml index b1591e916708804df3863ba1bb77f824d48db2e6..24766430338fbf28b415cbff8a47326547908c4f 100644 --- a/crates/onboarding_ui/Cargo.toml +++ b/crates/onboarding_ui/Cargo.toml @@ -22,6 +22,7 @@ component.workspace = true db.workspace = true feature_flags.workspace = true gpui.workspace = true +menu.workspace = true project.workspace = true settings.workspace = true settings_ui.workspace = true diff --git a/crates/onboarding_ui/src/onboarding_ui.rs b/crates/onboarding_ui/src/onboarding_ui.rs index 7b12df04448c61b88651e285b0521029b7281180..accd01eb7326afe5be004013212763e866d25c0c 100644 --- a/crates/onboarding_ui/src/onboarding_ui.rs +++ b/crates/onboarding_ui/src/onboarding_ui.rs @@ -7,6 +7,7 @@ use feature_flags::FeatureFlagAppExt as _; use gpui::{ Entity, EventEmitter, FocusHandle, Focusable, KeyBinding, Task, WeakEntity, actions, prelude::*, }; +use menu; use persistence::ONBOARDING_DB; use project::Project; @@ -51,7 +52,7 @@ pub fn init(cx: &mut App) { } fn feature_gate_onboarding_ui_actions(cx: &mut App) { - const ONBOARDING_ACTION_NAMESPACE: &str = "onboarding"; + const ONBOARDING_ACTION_NAMESPACE: &str = "onboarding_ui"; CommandPaletteFilter::update_global(cx, |filter, _cx| { filter.hide_namespace(ONBOARDING_ACTION_NAMESPACE); @@ -112,12 +113,19 @@ pub enum NavigationFocusItem { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PageFocusItem(pub usize); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusArea { + Navigation, + PageContent, +} + pub struct OnboardingUI { focus_handle: FocusHandle, current_page: OnboardingPage, nav_focus: NavigationFocusItem, page_focus: [PageFocusItem; 4], completed_pages: [bool; 4], + focus_area: FocusArea, // Workspace reference for Item trait workspace: WeakEntity, @@ -147,6 +155,12 @@ impl Render for OnboardingUI { div() .bg(cx.theme().colors().editor_background) .size_full() + .key_context("OnboardingUI") + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_previous)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::toggle_focus)) .flex() .items_center() .justify_center() @@ -163,14 +177,27 @@ impl Render for OnboardingUI { .on_action(cx.listener(Self::handle_next_page)) .on_action(cx.listener(Self::handle_previous_page)) .w(px(904.)) - .h(px(500.)) - .gap(px(48.)) - .child(self.render_navigation(window, cx)) + .gap(px(24.)) .child( - v_flex() - .h_full() - .flex_1() - .child(div().flex_1().child(self.render_active_page(window, cx))), + h_flex() + .h(px(500.)) + .w_full() + .gap(px(48.)) + .child(self.render_navigation(window, cx)) + .child( + v_flex() + .h_full() + .flex_1() + .when(self.focus_area == FocusArea::PageContent, |this| { + this.border_2() + .border_color(cx.theme().colors().border_focused) + }) + .rounded_lg() + .p_4() + .child( + div().flex_1().child(self.render_active_page(window, cx)), + ), + ), ), ) } @@ -181,8 +208,10 @@ impl OnboardingUI { Self { focus_handle: cx.focus_handle(), current_page: OnboardingPage::Basics, - current_focus: OnboardingFocus::default(), + nav_focus: NavigationFocusItem::Basics, + page_focus: [PageFocusItem(0); 4], completed_pages: [false; 4], + focus_area: FocusArea::Navigation, workspace: workspace.weak_handle(), workspace_id: workspace.database_id(), client, @@ -229,21 +258,188 @@ impl OnboardingUI { } } - fn toggle_focus(&mut self, _window: &mut gpui::Window, cx: &mut Context) { - self.current_focus = match self.current_focus { - OnboardingFocus::Navigation => OnboardingFocus::Page, - OnboardingFocus::Page => OnboardingFocus::Navigation, - }; - cx.notify(); - } - fn reset(&mut self, _window: &mut gpui::Window, cx: &mut Context) { self.current_page = OnboardingPage::Basics; - self.current_focus = OnboardingFocus::Page; + self.focus_area = FocusArea::Navigation; self.completed_pages = [false; 4]; cx.notify(); } + fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context) { + match self.focus_area { + FocusArea::Navigation => { + self.nav_focus = match self.nav_focus { + NavigationFocusItem::SignIn => NavigationFocusItem::Basics, + NavigationFocusItem::Basics => NavigationFocusItem::Editing, + NavigationFocusItem::Editing => NavigationFocusItem::AiSetup, + NavigationFocusItem::AiSetup => NavigationFocusItem::Welcome, + NavigationFocusItem::Welcome => NavigationFocusItem::Next, + NavigationFocusItem::Next => NavigationFocusItem::SignIn, + }; + } + FocusArea::PageContent => { + let page_index = match self.current_page { + OnboardingPage::Basics => 0, + OnboardingPage::Editing => 1, + OnboardingPage::AiSetup => 2, + OnboardingPage::Welcome => 3, + }; + // Bounds checking for page items + let max_items = match self.current_page { + OnboardingPage::Basics => 3, // 3 buttons + OnboardingPage::Editing => 3, // 3 buttons + OnboardingPage::AiSetup => 2, // Will have 2 items + OnboardingPage::Welcome => 1, // Will have 1 item + }; + + if self.page_focus[page_index].0 < max_items - 1 { + self.page_focus[page_index].0 += 1; + } else { + // Wrap to start + self.page_focus[page_index].0 = 0; + } + } + } + cx.notify(); + } + + fn select_previous( + &mut self, + _: &menu::SelectPrevious, + _window: &mut Window, + cx: &mut Context, + ) { + match self.focus_area { + FocusArea::Navigation => { + self.nav_focus = match self.nav_focus { + NavigationFocusItem::SignIn => NavigationFocusItem::Next, + NavigationFocusItem::Basics => NavigationFocusItem::SignIn, + NavigationFocusItem::Editing => NavigationFocusItem::Basics, + NavigationFocusItem::AiSetup => NavigationFocusItem::Editing, + NavigationFocusItem::Welcome => NavigationFocusItem::AiSetup, + NavigationFocusItem::Next => NavigationFocusItem::Welcome, + }; + } + FocusArea::PageContent => { + let page_index = match self.current_page { + OnboardingPage::Basics => 0, + OnboardingPage::Editing => 1, + OnboardingPage::AiSetup => 2, + OnboardingPage::Welcome => 3, + }; + // Bounds checking for page items + let max_items = match self.current_page { + OnboardingPage::Basics => 3, // 3 buttons + OnboardingPage::Editing => 3, // 3 buttons + OnboardingPage::AiSetup => 2, // Will have 2 items + OnboardingPage::Welcome => 1, // Will have 1 item + }; + + if self.page_focus[page_index].0 > 0 { + self.page_focus[page_index].0 -= 1; + } else { + // Wrap to end + self.page_focus[page_index].0 = max_items - 1; + } + } + } + cx.notify(); + } + + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + match self.focus_area { + FocusArea::Navigation => { + match self.nav_focus { + NavigationFocusItem::SignIn => { + // Handle sign in action + // TODO: Implement sign in action + } + NavigationFocusItem::Basics => { + self.jump_to_page(OnboardingPage::Basics, window, cx) + } + NavigationFocusItem::Editing => { + self.jump_to_page(OnboardingPage::Editing, window, cx) + } + NavigationFocusItem::AiSetup => { + self.jump_to_page(OnboardingPage::AiSetup, window, cx) + } + NavigationFocusItem::Welcome => { + self.jump_to_page(OnboardingPage::Welcome, window, cx) + } + NavigationFocusItem::Next => { + // Handle next button action + self.next_page(window, cx); + } + } + // After confirming navigation item (except Next), switch focus to page content + if self.nav_focus != NavigationFocusItem::Next { + self.focus_area = FocusArea::PageContent; + } + } + FocusArea::PageContent => { + // Handle page-specific item selection + let page_index = match self.current_page { + OnboardingPage::Basics => 0, + OnboardingPage::Editing => 1, + OnboardingPage::AiSetup => 2, + OnboardingPage::Welcome => 3, + }; + let item_index = self.page_focus[page_index].0; + + // Trigger the action for the focused item + match self.current_page { + OnboardingPage::Basics => { + match item_index { + 0 => { + // Open file action + cx.notify(); + } + 1 => { + // Create project action + cx.notify(); + } + 2 => { + // Explore UI action + cx.notify(); + } + _ => {} + } + } + OnboardingPage::Editing => { + // Similar handling for editing page + cx.notify(); + } + _ => { + cx.notify(); + } + } + } + } + cx.notify(); + } + + fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context) { + match self.focus_area { + FocusArea::PageContent => { + // Switch focus back to navigation + self.focus_area = FocusArea::Navigation; + } + FocusArea::Navigation => { + // If already in navigation, maybe close the onboarding? + // For now, just stay in navigation + } + } + cx.notify(); + } + + fn toggle_focus(&mut self, _: &ToggleFocus, _window: &mut Window, cx: &mut Context) { + self.focus_area = match self.focus_area { + FocusArea::Navigation => FocusArea::PageContent, + FocusArea::PageContent => FocusArea::Navigation, + }; + cx.notify(); + } + fn mark_page_completed( &mut self, page: OnboardingPage, @@ -340,6 +536,11 @@ impl OnboardingUI { Button::new("sign_in", "Sign in") .color(Color::Muted) .label_size(LabelSize::Small) + .when( + self.focus_area == FocusArea::Navigation + && self.nav_focus == NavigationFocusItem::SignIn, + |this| this.color(Color::Accent), + ) .size(ButtonSize::Compact) .on_click(cx.listener(move |_, _, window, cx| { let client = client.clone(); @@ -401,15 +602,31 @@ impl OnboardingUI { let shortcut = shortcut.into(); let id = ElementId::Name(label.clone()); + let is_focused = match page { + OnboardingPage::Basics => self.nav_focus == NavigationFocusItem::Basics, + OnboardingPage::Editing => self.nav_focus == NavigationFocusItem::Editing, + OnboardingPage::AiSetup => self.nav_focus == NavigationFocusItem::AiSetup, + OnboardingPage::Welcome => self.nav_focus == NavigationFocusItem::Welcome, + }; + + let area_focused = self.focus_area == FocusArea::Navigation; + h_flex() .id(id) .h(rems(1.5)) .w_full() + .when(is_focused, |this| { + this.bg(if area_focused { + cx.theme().colors().border_focused.opacity(0.16) + } else { + cx.theme().colors().border.opacity(0.24) + }) + }) .child( div() .w(px(3.)) .h_full() - .when(selected, |this| this.bg(cx.theme().status().info)), + .when(selected, |this| this.bg(cx.theme().colors().border_focused)), ) .child( h_flex() @@ -417,7 +634,7 @@ impl OnboardingUI { .flex_1() .justify_between() .items_center() - .child(Label::new(label)) + .child(Label::new(label).when(is_focused, |this| this.color(Color::Default))) .child(Label::new(format!("⌘{}", shortcut.clone())).color(Color::Muted)), ) .on_click(cx.listener(move |this, _, window, cx| { @@ -440,6 +657,11 @@ impl OnboardingUI { }, ) .style(ButtonStyle::Filled) + .when( + self.focus_area == FocusArea::Navigation + && self.nav_focus == NavigationFocusItem::Next, + |this| this.color(Color::Accent), + ) .key_binding(ui::KeyBinding::for_action_in( &NextPage, &self.focus_handle, @@ -452,48 +674,230 @@ impl OnboardingUI { ) } - fn render_active_page( - &mut self, - _window: &mut gpui::Window, - _cx: &mut Context, - ) -> AnyElement { + fn render_active_page(&mut self, _window: &mut Window, cx: &mut Context) -> AnyElement { match self.current_page { - OnboardingPage::Basics => self.render_basics_page(), - OnboardingPage::Editing => self.render_editing_page(), - OnboardingPage::AiSetup => self.render_ai_setup_page(), - OnboardingPage::Welcome => self.render_welcome_page(), + OnboardingPage::Basics => self.render_basics_page(cx), + OnboardingPage::Editing => self.render_editing_page(cx), + OnboardingPage::AiSetup => self.render_ai_setup_page(cx), + OnboardingPage::Welcome => self.render_welcome_page(cx), } } - fn render_basics_page(&self) -> AnyElement { + fn render_basics_page(&mut self, cx: &mut Context) -> AnyElement { + let page_index = 0; // Basics page index + let focused_item = self.page_focus[page_index].0; + let is_page_focused = self.focus_area == FocusArea::PageContent; + v_flex() .h_full() .w_full() - .child("Basics Page") + .items_center() + .justify_center() + .gap_4() + .child( + Label::new("Welcome to Zed!") + .size(LabelSize::Large) + .color(Color::Default), + ) + .child( + Label::new("Let's get you started with the basics") + .size(LabelSize::Default) + .color(Color::Muted), + ) + .child( + v_flex() + .gap_2() + .mt_4() + .child( + Button::new("open_file", "Open a File") + .style(ButtonStyle::Filled) + .when(is_page_focused && focused_item == 0, |this| { + this.color(Color::Accent) + }) + .on_click(cx.listener(|_, _, _, cx| { + // TODO: Trigger open file action + cx.notify(); + })), + ) + .child( + Button::new("create_project", "Create a Project") + .style(ButtonStyle::Filled) + .when(is_page_focused && focused_item == 1, |this| { + this.color(Color::Accent) + }) + .on_click(cx.listener(|_, _, _, cx| { + // TODO: Trigger create project action + cx.notify(); + })), + ) + .child( + Button::new("explore_ui", "Explore the UI") + .style(ButtonStyle::Filled) + .when(is_page_focused && focused_item == 2, |this| { + this.color(Color::Accent) + }) + .on_click(cx.listener(|_, _, _, cx| { + // TODO: Trigger explore UI action + cx.notify(); + })), + ), + ) .into_any_element() } - fn render_editing_page(&self) -> AnyElement { + fn render_editing_page(&mut self, cx: &mut Context) -> AnyElement { + let page_index = 1; // Editing page index + let focused_item = self.page_focus[page_index].0; + let is_page_focused = self.focus_area == FocusArea::PageContent; + v_flex() .h_full() .w_full() - .child("Editing Page") + .items_center() + .justify_center() + .gap_4() + .child( + Label::new("Editing Features") + .size(LabelSize::Large) + .color(Color::Default), + ) + .child( + v_flex() + .gap_2() + .mt_4() + .child( + Button::new("try_multi_cursor", "Try Multi-cursor Editing") + .style(ButtonStyle::Filled) + .when(is_page_focused && focused_item == 0, |this| { + this.color(Color::Accent) + }) + .on_click(cx.listener(|_, _, _, cx| { + cx.notify(); + })), + ) + .child( + Button::new("learn_shortcuts", "Learn Keyboard Shortcuts") + .style(ButtonStyle::Filled) + .when(is_page_focused && focused_item == 1, |this| { + this.color(Color::Accent) + }) + .on_click(cx.listener(|_, _, _, cx| { + cx.notify(); + })), + ) + .child( + Button::new("explore_actions", "Explore Command Palette") + .style(ButtonStyle::Filled) + .when(is_page_focused && focused_item == 2, |this| { + this.color(Color::Accent) + }) + .on_click(cx.listener(|_, _, _, cx| { + cx.notify(); + })), + ), + ) .into_any_element() } - fn render_ai_setup_page(&self) -> AnyElement { + fn render_ai_setup_page(&mut self, cx: &mut Context) -> AnyElement { + let page_index = 2; // AI Setup page index + let focused_item = self.page_focus[page_index].0; + let is_page_focused = self.focus_area == FocusArea::PageContent; + v_flex() .h_full() .w_full() - .child("AI Setup Page") + .items_center() + .justify_center() + .gap_4() + .child( + Label::new("AI Assistant Setup") + .size(LabelSize::Large) + .color(Color::Default), + ) + .child( + v_flex() + .gap_2() + .mt_4() + .child( + Button::new("configure_ai", "Configure AI Provider") + .style(ButtonStyle::Filled) + .when(is_page_focused && focused_item == 0, |this| { + this.color(Color::Accent) + }) + .on_click(cx.listener(|_, _, _, cx| { + cx.notify(); + })), + ) + .child( + Button::new("try_ai_chat", "Try AI Chat") + .style(ButtonStyle::Filled) + .when(is_page_focused && focused_item == 1, |this| { + this.color(Color::Accent) + }) + .on_click(cx.listener(|_, _, _, cx| { + cx.notify(); + })), + ), + ) .into_any_element() } - fn render_welcome_page(&self) -> AnyElement { + fn render_welcome_page(&mut self, cx: &mut Context) -> AnyElement { + let page_index = 3; // Welcome page index + let focused_item = self.page_focus[page_index].0; + let is_page_focused = self.focus_area == FocusArea::PageContent; + v_flex() .h_full() .w_full() - .child("Welcome Page") + .items_center() + .justify_center() + .gap_4() + .child( + Label::new("Welcome to Zed!") + .size(LabelSize::Large) + .color(Color::Default), + ) + .child( + Label::new("You're all set up and ready to code") + .size(LabelSize::Default) + .color(Color::Muted), + ) + .child( + Button::new("finish_onboarding", "Start Coding!") + .style(ButtonStyle::Filled) + .size(ButtonSize::Large) + .when(is_page_focused && focused_item == 0, |this| { + this.color(Color::Accent) + }) + .on_click(cx.listener(|_, _, _, cx| { + // TODO: Close onboarding and start coding + cx.notify(); + })), + ) + .into_any_element() + } + + fn render_keyboard_help(&self, cx: &mut Context) -> AnyElement { + let help_text = match self.focus_area { + FocusArea::Navigation => { + "Use ↑/↓ to navigate • Enter to select page • Tab to switch to page content" + } + FocusArea::PageContent => { + "Use ↑/↓ to navigate • Enter to activate • Esc to return to navigation" + } + }; + + h_flex() + .w_full() + .justify_center() + .p_2() + .child( + Label::new(help_text) + .size(LabelSize::Small) + .color(Color::Muted), + ) .into_any_element() } }