diff --git a/Cargo.lock b/Cargo.lock index baed77a49f..979d349427 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10814,6 +10814,35 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "onboarding_ui" +version = "0.1.0" +dependencies = [ + "anyhow", + "client", + "command_palette_hooks", + "component", + "db", + "editor", + "feature_flags", + "gpui", + "language", + "log", + "menu", + "project", + "serde_json", + "settings", + "settings_ui", + "smallvec", + "theme", + "ui", + "util", + "vim_mode_setting", + "welcome", + "workspace", + "zed_actions", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -19972,6 +20001,7 @@ dependencies = [ "collab_ui", "collections", "command_palette", + "command_palette_hooks", "component", "copilot", "dap", @@ -20022,6 +20052,7 @@ dependencies = [ "nix 0.29.0", "node_runtime", "notifications", + "onboarding_ui", "outline", "outline_panel", "parking_lot", diff --git a/Cargo.toml b/Cargo.toml index 82cbb53397..c9d848675f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,7 @@ members = [ "crates/node_runtime", "crates/notifications", "crates/ollama", + "crates/onboarding_ui", "crates/open_ai", "crates/open_router", "crates/outline", @@ -314,6 +315,7 @@ multi_buffer = { path = "crates/multi_buffer" } node_runtime = { path = "crates/node_runtime" } notifications = { path = "crates/notifications" } ollama = { path = "crates/ollama" } +onboarding_ui = { path = "crates/onboarding_ui" } open_ai = { path = "crates/open_ai" } open_router = { path = "crates/open_router", features = ["schemars"] } outline = { path = "crates/outline" } diff --git a/assets/fonts/plex-sans/ZedPlexSans-Medium.ttf b/assets/fonts/plex-sans/ZedPlexSans-Medium.ttf new file mode 100644 index 0000000000..fb75072d87 Binary files /dev/null and b/assets/fonts/plex-sans/ZedPlexSans-Medium.ttf differ diff --git a/assets/fonts/plex-sans/ZedPlexSans-MediumItalic.ttf b/assets/fonts/plex-sans/ZedPlexSans-MediumItalic.ttf new file mode 100644 index 0000000000..1b059bebd5 Binary files /dev/null and b/assets/fonts/plex-sans/ZedPlexSans-MediumItalic.ttf differ diff --git a/assets/images/atom_logo.svg b/assets/images/atom_logo.svg new file mode 100644 index 0000000000..d23ad5fe9b --- /dev/null +++ b/assets/images/atom_logo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/settings/default.json b/assets/settings/default.json index 985e322cac..0e46115ae3 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -25,7 +25,11 @@ // Features that can be globally enabled or disabled "features": { // Which edit prediction provider to use. - "edit_prediction_provider": "zed" + "edit_prediction_provider": "zed", + // A globally enable or disable AI features. + // + // This setting supersedes all other settings related to AI features. + "ai_assistance": true }, // The name of a font to use for rendering text in the editor "buffer_font_family": "Zed Plex Mono", diff --git a/crates/agent_ui/src/inline_assistant.rs b/crates/agent_ui/src/inline_assistant.rs index c9c173a68b..966e18de54 100644 --- a/crates/agent_ui/src/inline_assistant.rs +++ b/crates/agent_ui/src/inline_assistant.rs @@ -33,6 +33,7 @@ use gpui::{ App, Context, Entity, Focusable, Global, HighlightStyle, Subscription, Task, UpdateGlobal, WeakEntity, Window, point, }; +use language::language_settings; use language::{Buffer, Point, Selection, TransactionId}; use language_model::{ ConfigurationError, ConfiguredModel, LanguageModelRegistry, report_assistant_event, @@ -1768,7 +1769,7 @@ impl CodeActionProvider for AssistantCodeActionProvider { _: &mut Window, cx: &mut App, ) -> Task>> { - if !AgentSettings::get_global(cx).enabled { + if !AgentSettings::get_global(cx).enabled || !language_settings::ai_enabled(cx) { return Task::ready(Ok(Vec::new())); } diff --git a/crates/gpui_macros/src/styles.rs b/crates/gpui_macros/src/styles.rs index 36d46cfb51..f62a275cb6 100644 --- a/crates/gpui_macros/src/styles.rs +++ b/crates/gpui_macros/src/styles.rs @@ -405,6 +405,23 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { self } + /// Sets the box shadow of the element. + /// + /// A hairline shadow is a very thin shadow that is often used + /// to create a subtle depth effect under an element. + #visibility fn shadow_hairline(mut self) -> Self { + use gpui::{BoxShadow, hsla, point, px}; + use std::vec; + + self.style().box_shadow = Some(vec![BoxShadow { + color: hsla(0.0, 0.0, 0.0, 0.16), + offset: point(px(0.), px(1.)), + blur_radius: px(0.), + spread_radius: px(0.), + }]); + self + } + /// Sets the box shadow of the element. /// [Docs](https://tailwindcss.com/docs/box-shadow) #visibility fn shadow_2xs(mut self) -> Self { diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 9b0abb1537..c4e908df79 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -16,8 +16,10 @@ use serde::{ de::{self, IntoDeserializer, MapAccess, SeqAccess, Visitor}, }; +use fs::Fs; use settings::{ ParameterizedJsonSchema, Settings, SettingsLocation, SettingsSources, SettingsStore, + update_settings_file, }; use shellexpand; use std::{borrow::Cow, num::NonZeroU32, path::Path, slice, sync::Arc}; @@ -54,11 +56,18 @@ pub fn all_language_settings<'a>( AllLanguageSettings::get(location, cx) } +/// Returns whether AI assistance is globally enabled or disabled. +pub fn ai_enabled(cx: &App) -> bool { + all_language_settings(None, cx).ai_assistance +} + /// The settings for all languages. #[derive(Debug, Clone)] pub struct AllLanguageSettings { /// The edit prediction settings. pub edit_predictions: EditPredictionSettings, + /// Whether AI assistance is enabled. + pub ai_assistance: bool, pub defaults: LanguageSettings, languages: HashMap, pub(crate) file_types: FxHashMap, GlobSet>, @@ -646,6 +655,8 @@ pub struct CopilotSettingsContent { pub struct FeaturesContent { /// Determines which edit prediction provider to use. pub edit_prediction_provider: Option, + /// Whether AI assistance is enabled. + pub ai_assistance: Option, } /// Controls the soft-wrapping behavior in the editor. @@ -1122,6 +1133,26 @@ impl AllLanguageSettings { pub fn edit_predictions_mode(&self) -> EditPredictionsMode { self.edit_predictions.mode } + + /// Returns whether AI assistance is enabled. + pub fn is_ai_assistance_enabled(&self) -> bool { + self.ai_assistance + } + + /// Sets AI assistance to the specified state and updates the settings file. + pub fn set_ai_assistance(enabled: bool, fs: Arc, cx: &mut App) { + let current_state = Self::get_global(cx).ai_assistance; + + if current_state == enabled { + return; + } + + update_settings_file::(fs, cx, move |file, _| { + file.features + .get_or_insert(Default::default()) + .ai_assistance = Some(enabled); + }); + } } fn merge_with_editorconfig(settings: &mut LanguageSettings, cfg: &EditorconfigProperties) { @@ -1247,6 +1278,12 @@ impl settings::Settings for AllLanguageSettings { .map(|settings| settings.enabled_in_text_threads) .unwrap_or(true); + let mut ai_assistance = default_value + .features + .as_ref() + .and_then(|f| f.ai_assistance) + .unwrap_or(true); + let mut file_types: FxHashMap, GlobSet> = FxHashMap::default(); for (language, patterns) in &default_value.file_types { @@ -1268,6 +1305,14 @@ impl settings::Settings for AllLanguageSettings { edit_prediction_provider = Some(provider); } + if let Some(user_ai_assistance) = user_settings + .features + .as_ref() + .and_then(|f| f.ai_assistance) + { + ai_assistance = user_ai_assistance; + } + if let Some(edit_predictions) = user_settings.edit_predictions.as_ref() { edit_predictions_mode = edit_predictions.mode; enabled_in_text_threads = edit_predictions.enabled_in_text_threads; @@ -1359,6 +1404,7 @@ impl settings::Settings for AllLanguageSettings { copilot: copilot_settings, enabled_in_text_threads, }, + ai_assistance, defaults, languages, file_types, diff --git a/crates/onboarding_ui/Cargo.toml b/crates/onboarding_ui/Cargo.toml new file mode 100644 index 0000000000..456d81f64d --- /dev/null +++ b/crates/onboarding_ui/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "onboarding_ui" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/onboarding_ui.rs" + +[features] +test-support = [] + +[dependencies] +anyhow.workspace = true +client.workspace = true +command_palette_hooks.workspace = true +component.workspace = true +db.workspace = true +feature_flags.workspace = true +gpui.workspace = true +language.workspace = true +log.workspace = true +menu.workspace = true +project.workspace = true +serde_json.workspace = true +settings.workspace = true +settings_ui.workspace = true +smallvec.workspace = true +theme.workspace = true +ui.workspace = true +util.workspace = true +vim_mode_setting.workspace = true +welcome.workspace = true +workspace.workspace = true +zed_actions.workspace = true + +[dev-dependencies] +editor = { workspace = true, features = ["test-support"] } diff --git a/crates/onboarding_ui/src/components/callout_row.rs b/crates/onboarding_ui/src/components/callout_row.rs new file mode 100644 index 0000000000..2d7f600c0e --- /dev/null +++ b/crates/onboarding_ui/src/components/callout_row.rs @@ -0,0 +1,86 @@ +use component::{example_group_with_title, single_example}; +use gpui::{AnyElement, App, IntoElement, RenderOnce, Window}; +use smallvec::SmallVec; +use ui::{Label, prelude::*}; + +#[derive(IntoElement, RegisterComponent)] +pub struct CalloutRow { + title: SharedString, + lines: SmallVec<[SharedString; 4]>, +} + +impl CalloutRow { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + lines: SmallVec::new(), + } + } + + pub fn line(mut self, line: impl Into) -> Self { + self.lines.push(line.into()); + self + } +} + +impl RenderOnce for CalloutRow { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + div().px_2().child( + v_flex() + .p_3() + .gap_1() + .bg(cx.theme().colors().surface_background) + .border_1() + .border_color(cx.theme().colors().border_variant) + .rounded_md() + .child(Label::new(self.title).weight(gpui::FontWeight::MEDIUM)) + .children( + self.lines + .into_iter() + .map(|line| Label::new(line).size(LabelSize::Small).color(Color::Muted)), + ), + ) + } +} + +impl Component for CalloutRow { + fn scope() -> ComponentScope { + ComponentScope::Layout + } + + fn sort_name() -> &'static str { + "RowCallout" + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + let examples = example_group_with_title( + "CalloutRow Examples", + vec![ + single_example( + "Privacy Notice", + CalloutRow::new("We don't use your code to train AI models") + .line("You choose which providers you enable, and they have their own privacy policies.") + .line("Read more about our privacy practices in our Privacy Policy.") + .into_any_element(), + ), + single_example( + "Single Line", + CalloutRow::new("Important Notice") + .line("This is a single line of information.") + .into_any_element(), + ), + single_example( + "Multi Line", + CalloutRow::new("Getting Started") + .line("Welcome to Zed! Here are some things to know:") + .line("• Use Cmd+P to quickly open files") + .line("• Use Cmd+Shift+P to access the command palette") + .line("• Check out the documentation for more tips") + .into_any_element(), + ), + ], + ); + + Some(v_flex().p_4().gap_4().child(examples).into_any_element()) + } +} diff --git a/crates/onboarding_ui/src/components/checkbox_row.rs b/crates/onboarding_ui/src/components/checkbox_row.rs new file mode 100644 index 0000000000..a2e0957335 --- /dev/null +++ b/crates/onboarding_ui/src/components/checkbox_row.rs @@ -0,0 +1,134 @@ +use component::{example_group_with_title, single_example}; +use gpui::StatefulInteractiveElement as _; +use gpui::{AnyElement, App, ClickEvent, IntoElement, RenderOnce, Window}; +use ui::prelude::*; + +#[derive(IntoElement, RegisterComponent)] +pub struct CheckboxRow { + label: SharedString, + description: Option, + checked: bool, + on_click: Option>, +} + +impl CheckboxRow { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + description: None, + checked: false, + on_click: None, + } + } + + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn checked(mut self, checked: bool) -> Self { + self.checked = checked; + self + } + + pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_click = Some(Box::new(handler)); + self + } +} + +impl RenderOnce for CheckboxRow { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let checked = self.checked; + let on_click = self.on_click; + + let checkbox = gpui::div() + .w_4() + .h_4() + .rounded_sm() + .border_1() + .border_color(cx.theme().colors().border) + .when(checked, |this| { + this.bg(cx.theme().colors().element_selected) + .border_color(cx.theme().colors().border_selected) + }) + .hover(|this| this.bg(cx.theme().colors().element_hover)) + .child(gpui::div().when(checked, |this| { + this.size_full() + .flex() + .items_center() + .justify_center() + .child(Icon::new(IconName::Check)) + })); + + let main_row = if let Some(on_click) = on_click { + gpui::div() + .id("checkbox-row") + .h_flex() + .gap_2() + .items_center() + .child(checkbox) + .child(Label::new(self.label)) + .cursor_pointer() + .on_click(move |_event, window, cx| on_click(window, cx)) + } else { + gpui::div() + .id("checkbox-row") + .h_flex() + .gap_2() + .items_center() + .child(checkbox) + .child(Label::new(self.label)) + }; + + v_flex() + .px_5() + .py_1() + .gap_1() + .child(main_row) + .when_some(self.description, |this, desc| { + this.child( + gpui::div() + .ml_6() + .child(Label::new(desc).size(LabelSize::Small).color(Color::Muted)), + ) + }) + } +} + +impl Component for CheckboxRow { + fn scope() -> ComponentScope { + ComponentScope::Layout + } + + fn sort_name() -> &'static str { + "RowCheckbox" + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + let examples = example_group_with_title( + "CheckboxRow Examples", + vec![ + single_example( + "Unchecked", + CheckboxRow::new("Enable Vim Mode").into_any_element(), + ), + single_example( + "Checked", + CheckboxRow::new("Send Crash Reports") + .checked(true) + .into_any_element(), + ), + single_example( + "With Description", + CheckboxRow::new("Send Telemetry") + .description("Help improve Zed by sending anonymous usage data") + .checked(true) + .into_any_element(), + ), + ], + ); + + Some(v_flex().p_4().gap_4().child(examples).into_any_element()) + } +} diff --git a/crates/onboarding_ui/src/components/header_row.rs b/crates/onboarding_ui/src/components/header_row.rs new file mode 100644 index 0000000000..e13d48f84d --- /dev/null +++ b/crates/onboarding_ui/src/components/header_row.rs @@ -0,0 +1,78 @@ +use component::{example_group_with_title, single_example}; +use gpui::{AnyElement, App, IntoElement, RenderOnce, Window}; +use ui::{Label, prelude::*}; + +#[derive(IntoElement, RegisterComponent)] +pub struct HeaderRow { + label: SharedString, + end_slot: Option, +} + +impl HeaderRow { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + end_slot: None, + } + } + + pub fn end_slot(mut self, slot: impl IntoElement) -> Self { + self.end_slot = Some(slot.into_any_element()); + self + } +} + +impl RenderOnce for HeaderRow { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + h_flex() + .h(px(32.)) + .w_full() + .px_5() + .justify_between() + .child(Label::new(self.label)) + .when_some(self.end_slot, |this, slot| this.child(slot)) + } +} + +impl Component for HeaderRow { + fn scope() -> ComponentScope { + ComponentScope::Layout + } + + fn sort_name() -> &'static str { + "RowHeader" + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + let examples = example_group_with_title( + "HeaderRow Examples", + vec![ + single_example( + "Simple Header", + HeaderRow::new("Pick a Theme").into_any_element(), + ), + single_example( + "Header with Button", + HeaderRow::new("Pick a Theme") + .end_slot( + Button::new("more_themes", "More Themes") + .style(ButtonStyle::Subtle) + .color(Color::Muted), + ) + .into_any_element(), + ), + single_example( + "Header with Icon Button", + HeaderRow::new("Settings") + .end_slot( + IconButton::new("refresh", IconName::RotateCw) + .style(ButtonStyle::Subtle), + ) + .into_any_element(), + ), + ], + ); + + Some(v_flex().p_4().gap_4().child(examples).into_any_element()) + } +} diff --git a/crates/onboarding_ui/src/components/mod.rs b/crates/onboarding_ui/src/components/mod.rs new file mode 100644 index 0000000000..77ead73641 --- /dev/null +++ b/crates/onboarding_ui/src/components/mod.rs @@ -0,0 +1,11 @@ +mod callout_row; +mod checkbox_row; +mod header_row; +mod selectable_tile; +mod selectable_tile_row; + +pub use callout_row::CalloutRow; +pub use checkbox_row::CheckboxRow; +pub use header_row::HeaderRow; +pub use selectable_tile::SelectableTile; +pub use selectable_tile_row::SelectableTileRow; diff --git a/crates/onboarding_ui/src/components/selectable_tile.rs b/crates/onboarding_ui/src/components/selectable_tile.rs new file mode 100644 index 0000000000..b33b186270 --- /dev/null +++ b/crates/onboarding_ui/src/components/selectable_tile.rs @@ -0,0 +1,165 @@ +use component::{example_group_with_title, single_example}; +use gpui::{ClickEvent, transparent_black}; +use smallvec::SmallVec; +use ui::{Vector, VectorName, prelude::*, utils::CornerSolver}; + +#[derive(IntoElement, RegisterComponent)] +pub struct SelectableTile { + id: ElementId, + width: DefiniteLength, + height: DefiniteLength, + parent_focused: bool, + selected: bool, + children: SmallVec<[AnyElement; 2]>, + on_click: Option>, +} + +impl SelectableTile { + pub fn new( + id: impl Into, + width: impl Into, + height: impl Into, + ) -> Self { + Self { + id: id.into(), + width: width.into(), + height: height.into(), + parent_focused: false, + selected: false, + children: SmallVec::new(), + on_click: None, + } + } + + pub fn w(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + pub fn h(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + pub fn parent_focused(mut self, focused: bool) -> Self { + self.parent_focused = focused; + self + } + + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Box::new(handler)); + self + } +} + +impl RenderOnce for SelectableTile { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let ring_corner_radius = px(8.); + let ring_width = px(1.); + let padding = px(2.); + let content_border_width = px(0.); + let content_border_radius = CornerSolver::child_radius( + ring_corner_radius, + ring_width, + padding, + content_border_width, + ); + + let mut element = h_flex() + .id(self.id) + .w(self.width) + .h(self.height) + .overflow_hidden() + .rounded(ring_corner_radius) + .border(ring_width) + .border_color(if self.selected && self.parent_focused { + cx.theme().status().info + } else if self.selected { + cx.theme().colors().border + } else { + transparent_black() + }) + .p(padding) + .child( + h_flex() + .size_full() + .rounded(content_border_radius) + .items_center() + .justify_center() + .shadow_hairline() + .bg(cx.theme().colors().surface_background) + .children(self.children), + ); + + if let Some(on_click) = self.on_click { + element = element.on_click(move |event, window, cx| { + on_click(event, window, cx); + }); + } + + element + } +} + +impl ParentElement for SelectableTile { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } +} + +impl Component for SelectableTile { + fn scope() -> ComponentScope { + ComponentScope::Layout + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + let states = example_group(vec![ + single_example( + "Default", + SelectableTile::new("default", px(40.), px(40.)) + .parent_focused(false) + .selected(false) + .child(div().p_4().child(Vector::new( + VectorName::ZedLogo, + rems(18. / 16.), + rems(18. / 16.), + ))) + .into_any_element(), + ), + single_example( + "Selected", + SelectableTile::new("selected", px(40.), px(40.)) + .parent_focused(false) + .selected(true) + .child(div().p_4().child(Vector::new( + VectorName::ZedLogo, + rems(18. / 16.), + rems(18. / 16.), + ))) + .into_any_element(), + ), + single_example( + "Selected & Parent Focused", + SelectableTile::new("selected_focused", px(40.), px(40.)) + .parent_focused(true) + .selected(true) + .child(div().p_4().child(Vector::new( + VectorName::ZedLogo, + rems(18. / 16.), + rems(18. / 16.), + ))) + .into_any_element(), + ), + ]); + + Some(v_flex().p_4().gap_4().child(states).into_any_element()) + } +} diff --git a/crates/onboarding_ui/src/components/selectable_tile_row.rs b/crates/onboarding_ui/src/components/selectable_tile_row.rs new file mode 100644 index 0000000000..f903e4a663 --- /dev/null +++ b/crates/onboarding_ui/src/components/selectable_tile_row.rs @@ -0,0 +1,124 @@ +use super::selectable_tile::SelectableTile; +use component::{example_group_with_title, single_example}; +use gpui::{ + AnyElement, App, IntoElement, RenderOnce, StatefulInteractiveElement, Window, prelude::*, +}; +use smallvec::SmallVec; +use ui::{Label, prelude::*}; + +#[derive(IntoElement, RegisterComponent)] +pub struct SelectableTileRow { + gap: Pixels, + tiles: SmallVec<[SelectableTile; 8]>, +} + +impl SelectableTileRow { + pub fn new() -> Self { + Self { + gap: px(12.), + tiles: SmallVec::new(), + } + } + + pub fn gap(mut self, gap: impl Into) -> Self { + self.gap = gap.into(); + self + } + + pub fn tile(mut self, tile: SelectableTile) -> Self { + self.tiles.push(tile); + self + } +} + +impl RenderOnce for SelectableTileRow { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + h_flex().w_full().px_5().gap(self.gap).children(self.tiles) + } +} + +impl Component for SelectableTileRow { + fn scope() -> ComponentScope { + ComponentScope::Layout + } + + fn sort_name() -> &'static str { + "RowSelectableTile" + } + + fn preview(_window: &mut Window, _cx: &mut App) -> Option { + let examples = example_group_with_title( + "SelectableTileRow Examples", + vec![ + single_example( + "Theme Tiles", + SelectableTileRow::new() + .gap(px(12.)) + .tile( + SelectableTile::new("tile1", px(100.), px(80.)) + .selected(true) + .child( + div() + .size_full() + .bg(gpui::red()) + .flex() + .items_center() + .justify_center() + .child(Label::new("Dark")), + ), + ) + .tile( + SelectableTile::new("tile2", px(100.), px(80.)).child( + div() + .size_full() + .bg(gpui::green()) + .flex() + .items_center() + .justify_center() + .child(Label::new("Light")), + ), + ) + .tile( + SelectableTile::new("tile3", px(100.), px(80.)) + .parent_focused(true) + .child( + div() + .size_full() + .bg(gpui::blue()) + .flex() + .items_center() + .justify_center() + .child(Label::new("Auto")), + ), + ) + .into_any_element(), + ), + single_example( + "Icon Tiles", + SelectableTileRow::new() + .gap(px(8.)) + .tile( + SelectableTile::new("icon1", px(48.), px(48.)) + .selected(true) + .child(Icon::new(IconName::Code).size(IconSize::Medium)), + ) + .tile( + SelectableTile::new("icon2", px(48.), px(48.)) + .child(Icon::new(IconName::Terminal).size(IconSize::Medium)), + ) + .tile( + SelectableTile::new("icon3", px(48.), px(48.)) + .child(Icon::new(IconName::FileCode).size(IconSize::Medium)), + ) + .tile( + SelectableTile::new("icon4", px(48.), px(48.)) + .child(Icon::new(IconName::Settings).size(IconSize::Medium)), + ) + .into_any_element(), + ), + ], + ); + + Some(v_flex().p_4().gap_4().child(examples).into_any_element()) + } +} diff --git a/crates/onboarding_ui/src/juicy_button.rs b/crates/onboarding_ui/src/juicy_button.rs new file mode 100644 index 0000000000..39fb132b96 --- /dev/null +++ b/crates/onboarding_ui/src/juicy_button.rs @@ -0,0 +1,94 @@ +use gpui::{FontWeight, *}; +use ui::prelude::*; + +#[derive(IntoElement)] +pub struct JuicyButton { + base: Div, + label: SharedString, + keybinding: Option, + on_click: Option>, +} + +impl JuicyButton { + pub fn new(label: impl Into) -> Self { + Self { + base: div(), + label: label.into(), + keybinding: None, + on_click: None, + } + } + + pub fn keybinding(mut self, keybinding: Option) -> Self { + if let Some(kb) = keybinding { + self.keybinding = Some(kb.into_any_element()); + } + self + } +} + +impl Clickable for JuicyButton { + fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self { + self.on_click = Some(Box::new(handler)); + self + } + + fn cursor_style(mut self, style: gpui::CursorStyle) -> Self { + self.base = self.base.cursor(style); + self + } +} + +impl RenderOnce for JuicyButton { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let mut children = vec![ + h_flex() + .flex_1() + .items_center() + .justify_center() + .child( + div() + .text_size(px(14.)) + .font_weight(FontWeight::MEDIUM) + .text_color(cx.theme().colors().text) + .child(self.label), + ) + .into_any_element(), + ]; + + if let Some(keybinding) = self.keybinding { + children.push( + div() + .flex_none() + .bg(gpui::white().opacity(0.2)) + .rounded_md() + .px_1() + .child(keybinding) + .into_any_element(), + ); + } + + self.base + .id("juicy-button") + .w_full() + .h(rems(2.)) + .px(rems(1.5)) + .rounded(px(6.)) + .bg(cx.theme().colors().icon.opacity(0.12)) + .shadow_hairline() + .hover(|style| { + style.bg(cx.theme().colors().icon.opacity(0.12)) // Darker blue on hover + }) + .active(|style| { + style + .bg(rgb(0x1e40af)) // Even darker on active + .shadow_md() + }) + .cursor_pointer() + .flex() + .items_center() + .justify_between() + .when_some(self.on_click, |div, on_click| div.on_click(on_click)) + .children(children) + } +} diff --git a/crates/onboarding_ui/src/onboarding_ui.rs b/crates/onboarding_ui/src/onboarding_ui.rs new file mode 100644 index 0000000000..5901b9c249 --- /dev/null +++ b/crates/onboarding_ui/src/onboarding_ui.rs @@ -0,0 +1,1290 @@ +#![allow(unused, dead_code)] +mod components; +mod juicy_button; +mod persistence; +mod theme_preview; + +use self::components::{CalloutRow, CheckboxRow, HeaderRow, SelectableTile, SelectableTileRow}; +use self::juicy_button::JuicyButton; +use client::{Client, TelemetrySettings}; +use command_palette_hooks::CommandPaletteFilter; +use feature_flags::FeatureFlagAppExt as _; +use gpui::{ + Action, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, KeyBinding, Subscription, + Task, UpdateGlobal, WeakEntity, actions, prelude::*, svg, transparent_black, +}; +use menu; +use persistence::ONBOARDING_DB; + +use language::language_settings::{AllLanguageSettings, ai_enabled, all_language_settings}; +use project::Project; +use serde_json; +use settings::{Settings, SettingsStore, update_settings_file}; +use settings_ui::SettingsUiFeatureFlag; +use std::collections::HashSet; +use std::sync::Arc; +use theme::{Theme, ThemeRegistry, ThemeSettings}; +use ui::{ + CheckboxWithLabel, ContentGroup, FocusOutline, KeybindingHint, ListItem, ToggleState, Vector, + VectorName, prelude::*, +}; +use util::ResultExt; +use vim_mode_setting::VimModeSetting; +use welcome::{BaseKeymap, WelcomePage}; +use workspace::{ + Workspace, WorkspaceId, + item::{Item, ItemEvent, SerializableItem}, + notifications::NotifyResultExt, +}; +use zed_actions; + +actions!( + onboarding, + [ + ShowOnboarding, + JumpToBasics, + JumpToEditing, + JumpToAiSetup, + JumpToWelcome, + NextPage, + PreviousPage, + ToggleFocus, + ResetOnboarding, + ] +); + +pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, _, _cx| { + workspace.register_action(|workspace, _: &ShowOnboarding, window, cx| { + let client = workspace.client().clone(); + let onboarding = cx.new(|cx| OnboardingUI::new(workspace, client, cx)); + workspace.add_item_to_active_pane(Box::new(onboarding), None, true, window, cx); + }); + }) + .detach(); + + workspace::register_serializable_item::(cx); + + feature_gate_onboarding_ui_actions(cx); +} + +fn feature_gate_onboarding_ui_actions(cx: &mut App) { + const ONBOARDING_ACTION_NAMESPACE: &str = "onboarding_ui"; + + CommandPaletteFilter::update_global(cx, |filter, _cx| { + filter.hide_namespace(ONBOARDING_ACTION_NAMESPACE); + }); + + cx.observe_flag::({ + move |is_enabled, cx| { + CommandPaletteFilter::update_global(cx, |filter, _cx| { + if is_enabled { + filter.show_namespace(ONBOARDING_ACTION_NAMESPACE); + } else { + filter.hide_namespace(ONBOARDING_ACTION_NAMESPACE); + } + }); + } + }) + .detach(); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum OnboardingPage { + Basics, + Editing, + AiSetup, + Welcome, +} + +impl OnboardingPage { + fn next(&self) -> Option { + match self { + Self::Basics => Some(Self::Editing), + Self::Editing => Some(Self::AiSetup), + Self::AiSetup => Some(Self::Welcome), + Self::Welcome => None, + } + } + + fn previous(&self) -> Option { + match self { + Self::Basics => None, + Self::Editing => Some(Self::Basics), + Self::AiSetup => Some(Self::Editing), + Self::Welcome => Some(Self::AiSetup), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavigationFocusItem { + SignIn, + Basics, + Editing, + AiSetup, + Welcome, + Next, +} + +#[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: HashSet, + focus_area: FocusArea, + + // Workspace reference for Item trait + workspace: WeakEntity, + workspace_id: Option, + client: Arc, + welcome_page: Option>, + _settings_subscription: Option, +} + +impl OnboardingUI {} + +impl EventEmitter for OnboardingUI {} + +impl Focusable for OnboardingUI { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +#[derive(Clone)] +pub enum OnboardingEvent { + PageCompleted(OnboardingPage), +} + +impl Render for OnboardingUI { + fn render( + &mut self, + window: &mut gpui::Window, + cx: &mut Context, + ) -> impl gpui::IntoElement { + 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)) + .on_action(cx.listener(Self::handle_enable_ai_assistance)) + .on_action(cx.listener(Self::handle_disable_ai_assistance)) + .flex() + .items_center() + .justify_center() + .overflow_hidden() + .child( + h_flex() + .id("onboarding-ui") + .key_context("Onboarding") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::handle_jump_to_basics)) + .on_action(cx.listener(Self::handle_jump_to_editing)) + .on_action(cx.listener(Self::handle_jump_to_ai_setup)) + .on_action(cx.listener(Self::handle_jump_to_welcome)) + .on_action(cx.listener(Self::handle_next_page)) + .on_action(cx.listener(Self::handle_previous_page)) + .w(px(984.)) + .overflow_hidden() + .gap(px(24.)) + .child( + h_flex() + .h(px(500.)) + .w_full() + .overflow_hidden() + .gap(px(48.)) + .child(self.render_navigation(window, cx)) + .child( + v_flex() + .h_full() + .flex_1() + .overflow_hidden() + .child(self.render_active_page(window, cx)), + ), + ), + ) + } +} + +impl OnboardingUI { + pub fn new(workspace: &Workspace, client: Arc, cx: &mut Context) -> Self { + let settings_subscription = cx.observe_global::(|_, cx| { + cx.notify(); + }); + + Self { + focus_handle: cx.focus_handle(), + current_page: OnboardingPage::Basics, + nav_focus: NavigationFocusItem::Basics, + page_focus: [PageFocusItem(0); 4], + completed_pages: HashSet::new(), + focus_area: FocusArea::Navigation, + workspace: workspace.weak_handle(), + workspace_id: workspace.database_id(), + client, + welcome_page: None, + _settings_subscription: Some(settings_subscription), + } + } + + fn completed_pages_to_string(&self) -> String { + let mut result = String::new(); + for i in 0..4 { + let page = match i { + 0 => OnboardingPage::Basics, + 1 => OnboardingPage::Editing, + 2 => OnboardingPage::AiSetup, + 3 => OnboardingPage::Welcome, + _ => unreachable!(), + }; + result.push(if self.completed_pages.contains(&page) { + '1' + } else { + '0' + }); + } + result + } + + fn completed_pages_from_string(s: &str) -> HashSet { + let mut result = HashSet::new(); + for (i, ch) in s.chars().take(4).enumerate() { + if ch == '1' { + let page = match i { + 0 => OnboardingPage::Basics, + 1 => OnboardingPage::Editing, + 2 => OnboardingPage::AiSetup, + 3 => OnboardingPage::Welcome, + _ => continue, + }; + result.insert(page); + } + } + result + } + + fn jump_to_page( + &mut self, + page: OnboardingPage, + _window: &mut gpui::Window, + cx: &mut Context, + ) { + self.current_page = page; + cx.emit(ItemEvent::UpdateTab); + cx.notify(); + } + + fn next_page(&mut self, _window: &mut gpui::Window, cx: &mut Context) { + if let Some(next) = self.current_page.next() { + self.current_page = next; + cx.notify(); + } + } + + fn previous_page(&mut self, _window: &mut gpui::Window, cx: &mut Context) { + if let Some(prev) = self.current_page.previous() { + self.current_page = prev; + cx.notify(); + } + } + + fn reset(&mut self, _window: &mut gpui::Window, cx: &mut Context) { + self.current_page = OnboardingPage::Basics; + self.focus_area = FocusArea::Navigation; + self.completed_pages = HashSet::new(); + 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 => 14, // 4 themes + 7 keymaps + 3 checkboxes + 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 => 14, // 4 themes + 7 keymaps + 3 checkboxes + 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..=3 => { + // Theme selection + cx.notify(); + } + 4..=10 => { + // Keymap selection + cx.notify(); + } + 11..=13 => { + // Checkbox toggles (handled by their own listeners) + 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, + _window: &mut gpui::Window, + cx: &mut Context, + ) { + self.completed_pages.insert(page); + cx.notify(); + } + + fn set_ai_assistance( + &mut self, + selection: &ToggleState, + _: &mut Window, + cx: &mut Context, + ) { + let enabled = selection == &ToggleState::Selected; + + if let Some(workspace) = self.workspace.upgrade() { + let fs = workspace.read(cx).app_state().fs.clone(); + AllLanguageSettings::set_ai_assistance(enabled, fs, cx); + + cx.notify(); + } + } + + fn handle_jump_to_basics( + &mut self, + _: &JumpToBasics, + window: &mut Window, + cx: &mut Context, + ) { + self.jump_to_page(OnboardingPage::Basics, window, cx); + } + + fn handle_jump_to_editing( + &mut self, + _: &JumpToEditing, + window: &mut Window, + cx: &mut Context, + ) { + self.jump_to_page(OnboardingPage::Editing, window, cx); + } + + fn handle_jump_to_ai_setup( + &mut self, + _: &JumpToAiSetup, + window: &mut Window, + cx: &mut Context, + ) { + self.jump_to_page(OnboardingPage::AiSetup, window, cx); + } + + fn handle_jump_to_welcome( + &mut self, + _: &JumpToWelcome, + window: &mut Window, + cx: &mut Context, + ) { + self.jump_to_page(OnboardingPage::Welcome, window, cx); + } + + fn handle_next_page(&mut self, _: &NextPage, window: &mut Window, cx: &mut Context) { + self.next_page(window, cx); + } + + fn handle_enable_ai_assistance( + &mut self, + _: &zed_actions::EnableAiAssistance, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(workspace) = self.workspace.upgrade() { + let fs = workspace.read(cx).app_state().fs.clone(); + update_settings_file::(fs, cx, move |file, _| { + file.features + .get_or_insert(Default::default()) + .ai_assistance = Some(true); + }); + cx.notify(); + } + } + + fn handle_disable_ai_assistance( + &mut self, + _: &zed_actions::DisableAiAssistance, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(workspace) = self.workspace.upgrade() { + let fs = workspace.read(cx).app_state().fs.clone(); + update_settings_file::(fs, cx, move |file, _| { + file.features + .get_or_insert(Default::default()) + .ai_assistance = Some(false); + }); + cx.notify(); + } + } + + fn handle_previous_page( + &mut self, + _: &PreviousPage, + window: &mut Window, + cx: &mut Context, + ) { + self.previous_page(window, cx); + } + + fn render_navigation( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> impl gpui::IntoElement { + let client = self.client.clone(); + + v_flex() + .h_full() + .w(px(256.)) + .gap_2() + .justify_between() + .child( + v_flex() + .w_full() + .gap_px() + .child( + h_flex() + .w_full() + .justify_between() + .py(px(24.)) + .pl(px(24.)) + .pr(px(12.)) + .child( + Vector::new(VectorName::ZedLogo, rems(2.), rems(2.)) + .color(Color::Custom(cx.theme().colors().icon.opacity(0.5))), + ) + .child( + 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(); + window + .spawn(cx, async move |cx| { + client + .authenticate_and_connect(true, &cx) + .await + .into_response() + .notify_async_err(cx); + }) + .detach(); + })), + ), + ) + .child( + v_flex() + .gap_px() + .py(px(16.)) + .gap(px(2.)) + .child(self.render_nav_item( + OnboardingPage::Basics, + "The Basics", + "1", + cx, + )) + .child(self.render_nav_item( + OnboardingPage::Editing, + "Editing Experience", + "2", + cx, + )) + .child(self.render_nav_item( + OnboardingPage::AiSetup, + "AI Setup", + "3", + cx, + )) + .child(self.render_nav_item( + OnboardingPage::Welcome, + "Welcome", + "4", + cx, + )), + ), + ) + .child(self.render_bottom_controls(window, cx)) + } + + fn render_nav_item( + &mut self, + page: OnboardingPage, + label: impl Into, + shortcut: impl Into, + cx: &mut Context, + ) -> impl gpui::IntoElement { + let is_selected = self.current_page == page; + let label = label.into(); + let shortcut = shortcut.into(); + let id = ElementId::Name(label.clone()); + let corner_radius = px(4.); + + let item_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; + + FocusOutline::new(corner_radius, item_focused, px(2.)) + .active(area_focused && item_focused) + .child( + h_flex() + .id(id) + .h(rems(1.625)) + .w_full() + .rounded(corner_radius) + .px_3() + .when(is_selected, |this| { + this.bg(cx.theme().colors().border_focused.opacity(0.16)) + }) + .child( + h_flex() + .flex_1() + .justify_between() + .items_center() + .child( + Label::new(label) + .weight(FontWeight::MEDIUM) + .color(Color::Muted) + .when(item_focused, |this| this.color(Color::Default)), + ) + .child( + Label::new(format!("⌘{}", shortcut.clone())) + .color(Color::Placeholder) + .size(LabelSize::XSmall), + ), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.jump_to_page(page, window, cx); + })), + ) + } + + fn render_bottom_controls( + &mut self, + window: &mut gpui::Window, + cx: &mut Context, + ) -> impl gpui::IntoElement { + h_flex().w_full().p(px(12.)).child( + JuicyButton::new(if self.current_page == OnboardingPage::Welcome { + "Get Started" + } else { + "Next" + }) + .keybinding(ui::KeyBinding::for_action_in( + &NextPage, + &self.focus_handle, + window, + cx, + )) + .on_click(cx.listener(|this, _, window, cx| { + this.next_page(window, cx); + })), + ) + } + + fn render_active_page(&mut self, _window: &mut Window, cx: &mut Context) -> AnyElement { + match self.current_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(&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; + + use theme_preview::ThemePreviewTile; + + // Get available themes + let theme_registry = ThemeRegistry::default_global(cx); + let theme_names = theme_registry.list_names(); + let current_theme = cx.theme().clone(); + + v_flex() + .id("theme-selector") + .h_full() + .w_full() + .overflow_y_scroll() + .child({ + let vim_enabled = VimModeSetting::get_global(cx).0; + CheckboxRow::new("Enable Vim Mode") + .checked(vim_enabled) + .on_click(move |_window, cx| { + let current = VimModeSetting::get_global(cx).0; + SettingsStore::update_global(cx, move |store, cx| { + let mut settings = store.raw_user_settings().clone(); + settings["vim_mode"] = serde_json::json!(!current); + store.set_user_settings(&settings.to_string(), cx).ok(); + }); + }) + }) + // Theme selector section + .child( + v_flex() + .w_full() + .overflow_hidden() + .child( + HeaderRow::new("Pick a Theme").end_slot( + Button::new("more_themes", "More Themes") + .style(ButtonStyle::Subtle) + .color(Color::Muted) + .on_click(cx.listener(|_, _, window, cx| { + window.dispatch_action( + zed_actions::theme_selector::Toggle::default() + .boxed_clone(), + cx, + ); + })), + ), + ) + .child( + h_flex().w_full().overflow_hidden().gap_3().children( + vec![ + ("One Dark", "One Dark"), + ("Gruvbox Dark", "Gruvbox Dark"), + ("One Light", "One Light"), + ("Gruvbox Light", "Gruvbox Light"), + ] + .into_iter() + .enumerate() + .map(|(i, (label, theme_name))| { + let is_selected = current_theme.name == *theme_name; + let is_focused = is_page_focused && focused_item == i; + + v_flex() + .flex_1() + .gap_1p5() + .justify_center() + .text_center() + .child( + div() + .id(("theme", i)) + .rounded(px(8.)) + .h(px(90.)) + .w_full() + .overflow_hidden() + .border_1() + .border_color(if is_focused { + cx.theme().colors().border_focused + } else { + transparent_black() + }) + .child( + if let Ok(theme) = theme_registry.get(theme_name) { + ThemePreviewTile::new(theme, is_selected, 0.5) + .into_any_element() + } else { + div() + .size_full() + .bg(cx.theme().colors().surface_background) + .rounded_md() + .into_any_element() + }, + ) + .on_click(cx.listener(move |this, _, window, cx| { + SettingsStore::update_global( + cx, + move |store, cx| { + let mut settings = + store.raw_user_settings().clone(); + settings["theme"] = + serde_json::json!(theme_name); + store + .set_user_settings( + &settings.to_string(), + cx, + ) + .ok(); + }, + ); + cx.notify(); + })), + ) + .child( + div() + .text_color(cx.theme().colors().text) + .text_size(px(12.)) + .child(label), + ) + }), + ), + ), + ) + // Keymap selector section + .child( + v_flex() + .gap_1() + .child(HeaderRow::new("Pick a Keymap")) + .child( + h_flex().gap_2().children( + vec![ + ("Zed", VectorName::ZedLogo, 4), + ("Atom", VectorName::AtomLogo, 5), + ("JetBrains", VectorName::ZedLogo, 6), + ("Sublime", VectorName::ZedLogo, 7), + ("VSCode", VectorName::ZedLogo, 8), + ("Emacs", VectorName::ZedLogo, 9), + ("TextMate", VectorName::ZedLogo, 10), + ] + .into_iter() + .map(|(label, icon, index)| { + let is_focused = is_page_focused && focused_item == index; + let current_keymap = BaseKeymap::get_global(cx).to_string(); + let is_selected = current_keymap == label; + + v_flex() + .w(px(72.)) + .gap_1() + .items_center() + .justify_center() + .text_center() + .child( + h_flex() + .id(("keymap", index)) + .size(px(48.)) + .rounded(px(8.)) + .items_center() + .justify_center() + .border_1() + .border_color(if is_selected { + cx.theme().colors().border_selected + } else { + transparent_black() + }) + .when(is_focused, |this| { + this.border_color( + cx.theme().colors().border_focused, + ) + }) + .when(is_selected, |this| { + this.bg(cx.theme().status().info.opacity(0.08)) + }) + .child( + h_flex() + .size(px(34.)) + .rounded(px(6.)) + .border_2() + .border_color(cx.theme().colors().border) + .items_center() + .justify_center() + .shadow_hairline() + .child( + Vector::new(icon, rems(1.25), rems(1.25)) + .color(if is_selected { + Color::Info + } else { + Color::Default + }), + ), + ) + .on_click(cx.listener(move |this, _, window, cx| { + SettingsStore::update_global( + cx, + move |store, cx| { + let base_keymap = match label { + "Zed" => "None", + "Atom" => "Atom", + "JetBrains" => "JetBrains", + "Sublime" => "SublimeText", + "VSCode" => "VSCode", + "Emacs" => "Emacs", + "TextMate" => "TextMate", + _ => "VSCode", + }; + let mut settings = + store.raw_user_settings().clone(); + settings["base_keymap"] = + serde_json::json!(base_keymap); + store + .set_user_settings( + &settings.to_string(), + cx, + ) + .ok(); + }, + ); + cx.notify(); + })), + ) + .child( + div() + .text_color(cx.theme().colors().text) + .text_size(px(12.)) + .child(label), + ) + }), + ), + ), + ) + // Settings checkboxes + .child( + v_flex() + .gap_1() + .child(HeaderRow::new("Help Improve Zed")) + .child({ + let telemetry_enabled = TelemetrySettings::get_global(cx).metrics; + CheckboxRow::new("Send Telemetry") + .description("Help improve Zed by sending anonymous usage data") + .checked(telemetry_enabled) + .on_click(move |_window, cx| { + let current = TelemetrySettings::get_global(cx).metrics; + SettingsStore::update_global(cx, move |store, cx| { + let mut settings = store.raw_user_settings().clone(); + if settings.get("telemetry").is_none() { + settings["telemetry"] = serde_json::json!({}); + } + settings["telemetry"]["metrics"] = serde_json::json!(!current); + store.set_user_settings(&settings.to_string(), cx).ok(); + }); + }) + }) + .child({ + let crash_reports_enabled = TelemetrySettings::get_global(cx).diagnostics; + CheckboxRow::new("Send Crash Reports") + .description("We use crash reports to help us fix issues") + .checked(crash_reports_enabled) + .on_click(move |_window, cx| { + let current = TelemetrySettings::get_global(cx).diagnostics; + SettingsStore::update_global(cx, move |store, cx| { + let mut settings = store.raw_user_settings().clone(); + if settings.get("telemetry").is_none() { + settings["telemetry"] = serde_json::json!({}); + } + settings["telemetry"]["diagnostics"] = + serde_json::json!(!current); + store.set_user_settings(&settings.to_string(), cx).ok(); + }); + }) + }), + ) + .into_any_element() + } + + 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() + .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(&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; + + let ai_enabled = all_language_settings(None, cx).is_ai_assistance_enabled(); + + v_flex() + .h_full() + .w_full() + .gap_4() + .child( + h_flex() + .justify_start() + .child( + CheckboxWithLabel::new( + "disable_ai", + Label::new("Enable AI Features"), + if ai_enabled { + ToggleState::Selected + } else { + ToggleState::Unselected + }, + cx.listener(Self::set_ai_assistance), + ))) + .child( + CalloutRow::new("We don't use your code to train AI models") + .line("You choose which providers you enable, and they have their own privacy policies.") + .line("Read more about our privacy practices in our Privacy Policy.") + ) + .child( + HeaderRow::new("Choose your AI Providers") + ) + .into_any_element() + } + + fn render_welcome_page(&mut self, cx: &mut Context) -> AnyElement { + // Lazy-initialize the welcome page if needed + if self.welcome_page.is_none() { + if let Some(workspace) = self.workspace.upgrade() { + let _ = workspace.update(cx, |workspace, cx| { + self.welcome_page = Some(WelcomePage::new(workspace, cx)); + }); + } + } + + // Render the welcome page if it exists, otherwise show a fallback + if let Some(welcome_page) = &self.welcome_page { + welcome_page.clone().into_any_element() + } else { + // Fallback UI if we couldn't create the welcome page + v_flex() + .h_full() + .w_full() + .items_center() + .justify_center() + .child( + Label::new("Unable to load welcome page") + .size(LabelSize::Default) + .color(Color::Error), + ) + .into_any_element() + } + } +} + +impl Item for OnboardingUI { + type Event = ItemEvent; + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Onboarding".into() + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { + f(event.clone()) + } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + _window: &mut Window, + _cx: &mut Context, + ) { + self.workspace_id = workspace.database_id(); + } + + fn show_toolbar(&self) -> bool { + false + } + + fn clone_on_split( + &self, + _workspace_id: Option, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + let weak_workspace = self.workspace.clone(); + let client = self.client.clone(); + if let Some(workspace) = weak_workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + Some(cx.new(|cx| OnboardingUI::new(workspace, client, cx))) + }) + } else { + None + } + } +} + +impl SerializableItem for OnboardingUI { + fn serialized_item_kind() -> &'static str { + "OnboardingUI" + } + + fn deserialize( + _project: Entity, + workspace: WeakEntity, + workspace_id: WorkspaceId, + item_id: u64, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + window.spawn(cx, async move |cx| { + let (current_page, completed_pages) = if let Some((page_str, completed_str)) = + ONBOARDING_DB.get_state(item_id, workspace_id)? + { + let page = match page_str.as_str() { + "basics" => OnboardingPage::Basics, + "editing" => OnboardingPage::Editing, + "ai_setup" => OnboardingPage::AiSetup, + "welcome" => OnboardingPage::Welcome, + _ => OnboardingPage::Basics, + }; + let completed = OnboardingUI::completed_pages_from_string(&completed_str); + (page, completed) + } else { + (OnboardingPage::Basics, HashSet::new()) + }; + + cx.update(|window, cx| { + let workspace = workspace + .upgrade() + .ok_or_else(|| anyhow::anyhow!("workspace dropped"))?; + + workspace.update(cx, |workspace, cx| { + let client = workspace.client().clone(); + Ok(cx.new(|cx| { + let mut onboarding = OnboardingUI::new(workspace, client, cx); + onboarding.current_page = current_page; + onboarding.completed_pages = completed_pages; + onboarding + })) + }) + })? + }) + } + + fn serialize( + &mut self, + _workspace: &mut Workspace, + item_id: u64, + _closing: bool, + _window: &mut Window, + cx: &mut Context, + ) -> Option>> { + let workspace_id = self.workspace_id?; + let current_page = match self.current_page { + OnboardingPage::Basics => "basics", + OnboardingPage::Editing => "editing", + OnboardingPage::AiSetup => "ai_setup", + OnboardingPage::Welcome => "welcome", + } + .to_string(); + let completed_pages = self.completed_pages_to_string(); + + Some(cx.background_spawn(async move { + ONBOARDING_DB + .save_state(item_id, workspace_id, current_page, completed_pages) + .await + })) + } + + fn cleanup( + _workspace_id: WorkspaceId, + _item_ids: Vec, + _window: &mut Window, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn should_serialize(&self, _event: &ItemEvent) -> bool { + true + } +} diff --git a/crates/onboarding_ui/src/persistence.rs b/crates/onboarding_ui/src/persistence.rs new file mode 100644 index 0000000000..944d5c58ae --- /dev/null +++ b/crates/onboarding_ui/src/persistence.rs @@ -0,0 +1,53 @@ +use anyhow::Result; +use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql}; + +use workspace::{WorkspaceDb, WorkspaceId}; + +define_connection! { + pub static ref ONBOARDING_DB: OnboardingDb = + &[sql!( + CREATE TABLE onboarding_state ( + workspace_id INTEGER, + item_id INTEGER UNIQUE, + current_page TEXT, + completed_pages TEXT, + PRIMARY KEY(workspace_id, item_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ) STRICT; + )]; +} + +impl OnboardingDb { + pub async fn save_state( + &self, + item_id: u64, + workspace_id: WorkspaceId, + current_page: String, + completed_pages: String, + ) -> Result<()> { + let query = + "INSERT INTO onboarding_state(item_id, workspace_id, current_page, completed_pages) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT DO UPDATE SET + current_page = ?3, + completed_pages = ?4"; + self.write(move |conn| { + let mut statement = Statement::prepare(conn, query)?; + let mut next_index = statement.bind(&item_id, 1)?; + next_index = statement.bind(&workspace_id, next_index)?; + next_index = statement.bind(¤t_page, next_index)?; + statement.bind(&completed_pages, next_index)?; + statement.exec() + }) + .await + } + + query! { + pub fn get_state(item_id: u64, workspace_id: WorkspaceId) -> Result> { + SELECT current_page, completed_pages + FROM onboarding_state + WHERE item_id = ? AND workspace_id = ? + } + } +} diff --git a/crates/welcome/src/welcome_ui/theme_preview.rs b/crates/onboarding_ui/src/theme_preview.rs similarity index 66% rename from crates/welcome/src/welcome_ui/theme_preview.rs rename to crates/onboarding_ui/src/theme_preview.rs index b3a80c74c3..28c9f0f231 100644 --- a/crates/welcome/src/welcome_ui/theme_preview.rs +++ b/crates/onboarding_ui/src/theme_preview.rs @@ -2,13 +2,11 @@ use gpui::{Hsla, Length}; use std::sync::Arc; use theme::{Theme, ThemeRegistry}; -use ui::{ - IntoElement, RenderOnce, component_prelude::Documented, prelude::*, utils::inner_corner_radius, -}; +use ui::{IntoElement, RenderOnce, prelude::*, utils::CornerSolver}; /// Shows a preview of a theme as an abstract illustration /// of a thumbnail-sized editor. -#[derive(IntoElement, RegisterComponent, Documented)] +#[derive(IntoElement)] pub struct ThemePreviewTile { theme: Arc, selected: bool, @@ -36,10 +34,10 @@ impl RenderOnce for ThemePreviewTile { let root_radius = px(8.0); let root_border = px(2.0); - let root_padding = px(2.0); + let root_padding = px(0.0); let child_border = px(1.0); let inner_radius = - inner_corner_radius(root_radius, root_border, root_padding, child_border); + CornerSolver::child_radius(root_radius, root_border, root_padding, child_border); let item_skeleton = |w: Length, h: Pixels, bg: Hsla| div().w(w).h(h).rounded_full().bg(bg); @@ -200,81 +198,3 @@ impl RenderOnce for ThemePreviewTile { ) } } - -impl Component for ThemePreviewTile { - fn description() -> Option<&'static str> { - Some(Self::DOCS) - } - - fn preview(_window: &mut Window, cx: &mut App) -> Option { - let theme_registry = ThemeRegistry::global(cx); - - let one_dark = theme_registry.get("One Dark"); - let one_light = theme_registry.get("One Light"); - let gruvbox_dark = theme_registry.get("Gruvbox Dark"); - let gruvbox_light = theme_registry.get("Gruvbox Light"); - - let themes_to_preview = vec![ - one_dark.clone().ok(), - one_light.clone().ok(), - gruvbox_dark.clone().ok(), - gruvbox_light.clone().ok(), - ] - .into_iter() - .flatten() - .collect::>(); - - Some( - v_flex() - .gap_6() - .p_4() - .children({ - if let Some(one_dark) = one_dark.ok() { - vec![example_group(vec![ - single_example( - "Default", - div() - .w(px(240.)) - .h(px(180.)) - .child(ThemePreviewTile::new(one_dark.clone(), false, 0.42)) - .into_any_element(), - ), - single_example( - "Selected", - div() - .w(px(240.)) - .h(px(180.)) - .child(ThemePreviewTile::new(one_dark, true, 0.42)) - .into_any_element(), - ), - ])] - } else { - vec![] - } - }) - .child( - example_group(vec![single_example( - "Default Themes", - h_flex() - .gap_4() - .children( - themes_to_preview - .iter() - .enumerate() - .map(|(i, theme)| { - div().w(px(200.)).h(px(140.)).child(ThemePreviewTile::new( - theme.clone(), - false, - 0.42, - )) - }) - .collect::>(), - ) - .into_any_element(), - )]) - .grow(), - ) - .into_any_element(), - ) - } -} diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 88676e8a2b..d21bc16b4c 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -8,6 +8,7 @@ mod disclosure; mod divider; mod dropdown_menu; mod facepile; +mod focus_outline; mod group; mod icon; mod image; @@ -15,6 +16,7 @@ mod indent_guides; mod indicator; mod keybinding; mod keybinding_hint; +mod keyboard_navigation; mod label; mod list; mod modal; @@ -49,6 +51,7 @@ pub use disclosure::*; pub use divider::*; pub use dropdown_menu::*; pub use facepile::*; +pub use focus_outline::*; pub use group::*; pub use icon::*; pub use image::*; @@ -56,6 +59,7 @@ pub use indent_guides::*; pub use indicator::*; pub use keybinding::*; pub use keybinding_hint::*; +pub use keyboard_navigation::*; pub use label::*; pub use list::*; pub use modal::*; diff --git a/crates/ui/src/components/button/split_button.rs b/crates/ui/src/components/button/split_button.rs index c0811ecbab..235b7d04bf 100644 --- a/crates/ui/src/components/button/split_button.rs +++ b/crates/ui/src/components/button/split_button.rs @@ -1,7 +1,4 @@ -use gpui::{ - AnyElement, App, BoxShadow, IntoElement, ParentElement, RenderOnce, Styled, Window, div, hsla, - point, px, -}; +use gpui::{AnyElement, App, IntoElement, ParentElement, RenderOnce, Styled, Window, div}; use theme::ActiveTheme; use crate::{ElevationIndex, h_flex}; @@ -41,11 +38,6 @@ impl RenderOnce for SplitButton { ) .child(self.right) .bg(ElevationIndex::Surface.on_elevation_bg(cx)) - .shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.16), - offset: point(px(0.), px(1.)), - blur_radius: px(0.), - spread_radius: px(0.), - }]) + .shadow_hairline() } } diff --git a/crates/ui/src/components/focus_outline.rs b/crates/ui/src/components/focus_outline.rs new file mode 100644 index 0000000000..042ba588cd --- /dev/null +++ b/crates/ui/src/components/focus_outline.rs @@ -0,0 +1,65 @@ +use gpui::{ + AnyElement, IntoElement, ParentElement, Pixels, RenderOnce, Styled, px, transparent_black, +}; +use smallvec::SmallVec; +use theme::ActiveTheme; + +use crate::{h_flex, utils::CornerSolver}; + +/// An outline is a stylistic focus indicator that draws a ring around +/// an element with some space between the element and ring. +#[derive(IntoElement)] +pub struct FocusOutline { + corner_radius: Pixels, + border_width: Pixels, + padding: Pixels, + focused: bool, + active: bool, + children: SmallVec<[AnyElement; 2]>, +} + +impl FocusOutline { + pub fn new(child_corner_radius: Pixels, focused: bool, offset: Pixels) -> Self { + let ring_width = px(1.); + let corner_radius = + CornerSolver::parent_radius(child_corner_radius, ring_width, offset, px(0.)); + Self { + corner_radius, + border_width: ring_width, + padding: offset, + focused, + active: false, + children: SmallVec::new(), + } + } + + pub fn active(mut self, active: bool) -> Self { + self.active = active; + self + } +} + +impl RenderOnce for FocusOutline { + fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement { + let border_color = if self.focused && self.active { + cx.theme().colors().border_focused.opacity(0.48) + } else if self.focused { + cx.theme().colors().border_variant + } else { + transparent_black() + }; + + h_flex() + .border(self.border_width) + .border_color(border_color) + .rounded(self.corner_radius) + .p(self.padding) + .children(self.children) + } +} + +impl ParentElement for FocusOutline { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } +} diff --git a/crates/ui/src/components/image.rs b/crates/ui/src/components/image.rs index 2deba68d88..c4d0389e43 100644 --- a/crates/ui/src/components/image.rs +++ b/crates/ui/src/components/image.rs @@ -12,11 +12,13 @@ use crate::prelude::*; )] #[strum(serialize_all = "snake_case")] pub enum VectorName { + AiGrid, + AtomLogo, + DebuggerGrid, + Grid, + SublimeLogo, ZedLogo, ZedXCopilot, - Grid, - AiGrid, - DebuggerGrid, } impl VectorName { diff --git a/crates/ui/src/components/keyboard_navigation.rs b/crates/ui/src/components/keyboard_navigation.rs new file mode 100644 index 0000000000..2a624cb3a3 --- /dev/null +++ b/crates/ui/src/components/keyboard_navigation.rs @@ -0,0 +1,59 @@ +use gpui::{Focusable, actions}; + +actions!( + keyboard_navigation, + [NextRow, PreviousRow, NextColumn, PreviousColumn] +); + +/// Implement this trait to enable grid-like keyboard navigation for a layout. +/// +/// This trait allows you to navigate through a layout of rows with mixed column +/// lengths. In example, a layout might have rows with 5, 1 and 3 columns. +/// +/// Moving up or down between rows will focus the first element in the next or previous row. +/// Moving left or right between columns will focus the next or previous element in the same row. +/// +/// Wrapping can be enabled via `vertical_wrapping` and `horizontal_wrapping` respectively. +pub trait KeyboardNavigation: Focusable { + fn has_focus(&self) -> bool; + /// The focused row. Always has a value to allow for "focused inactive" states. + fn focused_row(&self) -> usize; + /// The focused column. Always has a value to allow for "focused inactive" states. + fn focused_column(&self) -> usize; + /// Focus the first focusable element in the layout. + fn focus_first(&self); + /// Focus the next row, wrapping back to the first row if necessary. + /// + /// Is a no-op if wrapping is not enabled. + fn focus_next_row(&self); + /// Focus the previous row, wrapping back to the last row if necessary. + /// + /// Is a no-op if wrapping is not enabled. + fn focus_previous_row(&self); + /// Focus the next column, wrapping back to the first column if necessary. + /// + /// Is a no-op if wrapping is not enabled. + fn focus_next_column(&self); + /// Focus the previous column, wrapping back to the last column if necessary. + /// + /// Is a no-op if wrapping is not enabled. + fn focus_previous_column(&self); + /// Focus the row at the given index. + fn focus_row_index(&self, index: usize); + /// Focus the column at the given index. + fn focus_column_index(&self, ix: usize); + /// When reaching the last row, should moving down wrap + /// back to the first row, and vice versa? + fn vertical_wrap(&self) -> bool { + false + } + /// When reaching the last column, should moving right wrap + /// back to the first column, and vice versa? + fn horizontal_wrap(&self) -> bool { + false + } +} + +pub struct NavigationRow {} + +pub struct NavigationColumn {} diff --git a/crates/ui/src/utils.rs b/crates/ui/src/utils.rs index 26a59001f6..bcb4241ea4 100644 --- a/crates/ui/src/utils.rs +++ b/crates/ui/src/utils.rs @@ -10,7 +10,7 @@ mod search_input; mod with_rem_size; pub use color_contrast::*; -pub use corner_solver::{CornerSolver, inner_corner_radius}; +pub use corner_solver::{CornerSolver, NestedCornerSolver}; pub use format_distance::*; pub use search_input::*; pub use with_rem_size::*; diff --git a/crates/ui/src/utils/corner_solver.rs b/crates/ui/src/utils/corner_solver.rs index c49bccc445..38813c303c 100644 --- a/crates/ui/src/utils/corner_solver.rs +++ b/crates/ui/src/utils/corner_solver.rs @@ -1,61 +1,196 @@ use gpui::Pixels; -/// Calculates the child’s content-corner radius for a single nested level. +/// Calculates corner radii for nested elements in both directions. /// -/// child_content_radius = max(0, parent_radius - parent_border - parent_padding + self_border) +/// ## Forward calculation (parent → child) +/// Given a parent's corner radius, calculates the child's corner radius: +/// ``` +/// child_radius = max(0, parent_radius - parent_border - parent_padding + child_border) +/// ``` /// -/// - parent_radius: outer corner radius of the parent element -/// - parent_border: border width of the parent element -/// - parent_padding: padding of the parent element -/// - self_border: border width of this child element (for content inset) -pub fn inner_corner_radius( - parent_radius: Pixels, - parent_border: Pixels, - parent_padding: Pixels, - self_border: Pixels, -) -> Pixels { - (parent_radius - parent_border - parent_padding + self_border).max(Pixels::ZERO) -} - -/// Solver for arbitrarily deep nested corner radii. -/// -/// Each nested level’s outer border-box radius is: -/// R₀ = max(0, root_radius - root_border - root_padding) -/// Rᵢ = max(0, Rᵢ₋₁ - childᵢ₋₁_border - childᵢ₋₁_padding) for i > 0 -pub struct CornerSolver { - root_radius: Pixels, - root_border: Pixels, - root_padding: Pixels, - children: Vec<(Pixels, Pixels)>, // (border, padding) -} +/// ## Inverse calculation (child → parent) +/// Given a child's desired corner radius, calculates the required parent radius: +/// ``` +/// parent_radius = child_radius + parent_border + parent_padding - child_border +/// ``` +pub struct CornerSolver; impl CornerSolver { - pub fn new(root_radius: Pixels, root_border: Pixels, root_padding: Pixels) -> Self { - Self { - root_radius, - root_border, - root_padding, - children: Vec::new(), - } + /// Calculates the child's corner radius given the parent's properties. + /// + /// # Arguments + /// - `parent_radius`: Outer corner radius of the parent element + /// - `parent_border`: Border width of the parent element + /// - `parent_padding`: Padding of the parent element + /// - `child_border`: Border width of the child element + pub fn child_radius( + parent_radius: Pixels, + parent_border: Pixels, + parent_padding: Pixels, + child_border: Pixels, + ) -> Pixels { + (parent_radius - parent_border - parent_padding + child_border).max(Pixels::ZERO) } - pub fn add_child(mut self, border: Pixels, padding: Pixels) -> Self { - self.children.push((border, padding)); + /// Calculates the required parent radius to achieve a desired child radius. + /// + /// # Arguments + /// - `child_radius`: Desired corner radius for the child element + /// - `parent_border`: Border width of the parent element + /// - `parent_padding`: Padding of the parent element + /// - `child_border`: Border width of the child element + pub fn parent_radius( + child_radius: Pixels, + parent_border: Pixels, + parent_padding: Pixels, + child_border: Pixels, + ) -> Pixels { + child_radius + parent_border + parent_padding - child_border + } +} + +/// Builder for calculating corner radii across multiple nested levels. +pub struct NestedCornerSolver { + levels: Vec, +} + +#[derive(Debug, Clone, Copy)] +struct Level { + border: Pixels, + padding: Pixels, +} + +impl NestedCornerSolver { + /// Creates a new nested corner solver. + pub fn new() -> Self { + Self { levels: Vec::new() } + } + + /// Adds a level to the nesting hierarchy. + /// + /// Levels should be added from outermost to innermost. + pub fn add_level(mut self, border: Pixels, padding: Pixels) -> Self { + self.levels.push(Level { border, padding }); self } - pub fn corner_radius(&self, level: usize) -> Pixels { - if level == 0 { - return (self.root_radius - self.root_border - self.root_padding).max(Pixels::ZERO); + /// Calculates the corner radius at a specific nesting level given the root radius. + /// + /// # Arguments + /// - `root_radius`: The outermost corner radius + /// - `level`: The nesting level (0 = first child of root, 1 = grandchild, etc.) + pub fn radius_at_level(&self, root_radius: Pixels, level: usize) -> Pixels { + let mut radius = root_radius; + + for i in 0..=level.min(self.levels.len().saturating_sub(1)) { + let current_level = &self.levels[i]; + let next_border = if i < self.levels.len() - 1 { + self.levels[i + 1].border + } else { + Pixels::ZERO + }; + + radius = CornerSolver::child_radius( + radius, + current_level.border, + current_level.padding, + next_border, + ); } - if level >= self.children.len() { - return Pixels::ZERO; + + radius + } + + /// Calculates the required root radius to achieve a desired radius at a specific level. + /// + /// # Arguments + /// - `target_radius`: The desired corner radius at the target level + /// - `target_level`: The nesting level where the target radius should be achieved + pub fn root_radius_for_level(&self, target_radius: Pixels, target_level: usize) -> Pixels { + if target_level >= self.levels.len() { + return target_radius; } - let mut r = (self.root_radius - self.root_border - self.root_padding).max(Pixels::ZERO); - for i in 0..level { - let (b, p) = self.children[i]; - r = (r - b - p).max(Pixels::ZERO); + + let mut radius = target_radius; + + // Work backwards from the target level to the root + for i in (0..=target_level).rev() { + let current_level = &self.levels[i]; + let child_border = if i < self.levels.len() - 1 { + self.levels[i + 1].border + } else { + Pixels::ZERO + }; + + radius = CornerSolver::parent_radius( + radius, + current_level.border, + current_level.padding, + child_border, + ); } - r + + radius + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_forward_calculation() { + let parent_radius = Pixels(20.0); + let parent_border = Pixels(2.0); + let parent_padding = Pixels(8.0); + let child_border = Pixels(1.0); + + let child_radius = + CornerSolver::child_radius(parent_radius, parent_border, parent_padding, child_border); + + assert_eq!(child_radius, Pixels(11.0)); // 20 - 2 - 8 + 1 = 11 + } + + #[test] + fn test_inverse_calculation() { + let child_radius = Pixels(11.0); + let parent_border = Pixels(2.0); + let parent_padding = Pixels(8.0); + let child_border = Pixels(1.0); + + let parent_radius = + CornerSolver::parent_radius(child_radius, parent_border, parent_padding, child_border); + + assert_eq!(parent_radius, Pixels(20.0)); // 11 + 2 + 8 - 1 = 20 + } + + #[test] + fn test_nested_forward() { + let solver = NestedCornerSolver::new() + .add_level(Pixels(2.0), Pixels(8.0)) // Root level + .add_level(Pixels(1.0), Pixels(4.0)) // First child + .add_level(Pixels(1.0), Pixels(2.0)); // Second child + + let root_radius = Pixels(20.0); + + assert_eq!(solver.radius_at_level(root_radius, 0), Pixels(11.0)); // 20 - 2 - 8 + 1 + assert_eq!(solver.radius_at_level(root_radius, 1), Pixels(7.0)); // 11 - 1 - 4 + 1 + assert_eq!(solver.radius_at_level(root_radius, 2), Pixels(4.0)); // 7 - 1 - 2 + 0 + } + + #[test] + fn test_nested_inverse() { + let solver = NestedCornerSolver::new() + .add_level(Pixels(2.0), Pixels(8.0)) // Root level + .add_level(Pixels(1.0), Pixels(4.0)) // First child + .add_level(Pixels(1.0), Pixels(2.0)); // Second child + + let target_radius = Pixels(4.0); + let root_radius = solver.root_radius_for_level(target_radius, 2); + + assert_eq!(root_radius, Pixels(20.0)); + + // Verify by calculating forward + assert_eq!(solver.radius_at_level(root_radius, 2), target_radius); } } diff --git a/crates/welcome/src/welcome.rs b/crates/welcome/src/welcome.rs index 74d7323d8c..31a285c568 100644 --- a/crates/welcome/src/welcome.rs +++ b/crates/welcome/src/welcome.rs @@ -23,7 +23,6 @@ pub use multibuffer_hint::*; mod base_keymap_picker; mod base_keymap_setting; mod multibuffer_hint; -mod welcome_ui; actions!( welcome, diff --git a/crates/welcome/src/welcome_ui.rs b/crates/welcome/src/welcome_ui.rs deleted file mode 100644 index 622b6f448d..0000000000 --- a/crates/welcome/src/welcome_ui.rs +++ /dev/null @@ -1 +0,0 @@ -mod theme_preview; diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 884443e770..6305947246 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -21,8 +21,8 @@ path = "src/main.rs" [dependencies] activity_indicator.workspace = true agent.workspace = true -agent_ui.workspace = true agent_settings.workspace = true +agent_ui.workspace = true anyhow.workspace = true askpass.workspace = true assets.workspace = true @@ -42,6 +42,7 @@ client.workspace = true collab_ui.workspace = true collections.workspace = true command_palette.workspace = true +command_palette_hooks.workspace = true component.workspace = true copilot.workspace = true dap_adapters.workspace = true @@ -70,7 +71,6 @@ gpui = { workspace = true, features = [ "windows-manifest", ] } gpui_tokio.workspace = true - http_client.workspace = true image_viewer.workspace = true indoc.workspace = true @@ -90,13 +90,13 @@ libc.workspace = true log.workspace = true markdown.workspace = true markdown_preview.workspace = true -svg_preview.workspace = true menu.workspace = true migrator.workspace = true mimalloc = { version = "0.1", optional = true } nix = { workspace = true, features = ["pthread", "signal"] } node_runtime.workspace = true notifications.workspace = true +onboarding_ui.workspace = true outline.workspace = true outline_panel.workspace = true parking_lot.workspace = true @@ -125,6 +125,7 @@ smol.workspace = true snippet_provider.workspace = true snippets_ui.workspace = true supermaven.workspace = true +svg_preview.workspace = true sysinfo.workspace = true tab_switcher.workspace = true task.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 89d9c2edf1..8476ccfb62 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -583,6 +583,7 @@ pub fn main() { collab_ui::init(&app_state, cx); git_ui::init(cx); jj_ui::init(cx); + onboarding_ui::init(cx); feedback::init(cx); markdown_preview::init(cx); svg_preview::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 10fdcf34a6..ac82e5a040 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -16,6 +16,7 @@ use assets::Assets; use breadcrumbs::Breadcrumbs; use client::zed_urls; use collections::VecDeque; +use command_palette_hooks::CommandPaletteFilter; use debugger_ui::debugger_panel::DebugPanel; use editor::ProposedChangesEditorToolbar; use editor::{Editor, MultiBuffer}; @@ -52,6 +53,7 @@ use settings::{ Settings, SettingsStore, VIM_KEYMAP_PATH, initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content, update_settings_file, }; +use std::any::TypeId; use std::path::PathBuf; use std::sync::atomic::{self, AtomicBool}; use std::{borrow::Cow, path::Path, sync::Arc}; @@ -72,7 +74,8 @@ use workspace::{ use workspace::{CloseIntent, CloseWindow, RestoreBanner, with_active_or_new_workspace}; use workspace::{Pane, notifications::DetachAndPromptErr}; use zed_actions::{ - OpenAccountSettings, OpenBrowser, OpenDocs, OpenServerSettings, OpenSettings, OpenZedUrl, Quit, + DisableAiAssistance, EnableAiAssistance, OpenAccountSettings, OpenBrowser, OpenDocs, + OpenServerSettings, OpenSettings, OpenZedUrl, Quit, }; actions!( @@ -215,6 +218,35 @@ pub fn init(cx: &mut App) { ); }); }); + cx.on_action(|_: &EnableAiAssistance, cx| { + with_active_or_new_workspace(cx, |workspace, _, cx| { + let fs = workspace.app_state().fs.clone(); + language::language_settings::AllLanguageSettings::set_ai_assistance(true, fs, cx); + }); + }); + cx.on_action(|_: &DisableAiAssistance, cx| { + with_active_or_new_workspace(cx, |workspace, _, cx| { + let fs = workspace.app_state().fs.clone(); + language::language_settings::AllLanguageSettings::set_ai_assistance(false, fs, cx); + }); + }); + + // Filter AI assistance actions based on current state + cx.observe_global::(move |cx| { + let ai_enabled = + language::language_settings::all_language_settings(None, cx).is_ai_assistance_enabled(); + + CommandPaletteFilter::update_global(cx, |filter, _cx| { + if ai_enabled { + filter.hide_action_types(&[TypeId::of::()]); + filter.show_action_types([TypeId::of::()].iter()); + } else { + filter.show_action_types([TypeId::of::()].iter()); + filter.hide_action_types(&[TypeId::of::()]); + } + }); + }) + .detach(); } fn bind_on_window_closed(cx: &mut App) -> Option { diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index ffe232ad7b..1daba076b5 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -50,6 +50,10 @@ actions!( OpenLicenses, /// Opens the telemetry log. OpenTelemetryLog, + /// Enables AI assistance features. + EnableAiAssistance, + /// Disables AI assistance features. + DisableAiAssistance, ] );