diff --git a/src/app/desktop.rs b/src/app/desktop.rs index a2ec3d8..9c653b8 100644 --- a/src/app/desktop.rs +++ b/src/app/desktop.rs @@ -23,7 +23,6 @@ use crate::shared::theme::{OpenCoreTheme, ThemeTransition, apply_nothing_theme}; use super::AppError; #[cfg(debug_assertions)] use super::dev_reset::{DevResetCallbacks, DevResetState, dev_reset_fab}; -use super::hero::{HeroTransition, brand_width, opencore_brand_image, responsive_brand_height}; use super::shell::{DockSaveFn, ShellWorkspace, register_shell_panels}; use super::state::{ActiveScreen, AppState}; use super::viewport::WindowViewport; @@ -101,7 +100,7 @@ pub struct OpenCoreApp { _shutdown_subscription: gpui::Subscription, _window_closed_subscription: gpui::Subscription, theme_transition: Option, - hero_transition: Option, + welcome_callbacks: Option, persistence_error: Option, #[cfg(debug_assertions)] dev_reset_state: DevResetState, @@ -152,7 +151,7 @@ impl OpenCoreApp { _shutdown_subscription: shutdown_subscription, _window_closed_subscription: window_closed_subscription, theme_transition: None, - hero_transition: None, + welcome_callbacks: None, persistence_error: None, #[cfg(debug_assertions)] dev_reset_state: DevResetState::default(), @@ -190,7 +189,17 @@ impl OpenCoreApp { cx.notify(); } + fn welcome_input_enabled(&self, now: Instant) -> bool { + self.welcome_ui + .as_ref() + .is_none_or(|ui| ui.accepts_enter(now)) + } + fn ensure_welcome_focus(&mut self, window: &mut Window, cx: &mut Context) { + let now = Instant::now(); + if !self.welcome_input_enabled(now) { + return; + } if let Some(ui) = self.welcome_ui.as_mut() { ui.ensure_initial_focus(window, &self.focus_handle, cx); } @@ -263,43 +272,14 @@ impl OpenCoreApp { shell } - fn settle_hero_transition(&mut self, now: Instant) -> bool { - if self.hero_transition.is_some_and(|tx| !tx.is_active(now)) { - self.hero_transition = None; - if self.state.active_screen == ActiveScreen::Welcome { - self.state.finish_welcome_transition(); - self.welcome_ui = None; - return true; - } - } - false - } - - fn start_hero_transition(&mut self, viewport: WindowViewport) -> Result<(), PreferencesError> { - if self.hero_transition.is_some() { - return Ok(()); - } - let now = Instant::now(); - let hero_height = responsive_brand_height(viewport); - self.hero_transition = Some(HeroTransition::start(now, viewport, hero_height)); - match self.state.persist_welcome_completion(self.store.as_ref()) { - Ok(()) => Ok(()), - Err(error) => { - self.hero_transition = None; - Err(error) - } - } - } - - fn begin_hero_transition(&mut self, window: &mut Window, cx: &mut Context) { - let viewport = WindowViewport::from_window(window); - match self.start_hero_transition(viewport) { + fn complete_welcome(&mut self, window: &mut Window, cx: &mut Context) { + match self.state.complete_welcome(self.store.as_ref()) { Ok(()) => { self.persistence_error = None; + self.welcome_ui = None; self.finish_screen_transition(window, cx); } Err(error) => { - self.hero_transition = None; self.record_persistence_error("persist welcome completion", error); cx.notify(); } @@ -312,14 +292,20 @@ impl OpenCoreApp { window: &mut Window, cx: &mut Context, ) { + if !self.welcome_input_enabled(Instant::now()) { + return; + } match reduce_welcome(command) { WelcomeOutcome::Pending => {} - WelcomeOutcome::Completed => self.begin_hero_transition(window, cx), + WelcomeOutcome::Completed => self.complete_welcome(window, cx), } } fn toggle_theme(&mut self, cx: &mut Context) { let now = Instant::now(); + if !self.welcome_input_enabled(now) { + return; + } let from = self.state.theme_mode(); let next = from.toggle(); match self.state.set_theme_mode(self.store.as_ref(), next) { @@ -366,7 +352,6 @@ impl OpenCoreApp { self.pending_shell_save.borrow_mut().clear(); self.shell = None; self.welcome_ui = Some(WelcomeUiState::new()); - self.hero_transition = None; self.persistence_error = None; Ok(()) } @@ -520,40 +505,44 @@ impl Render for OpenCoreApp { let now = Instant::now(); self.settle_theme_transition(now); - if self.settle_hero_transition(now) { - cx.notify(); - } let theme = self.visual_theme(now); - let transition_progress = self - .hero_transition - .map(|tx| tx.linear_progress(now)) - .unwrap_or(0.0); + let welcome_intro_animating = if self.state.active_screen == ActiveScreen::Welcome { + self.welcome_ui + .get_or_insert_with(WelcomeUiState::new) + .tick(now) + } else { + false + }; if should_request_animation_frame( - self.hero_transition.as_ref(), self.theme_transition.as_ref(), + welcome_intro_animating, now, ) { window.request_animation_frame(); } - let welcome_content_opacity = self - .hero_transition - .map(|_| HeroTransition::content_opacity(transition_progress)) - .unwrap_or(1.0); - let shell_brand_opacity = HeroTransition::shell_brand_opacity( - self.hero_transition - .map(|_| transition_progress) - .unwrap_or(1.0), - ); - let content = match self.state.active_screen { ActiveScreen::Welcome => { - let _ = self.welcome_ui.get_or_insert_with(WelcomeUiState::new); - let ui = self.welcome_ui.as_ref().expect("inserted"); - let callbacks = WelcomeCallbacks::from_app(cx.entity().downgrade()); + let accepts_enter = self + .welcome_ui + .as_ref() + .expect("welcome ui initialized for intro tick") + .accepts_enter(now); + let ui = self + .welcome_ui + .as_mut() + .expect("welcome ui initialized for intro tick"); + if accepts_enter { + ui.ensure_initial_focus(window, &self.focus_handle, cx); + } + let callbacks = self + .welcome_callbacks + .get_or_insert_with(|| WelcomeCallbacks::from_app(cx.entity().downgrade())) + .clone(); let persistence_error = self.persistence_error.as_deref(); let on_enter = callbacks.on_enter.clone(); + let reveal_progress = ui.reveal_progress(now); div() .size_full() @@ -561,14 +550,14 @@ impl Render for OpenCoreApp { .min_h_0() .child(welcome_interactive_root( &self.focus_handle, + accepts_enter, on_enter, welcome_screen( theme, - ui, + reveal_progress, callbacks, persistence_error, WindowViewport::from_window(window), - welcome_content_opacity, ), )) } @@ -576,27 +565,13 @@ impl Render for OpenCoreApp { let shell = self.ensure_shell(window, cx); shell.update(cx, |shell, cx| { shell.set_theme(theme, cx); - shell.set_brand_chrome(shell_brand_opacity, cx); + shell.set_brand_chrome(1.0, cx); }); div().size_full().min_w_0().min_h_0().child(shell) } }; - let mut root = div().size_full().relative().child(content); - - if let Some(transition) = self.hero_transition - && transition.is_active(now) - { - let (center_x, center_y, height) = transition.layout_at(now); - let width = brand_width(height); - root = root.child( - div() - .absolute() - .left(px(center_x - width * 0.5)) - .top(px(center_y - height * 0.5)) - .child(opencore_brand_image(theme, height, 1.0)), - ); - } + let root = div().size_full().relative().child(content); #[cfg(debug_assertions)] { @@ -629,12 +604,11 @@ impl Render for OpenCoreApp { } fn should_request_animation_frame( - hero_transition: Option<&HeroTransition>, theme_transition: Option<&ThemeTransition>, + welcome_intro_animating: bool, now: Instant, ) -> bool { - hero_transition.is_some_and(|tx| tx.is_active(now)) - || theme_transition.is_some_and(|tx| tx.is_active(now)) + welcome_intro_animating || theme_transition.is_some_and(|tx| tx.is_active(now)) } fn window_bounds_for_state(state: &AppState, cx: &App) -> WindowBounds { @@ -734,28 +708,102 @@ mod tests { } } +#[cfg(test)] +mod welcome_intro_gating_tests { + use super::*; + use crate::shared::preferences::AppPreferences; + use gpui::{AppContext, TestAppContext, VisualContext}; + use std::sync::Arc; + use tempfile::TempDir; + + fn welcome_app( + cx: &mut TestAppContext, + store: Arc, + ) -> (gpui::Entity, &mut gpui::VisualTestContext) { + cx.update(gpui_component::init); + cx.add_window_view(|_window, cx| { + OpenCoreApp::new( + AppState::from_preferences(AppPreferences::default()), + store, + cx, + ) + }) + } + + #[gpui::test] + fn enter_blocked_during_intro_reveal(cx: &mut TestAppContext) { + let _dir = TempDir::new().expect("temp dir"); + let store = Arc::new(FilePreferencesStore::at( + _dir.path().join("preferences.json"), + )); + let start = Instant::now(); + let (app, cx) = welcome_app(cx, store); + + cx.update_window_entity(&app, |app, window, entity_cx| { + app.welcome_ui.as_mut().expect("welcome ui").tick(start); + app.apply_welcome_command(WelcomeCommand::EnterPressed, window, entity_cx); + }); + + cx.read_entity(&app, |app, _| { + assert_eq!(app.state.active_screen, ActiveScreen::Welcome); + assert!(!app.state.preferences.onboarding_completed); + }); + } + + #[gpui::test] + fn toggle_theme_blocked_during_intro_reveal(cx: &mut TestAppContext) { + let _dir = TempDir::new().expect("temp dir"); + let store = Arc::new(FilePreferencesStore::at( + _dir.path().join("preferences.json"), + )); + let start = Instant::now(); + let (app, cx) = welcome_app(cx, store); + let theme_before = cx.read_entity(&app, |app, _| app.state.theme_mode()); + + cx.update_window_entity(&app, |app, _window, entity_cx| { + app.welcome_ui.as_mut().expect("welcome ui").tick(start); + app.toggle_theme(entity_cx); + }); + + cx.read_entity(&app, |app, _| { + assert_eq!(app.state.theme_mode(), theme_before); + }); + } + + #[gpui::test] + fn enter_enabled_after_intro_reveal(cx: &mut TestAppContext) { + let _dir = TempDir::new().expect("temp dir"); + let store = Arc::new(FilePreferencesStore::at( + _dir.path().join("preferences.json"), + )); + let (app, cx) = welcome_app(cx, store); + + cx.update_window_entity(&app, |app, _window, _entity_cx| { + app.welcome_ui = Some(WelcomeUiState::new()); + assert!(!app.welcome_input_enabled(Instant::now())); + app.welcome_ui + .as_mut() + .expect("welcome ui") + .complete_intro_for_test(); + assert!(app.welcome_input_enabled(Instant::now())); + }); + } +} + #[cfg(test)] mod animation_gate_tests { use super::*; #[test] - fn hero_animation_gate_follows_active_transition() { + fn welcome_intro_requests_animation_frames() { let now = Instant::now(); - assert!(!should_request_animation_frame(None, None, now)); - let tx = HeroTransition::start( - now, - WindowViewport { - width: 960.0, - height: 740.0, - }, - 52.0, - ); - assert!(should_request_animation_frame(Some(&tx), None, now)); - assert!(!should_request_animation_frame( - Some(&tx), - None, - now + super::super::hero::HERO_TRANSITION_DURATION - )); + assert!(should_request_animation_frame(None, true, now)); + } + + #[test] + fn welcome_intro_stops_requesting_frames() { + let now = Instant::now(); + assert!(!should_request_animation_frame(None, false, now)); } #[test] @@ -766,13 +814,13 @@ mod animation_gate_tests { crate::shared::theme::ThemeMode::Light, now, ); - assert!(should_request_animation_frame(None, Some(&tx), now)); + assert!(should_request_animation_frame(Some(&tx), false, now)); assert!(!should_request_animation_frame( - None, Some(&tx), + false, now + crate::shared::theme::THEME_TRANSITION_DURATION )); - assert!(!should_request_animation_frame(None, None, now)); + assert!(!should_request_animation_frame(None, false, now)); } } @@ -904,128 +952,6 @@ mod dock_layout_persistence_tests { } } -#[cfg(test)] -mod hero_transition_tests { - use super::*; - use crate::shared::preferences::AppPreferences; - use gpui::{AppContext, TestAppContext}; - use std::time::Duration; - use tempfile::TempDir; - - const WELCOME_VIEWPORT: WindowViewport = WindowViewport { - width: 960.0, - height: 740.0, - }; - - #[gpui::test] - fn enter_sets_hero_transition_and_defers_home_routing(cx: &mut TestAppContext) { - let dir = TempDir::new().expect("temp dir"); - let store = Arc::new(FilePreferencesStore::at( - dir.path().join("preferences.json"), - )); - let app = cx.new(|cx| { - OpenCoreApp::new( - AppState::from_preferences(AppPreferences::default()), - store.clone(), - cx, - ) - }); - - app.update(cx, |app, cx| { - app.start_hero_transition(WELCOME_VIEWPORT) - .expect("start hero transition"); - cx.notify(); - }); - - cx.read_entity(&app, |app, _| { - assert!(app.hero_transition.is_some()); - assert_eq!(app.state.active_screen, ActiveScreen::Welcome); - assert!(app.state.preferences.onboarding_completed); - assert!(app.welcome_ui.is_some()); - }); - let saved = store.load().expect("load"); - assert!(saved.onboarding_completed); - - let done = Instant::now() + super::super::hero::HERO_TRANSITION_DURATION; - app.update(cx, |app, cx| { - assert!(app.settle_hero_transition(done)); - cx.notify(); - }); - - cx.read_entity(&app, |app, _| { - assert!(app.hero_transition.is_none()); - assert_eq!(app.state.active_screen, ActiveScreen::Home); - assert!(app.welcome_ui.is_none()); - }); - } - - #[gpui::test] - fn persistence_failure_clears_hero_transition_without_routing_home(cx: &mut TestAppContext) { - let dir = TempDir::new().expect("temp dir"); - let store = Arc::new(FilePreferencesStore::at(dir.path())); - let app = cx.new(|cx| { - OpenCoreApp::new( - AppState::from_preferences(AppPreferences::default()), - store, - cx, - ) - }); - - app.update(cx, |app, cx| { - assert!(app.start_hero_transition(WELCOME_VIEWPORT).is_err()); - cx.notify(); - }); - - cx.read_entity(&app, |app, _| { - assert!(app.hero_transition.is_none()); - assert_eq!(app.state.active_screen, ActiveScreen::Welcome); - assert!(!app.state.preferences.onboarding_completed); - }); - } - - #[gpui::test] - fn double_enter_does_not_restart_hero_transition(cx: &mut TestAppContext) { - let dir = TempDir::new().expect("temp dir"); - let store = Arc::new(FilePreferencesStore::at( - dir.path().join("preferences.json"), - )); - let app = cx.new(|cx| { - OpenCoreApp::new( - AppState::from_preferences(AppPreferences::default()), - store, - cx, - ) - }); - - app.update(cx, |app, cx| { - app.start_hero_transition(WELCOME_VIEWPORT) - .expect("first enter"); - cx.notify(); - }); - cx.executor().advance_clock(Duration::from_millis(400)); - - let progress_before_second_enter = cx.read_entity(&app, |app, _| { - app.hero_transition - .map(|tx| tx.linear_progress(Instant::now())) - .expect("hero transition active") - }); - - app.update(cx, |app, cx| { - app.start_hero_transition(WELCOME_VIEWPORT) - .expect("second enter is ignored"); - cx.notify(); - }); - cx.executor().advance_clock(Duration::from_millis(100)); - - let progress_after_second_enter = cx.read_entity(&app, |app, _| { - app.hero_transition - .map(|tx| tx.linear_progress(Instant::now())) - .expect("hero transition still active") - }); - assert!(progress_after_second_enter > progress_before_second_enter); - } -} - #[cfg(all(test, debug_assertions))] mod reset_tests { use super::*; diff --git a/src/app/hero/layout.rs b/src/app/hero/layout.rs index da395f8..b1c4832 100644 --- a/src/app/hero/layout.rs +++ b/src/app/hero/layout.rs @@ -1,9 +1,8 @@ //! Hero layout math — welcome center, docked title-bar slot (layout A). -use super::brand::{BRAND_ASPECT, brand_width}; -use crate::app::state::{HOME_WINDOW_HEIGHT, HOME_WINDOW_WIDTH}; +use super::brand::BRAND_ASPECT; use crate::app::viewport::WindowViewport; -use gpui_component::TITLE_BAR_HEIGHT; +use crate::shared::theme::TypeRole; pub const BRAND_HERO_MIN: f32 = 220.0; pub const BRAND_HERO_MAX: f32 = 320.0; @@ -11,12 +10,22 @@ pub const BRAND_HERO_MAX: f32 = 320.0; pub const BRAND_SHELL_HEIGHT: f32 = 18.0; /// Ghost xsmall icon button width in the shell title bar. +#[allow(dead_code)] pub const SHELL_TOGGLE_WIDTH: f32 = 28.0; +#[allow(dead_code)] pub const SHELL_TITLE_GAP: f32 = 4.0; -const WELCOME_EDGE_INSET_H: f32 = 16.0; -const WELCOME_HEADER_BAND: f32 = 46.0; +pub const WELCOME_EDGE_INSET_H: f32 = 16.0; +pub const WELCOME_TITLEBAR_HEIGHT: f32 = 38.0; +pub const WELCOME_EDGE_INSET_TOP: f32 = 4.0; +pub const WELCOME_EDGE_INSET_BOTTOM: f32 = 20.0; +pub const WELCOME_HERO_BRAND_FRAME_EXTRA: f32 = 40.0; +pub const WELCOME_ACTION_SPACER: f32 = 60.0; +pub const WELCOME_ENTER_BUTTON_HEIGHT: f32 = 48.0; + const WELCOME_ACTION_BAND: f32 = 260.0; +const WELCOME_HEADER_GAP: f32 = 8.0; +const WELCOME_THEME_TOGGLE_HEIGHT: f32 = 32.0; /// macOS traffic-light inset matches gpui-component `TITLE_BAR_LEFT_PADDING`. pub fn title_bar_left_padding() -> f32 { @@ -37,13 +46,23 @@ pub fn responsive_hero_size(available_width: f32, available_height: f32) -> f32 width_limit.min(height_limit).min(BRAND_HERO_MAX) } -/// Center of the large welcome brand in window coordinates. -pub fn welcome_brand_center(viewport: WindowViewport) -> (f32, f32) { - let hero_height = responsive_brand_height(viewport); - let content_top = WELCOME_HEADER_BAND; - let content_height = (viewport.height - content_top - WELCOME_ACTION_BAND).max(hero_height); - let center_y = content_top + content_height * 0.5; - (viewport.width * 0.5, center_y) +/// Height of the welcome header row (title + subtitle). +pub fn welcome_header_row_height() -> f32 { + let text_column = TypeRole::LabelMd.size() * TypeRole::LabelMd.line_height() + + 2.0 + + TypeRole::MonoSm.size() * TypeRole::MonoSm.line_height(); + text_column.max(WELCOME_THEME_TOGGLE_HEIGHT) +} + +/// Vertical space available for the centered brand frame and copy column. +pub fn welcome_vertical_content_budget(viewport: WindowViewport) -> f32 { + (viewport.height + - WELCOME_EDGE_INSET_TOP + - WELCOME_EDGE_INSET_BOTTOM + - welcome_header_row_height() + - WELCOME_HEADER_GAP + - WELCOME_ACTION_BAND) + .max(0.0) } /// Responsive welcome brand height. @@ -55,30 +74,45 @@ pub fn responsive_brand_height(viewport: WindowViewport) -> f32 { .min(BRAND_HERO_MAX / BRAND_ASPECT) } -/// Center of the docked brand: `[toggle-left] [brand lockup]` (layout A). -pub fn docked_brand_center(viewport: WindowViewport) -> (f32, f32) { - let title_h = TITLE_BAR_HEIGHT.as_f32(); - let width = brand_width(BRAND_SHELL_HEIGHT); - let x = title_bar_left_padding() + SHELL_TOGGLE_WIDTH + SHELL_TITLE_GAP + width * 0.5; - let _ = viewport; - (x, title_h * 0.5) +/// Large centered brand height during the show-off phase. +/// +/// Unlike the old wireframe cube (square), the brand lockup is very wide +/// (`BRAND_ASPECT`), so height must be derived from available width. +pub fn show_off_brand_height(viewport: WindowViewport) -> f32 { + let hero = responsive_brand_height(viewport); + let width_limit = (viewport.width - WELCOME_EDGE_INSET_H * 2.0) / BRAND_ASPECT; + let available_height = welcome_vertical_content_budget(viewport); + // Prominent intro size that still fits the viewport; morphs down to `hero`. + width_limit.min(available_height * 0.4).max(hero) } -/// Viewport used to compute the docked slot after welcome completes. -pub fn home_transition_viewport() -> WindowViewport { - WindowViewport { - width: HOME_WINDOW_WIDTH as f32, - height: HOME_WINDOW_HEIGHT as f32, - } +/// Linear interpolation between two values. +pub fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t } #[cfg(test)] mod tests { use super::*; + use crate::app::hero::brand::brand_width; + use gpui_component::TITLE_BAR_HEIGHT; + + /// Center of the docked brand: `[toggle-left] [brand lockup]` (layout A). + fn docked_brand_center(viewport: WindowViewport) -> (f32, f32) { + let title_h = TITLE_BAR_HEIGHT.as_f32(); + let width = brand_width(BRAND_SHELL_HEIGHT); + let x = title_bar_left_padding() + SHELL_TOGGLE_WIDTH + SHELL_TITLE_GAP + width * 0.5; + let _ = viewport; + (x, title_h * 0.5) + } #[test] fn docked_brand_sits_after_left_toggle() { - let (x, y) = docked_brand_center(home_transition_viewport()); + let viewport = WindowViewport { + width: 1280.0, + height: 800.0, + }; + let (x, y) = docked_brand_center(viewport); let width = brand_width(BRAND_SHELL_HEIGHT); let expected_x = title_bar_left_padding() + SHELL_TOGGLE_WIDTH + SHELL_TITLE_GAP + width * 0.5; @@ -91,4 +125,62 @@ mod tests { assert_eq!(responsive_hero_size(440.0, 360.0), BRAND_HERO_MIN); assert_eq!(responsive_hero_size(1200.0, 900.0), BRAND_HERO_MAX); } + + #[test] + fn show_off_brand_fits_within_viewport_width() { + let viewport = WindowViewport { + width: 960.0, + height: 740.0, + }; + let height = show_off_brand_height(viewport); + let width = brand_width(height); + assert!(width <= viewport.width - WELCOME_EDGE_INSET_H * 2.0 + 1.0); + assert!(height >= responsive_brand_height(viewport)); + } + + #[test] + fn show_off_brand_fits_narrow_viewport() { + let viewport = WindowViewport { + width: 440.0, + height: 360.0, + }; + let height = show_off_brand_height(viewport); + let width = brand_width(height); + assert!(width <= viewport.width - WELCOME_EDGE_INSET_H * 2.0 + 1.0); + assert!(height >= responsive_brand_height(viewport)); + } + + #[test] + fn show_off_brand_settles_to_resting_height() { + let viewport = WindowViewport { + width: 960.0, + height: 740.0, + }; + let hero = responsive_brand_height(viewport); + let show_off = show_off_brand_height(viewport); + let settled = lerp_f32(show_off, hero, 1.0); + assert!((settled - hero).abs() < 1e-3); + } + + #[test] + fn show_off_brand_fits_within_viewport_height() { + let viewport = WindowViewport { + width: 960.0, + height: 740.0, + }; + let height = show_off_brand_height(viewport); + let available = welcome_vertical_content_budget(viewport); + assert!(height + WELCOME_HERO_BRAND_FRAME_EXTRA <= available + 1.0); + } + + #[test] + fn show_off_brand_fits_short_viewport_height() { + let viewport = WindowViewport { + width: 960.0, + height: 500.0, + }; + let height = show_off_brand_height(viewport); + let available = welcome_vertical_content_budget(viewport); + assert!(height + WELCOME_HERO_BRAND_FRAME_EXTRA <= available + 1.0); + } } diff --git a/src/app/hero/mod.rs b/src/app/hero/mod.rs index 2b45db8..7b3be0b 100644 --- a/src/app/hero/mod.rs +++ b/src/app/hero/mod.rs @@ -2,13 +2,14 @@ mod brand; mod layout; -mod transition; pub use brand::{ BRAND_ASPECT, BRAND_IMAGE, BRAND_IMAGE_INVERSE, brand_width, opencore_brand_image, }; pub use layout::{ - BRAND_HERO_MAX, BRAND_HERO_MIN, BRAND_SHELL_HEIGHT, docked_brand_center, - responsive_brand_height, responsive_hero_size, title_bar_left_padding, welcome_brand_center, + BRAND_HERO_MAX, BRAND_HERO_MIN, BRAND_SHELL_HEIGHT, WELCOME_ACTION_SPACER, + WELCOME_EDGE_INSET_BOTTOM, WELCOME_EDGE_INSET_H, WELCOME_EDGE_INSET_TOP, + WELCOME_ENTER_BUTTON_HEIGHT, WELCOME_HERO_BRAND_FRAME_EXTRA, WELCOME_TITLEBAR_HEIGHT, lerp_f32, + responsive_brand_height, responsive_hero_size, show_off_brand_height, title_bar_left_padding, + welcome_vertical_content_budget, }; -pub use transition::{HERO_TRANSITION_DURATION, HeroTransition}; diff --git a/src/app/hero/transition.rs b/src/app/hero/transition.rs deleted file mode 100644 index 89247b1..0000000 --- a/src/app/hero/transition.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! Big-to-small brand hero transition (iOS onboarding port). - -use std::time::{Duration, Instant}; - -use super::layout::{ - BRAND_SHELL_HEIGHT, docked_brand_center, home_transition_viewport, welcome_brand_center, -}; -use crate::app::viewport::WindowViewport; - -/// Hero morph duration — matches iOS `smooth(duration: 1.02)`. -pub const HERO_TRANSITION_DURATION: Duration = Duration::from_millis(1020); - -const MORPH_START: f32 = 0.54; - -#[derive(Debug, Clone, Copy)] -pub struct HeroTransition { - started_at: Instant, - start_center: (f32, f32), - start_size: f32, - end_center: (f32, f32), - end_size: f32, -} - -impl HeroTransition { - pub fn start(now: Instant, welcome_viewport: WindowViewport, hero_size: f32) -> Self { - let start_center = welcome_brand_center(welcome_viewport); - let end_center = docked_brand_center(home_transition_viewport()); - Self { - started_at: now, - start_center, - start_size: hero_size, - end_center, - end_size: BRAND_SHELL_HEIGHT, - } - } - - pub fn linear_progress(&self, now: Instant) -> f32 { - let total = HERO_TRANSITION_DURATION.as_secs_f32(); - if total <= 0.0 { - return 1.0; - } - (now.saturating_duration_since(self.started_at).as_secs_f32() / total).clamp(0.0, 1.0) - } - - pub fn is_active(&self, now: Instant) -> bool { - self.linear_progress(now) < 1.0 - } - - pub fn morph_progress(transition: f32) -> f32 { - let span = (1.0 - MORPH_START).max(0.001); - let raw = ((transition - MORPH_START) / span).clamp(0.0, 1.0); - 1.0 - (1.0 - raw).powi(3) - } - - /// Window-space center and size at `now`. - pub fn layout_at(&self, now: Instant) -> (f32, f32, f32) { - let transition = self.linear_progress(now); - let morph = Self::morph_progress(transition); - let cx = lerp(self.start_center.0, self.end_center.0, morph); - let cy = lerp(self.start_center.1, self.end_center.1, morph); - let size = lerp(self.start_size, self.end_size, morph); - (cx, cy, size) - } - - /// Fades welcome chrome out during the first third of the transition. - pub fn content_opacity(transition: f32) -> f32 { - (1.0 - (transition / 0.35).clamp(0.0, 1.0)).max(0.0) - } - - pub fn shell_brand_opacity(transition: f32) -> f32 { - if transition >= 1.0 { - 1.0 - } else { - ((transition - 0.72) / 0.28).clamp(0.0, 1.0) - } - } -} - -fn lerp(a: f32, b: f32, t: f32) -> f32 { - a + (b - a) * t -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn content_opacity_visible_before_transition() { - assert!((HeroTransition::content_opacity(0.0) - 1.0).abs() < 1e-3); - assert!(HeroTransition::content_opacity(1.0) < 0.01); - } - - #[test] - fn morph_reaches_completion_at_end() { - assert!(HeroTransition::morph_progress(0.38) < 0.01); - assert!(HeroTransition::morph_progress(1.0) >= 0.99); - } - - #[test] - fn transition_endpoints() { - let now = Instant::now(); - let tx = HeroTransition::start( - now, - WindowViewport { - width: 960.0, - height: 740.0, - }, - 220.0, - ); - let (sx, sy, ss) = tx.layout_at(now); - assert!((sx - tx.start_center.0).abs() < 1e-3); - assert!((sy - tx.start_center.1).abs() < 1e-3); - assert!((ss - 220.0).abs() < 1e-3); - - let done = now + HERO_TRANSITION_DURATION; - let (ex, ey, es) = tx.layout_at(done); - assert!((ex - tx.end_center.0).abs() < 1e-3); - assert!((ey - tx.end_center.1).abs() < 1e-3); - assert!((es - BRAND_SHELL_HEIGHT).abs() < 1e-3); - } -} diff --git a/src/app/mod.rs b/src/app/mod.rs index 1c3a802..edc92f0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -102,21 +102,6 @@ mod tests { assert_eq!(state.active_screen, ActiveScreen::Welcome); } - #[test] - fn persist_welcome_completion_defers_screen_routing() { - let store = InMemoryPreferencesStore::new(); - let mut state = AppState::from_preferences(AppPreferences::default()); - state - .persist_welcome_completion(&store) - .expect("persist welcome completion"); - - assert!(state.preferences.onboarding_completed); - assert_eq!(state.active_screen, ActiveScreen::Welcome); - let intent = state.pending_window_resize.expect("resize intent recorded"); - assert_eq!(intent.width, HOME_WINDOW_WIDTH); - assert_eq!(intent.height, HOME_WINDOW_HEIGHT); - } - #[test] fn completing_onboarding_persists_and_routes_to_home() { let store = InMemoryPreferencesStore::new(); diff --git a/src/app/state.rs b/src/app/state.rs index c430d93..2a4b378 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -88,27 +88,6 @@ impl AppState { Ok(()) } - /// Persists onboarding completion and queues the home resize without routing. - pub fn persist_welcome_completion( - &mut self, - store: &S, - ) -> Result<(), PreferencesError> { - let mut updated = self.preferences.clone(); - updated.onboarding_completed = true; - store.save(&updated)?; - self.preferences = updated; - self.pending_window_resize = Some(WindowResizeIntent { - width: HOME_WINDOW_WIDTH, - height: HOME_WINDOW_HEIGHT, - }); - Ok(()) - } - - /// Routes to home after the welcome hero transition finishes. - pub fn finish_welcome_transition(&mut self) { - self.active_screen = ActiveScreen::Home; - } - /// Applies a reducer outcome: persist and route when completed. pub fn apply_welcome_outcome( &mut self, @@ -118,8 +97,15 @@ impl AppState { match outcome { WelcomeOutcome::Pending => {} WelcomeOutcome::Completed => { - self.persist_welcome_completion(store)?; - self.finish_welcome_transition(); + let mut updated = self.preferences.clone(); + updated.onboarding_completed = true; + store.save(&updated)?; + self.preferences = updated; + self.pending_window_resize = Some(WindowResizeIntent { + width: HOME_WINDOW_WIDTH, + height: HOME_WINDOW_HEIGHT, + }); + self.active_screen = ActiveScreen::Home; } } Ok(()) diff --git a/src/app/welcome/ui_state.rs b/src/app/welcome/ui_state.rs index 68d3983..5405337 100644 --- a/src/app/welcome/ui_state.rs +++ b/src/app/welcome/ui_state.rs @@ -1,18 +1,64 @@ -//! Interactive welcome UI state (keyboard focus). +//! Interactive welcome UI state (keyboard focus, intro animation). + +use std::time::{Duration, Instant}; use gpui::{App, FocusHandle, Window}; +/// Brand morph + header, copy, and CTA fade-in on welcome load. +pub const CHROME_REVEAL_DURATION: Duration = Duration::from_millis(800); + pub struct WelcomeUiState { focus_claimed: bool, + started_at: Option, } impl WelcomeUiState { pub fn new() -> Self { Self { focus_claimed: false, + started_at: None, + } + } + + /// Advances intro timing and returns true while the reveal animation is active. + pub fn tick(&mut self, now: Instant) -> bool { + self.started_at.get_or_insert(now); + self.intro_animating(now) + } + + pub fn intro_animating(&self, now: Instant) -> bool { + match self.started_at { + None => true, + Some(started_at) => now.saturating_duration_since(started_at) < CHROME_REVEAL_DURATION, } } + pub fn reveal_progress(&self, now: Instant) -> f32 { + let Some(started_at) = self.started_at else { + return 0.0; + }; + let elapsed = now.saturating_duration_since(started_at).as_secs_f32(); + let duration = CHROME_REVEAL_DURATION.as_secs_f32(); + if duration <= 0.0 { + return 1.0; + } + let t = (elapsed / duration).clamp(0.0, 1.0); + 1.0 - (1.0 - t).powi(3) + } + + pub fn accepts_enter(&self, now: Instant) -> bool { + !self.intro_animating(now) + } + + #[cfg(test)] + pub fn complete_intro_for_test(&mut self) { + self.started_at = Some( + Instant::now() + .checked_sub(CHROME_REVEAL_DURATION + Duration::from_millis(1)) + .expect("recent instant"), + ); + } + /// Requests keyboard focus once per welcome session. pub fn ensure_initial_focus( &mut self, @@ -30,3 +76,50 @@ impl WelcomeUiState { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reveal_progress_eases_in_from_start() { + let start = Instant::now(); + let ui = WelcomeUiState { + focus_claimed: false, + started_at: Some(start), + }; + assert!((ui.reveal_progress(start) - 0.0).abs() < 1e-3); + assert!(ui.reveal_progress(start + CHROME_REVEAL_DURATION) >= 0.99); + } + + #[test] + fn intro_animating_only_during_reveal() { + let start = Instant::now(); + let ui = WelcomeUiState { + focus_claimed: false, + started_at: Some(start), + }; + assert!(ui.intro_animating(start)); + assert!(!ui.intro_animating(start + CHROME_REVEAL_DURATION)); + } + + #[test] + fn intro_clock_starts_on_first_tick() { + let start = Instant::now(); + let mut ui = WelcomeUiState::new(); + assert!((ui.reveal_progress(start) - 0.0).abs() < 1e-3); + ui.tick(start); + assert!((ui.reveal_progress(start) - 0.0).abs() < 1e-3); + assert!((ui.reveal_progress(start + CHROME_REVEAL_DURATION) - 1.0).abs() < 1e-2); + } + + #[test] + fn accepts_enter_blocked_until_reveal_finishes() { + let start = Instant::now(); + let mut ui = WelcomeUiState::new(); + assert!(!ui.accepts_enter(start)); + ui.tick(start); + assert!(!ui.accepts_enter(start)); + assert!(ui.accepts_enter(start + CHROME_REVEAL_DURATION)); + } +} diff --git a/src/app/welcome/view.rs b/src/app/welcome/view.rs index 5244e69..85517e8 100644 --- a/src/app/welcome/view.rs +++ b/src/app/welcome/view.rs @@ -10,25 +10,28 @@ use std::cell::Cell; use std::rc::Rc; use crate::app::gpui_callbacks::WindowAppHandler; -use crate::app::hero::{opencore_brand_image, responsive_brand_height}; +use crate::app::hero::{ + WELCOME_ACTION_SPACER, WELCOME_EDGE_INSET_BOTTOM, WELCOME_EDGE_INSET_H, WELCOME_EDGE_INSET_TOP, + WELCOME_ENTER_BUTTON_HEIGHT, WELCOME_HERO_BRAND_FRAME_EXTRA, WELCOME_TITLEBAR_HEIGHT, lerp_f32, + opencore_brand_image, responsive_brand_height, show_off_brand_height, +}; use crate::app::viewport::WindowViewport; use crate::shared::theme::{ BackgroundToken, ForegroundToken, OpenCoreTheme, SpacingToken, TypeRole, }; use super::theme_toggle::theme_toggle_button; -use super::ui_state::WelcomeUiState; -const HERO_MAX_WIDTH: f32 = 680.0; +const HERO_TAGLINE: &str = "Your local AI command workspace"; +/// Single-line tagline width at [`TypeRole::DisplayMd`] in Space Grotesk. +/// +/// The tagline uses `whitespace_nowrap`; content width should stay at or above this +/// value (default welcome window is 960px with 16px horizontal insets). +const HERO_TAGLINE_COLUMN_WIDTH: f32 = 596.0; const HERO_GLOW_INSET_H: f32 = 44.0; const HERO_GLOW_INSET_TOP: f32 = 46.0; const HERO_GLOW_INSET_BOTTOM: f32 = 34.0; -const EDGE_INSET_H: f32 = 16.0; -const EDGE_INSET_TOP: f32 = 4.0; -const EDGE_INSET_BOTTOM: f32 = 20.0; -const ENTER_BUTTON_HEIGHT: f32 = 48.0; const TITLEBAR_CONTROLS_INSET: f32 = 88.0; -const TITLEBAR_HEIGHT: f32 = 38.0; fn welcome_drag_should_start(pointer_down: bool, pointer_moved: bool) -> bool { pointer_down && pointer_moved @@ -43,6 +46,7 @@ pub struct WelcomeCallbacks { /// Focusable shell for welcome keyboard input (Enter to complete). pub fn welcome_interactive_root( focus_handle: &FocusHandle, + accepts_enter: bool, on_enter: WindowAppHandler, content: impl IntoElement, ) -> impl IntoElement { @@ -74,18 +78,23 @@ pub fn welcome_interactive_root( .tab_index(0) .track_focus(focus_handle) .on_key_down(move |event: &KeyDownEvent, window, cx| { - if is_enter_keystroke(event) { + if accepts_enter && is_enter_keystroke(event) { on_enter(window, cx); } }) - .child(div().size_full().pt(px(TITLEBAR_HEIGHT)).child(content)) + .child( + div() + .size_full() + .pt(px(WELCOME_TITLEBAR_HEIGHT)) + .child(content), + ) .child( div() .absolute() .top_0() .left(px(TITLEBAR_CONTROLS_INSET)) .right_0() - .h(px(TITLEBAR_HEIGHT)) + .h(px(WELCOME_TITLEBAR_HEIGHT)) .window_control_area(WindowControlArea::Drag) .on_mouse_down(MouseButton::Left, on_drag_down) .on_mouse_up(MouseButton::Left, on_drag_up) @@ -96,30 +105,23 @@ pub fn welcome_interactive_root( /// Full-screen welcome landing scene. pub fn welcome_screen( theme: OpenCoreTheme, - ui: &WelcomeUiState, + reveal_progress: f32, callbacks: WelcomeCallbacks, persistence_error: Option<&str>, viewport: WindowViewport, - content_opacity: f32, ) -> impl IntoElement { let background = theme.surface(BackgroundToken::Primary); let hero_height = responsive_brand_height(viewport); + let show_off_height = show_off_brand_height(viewport); - div() - .size_full() - .bg(background) - .child( - div() - .size_full() - .opacity(content_opacity) - .child(main_column( - theme, - ui, - callbacks, - persistence_error, - hero_height, - )), - ) + div().size_full().bg(background).child(main_column( + theme, + callbacks, + persistence_error, + hero_height, + show_off_height, + reveal_progress, + )) } fn is_enter_keystroke(event: &KeyDownEvent) -> bool { @@ -129,19 +131,23 @@ fn is_enter_keystroke(event: &KeyDownEvent) -> bool { fn main_column( theme: OpenCoreTheme, - _ui: &WelcomeUiState, callbacks: WelcomeCallbacks, persistence_error: Option<&str>, hero_height: f32, + show_off_height: f32, + reveal_progress: f32, ) -> impl IntoElement { + let brand_height = lerp_f32(show_off_height, hero_height, reveal_progress); + let mut centered_content = div() .w_full() .flex_1() .flex() .flex_col() - .items_center() + .items_stretch() .justify_center() - .child(hero_block(theme, hero_height)); + .child(hero_brand_standalone(theme, brand_height)) + .child(hero_copy(theme, reveal_progress)); if let Some(message) = persistence_error { let muted = theme.foreground(ForegroundToken::Muted); @@ -150,6 +156,7 @@ fn main_column( centered_content = centered_content.child( div() .w_full() + .opacity(reveal_progress) .text_center() .text_size(px(TypeRole::MonoSm.size())) .font_family(mono) @@ -160,17 +167,21 @@ fn main_column( } centered_content = centered_content - .child(div().h(px(60.0))) - .child(action_row(theme, callbacks.clone())); + .child(div().h(px(WELCOME_ACTION_SPACER))) + .child(action_row(theme, callbacks.clone(), reveal_progress)); div() .size_full() .flex() .flex_col() - .pt(px(EDGE_INSET_TOP)) - .pb(px(EDGE_INSET_BOTTOM)) - .px(px(EDGE_INSET_H)) - .child(header_row(theme, callbacks.clone())) + .pt(px(WELCOME_EDGE_INSET_TOP)) + .pb(px(WELCOME_EDGE_INSET_BOTTOM)) + .px(px(WELCOME_EDGE_INSET_H)) + .child( + div() + .opacity(reveal_progress) + .child(header_row(theme, callbacks.clone())), + ) .child(div().h(px(8.))) .child(centered_content) } @@ -224,50 +235,46 @@ fn hero_glow(theme: OpenCoreTheme) -> impl IntoElement { ]) } -fn hero_block(theme: OpenCoreTheme, hero_height: f32) -> impl IntoElement { - let primary = theme.foreground(ForegroundToken::Primary); - let secondary = theme.foreground(ForegroundToken::Secondary); - let grotesk = SharedString::from("Space Grotesk"); - let spacing = theme.spacing; - - let hero_brand = div() +fn hero_brand_standalone(theme: OpenCoreTheme, hero_height: f32) -> impl IntoElement { + div() .relative() .w_full() - .h(px(hero_height + 40.0)) + .h(px(hero_height + WELCOME_HERO_BRAND_FRAME_EXTRA)) .flex() .items_center() .justify_center() .child(hero_glow(theme)) - .child(opencore_brand_image(theme, hero_height, 1.0)); + .child(opencore_brand_image(theme, hero_height, 1.0)) +} + +fn hero_copy(theme: OpenCoreTheme, reveal_progress: f32) -> impl IntoElement { + let primary = theme.foreground(ForegroundToken::Primary); + let secondary = theme.foreground(ForegroundToken::Secondary); + let grotesk = SharedString::from("Space Grotesk"); + let spacing = theme.spacing; div() .w_full() .flex() .justify_center() .child( - div() - .w_full() - .max_w(px(HERO_MAX_WIDTH)) - .flex() - .flex_col() - .items_center() - .child(hero_brand) + hero_copy_column(reveal_progress) .child(div().h(px(spacing.lg as f32))) .child( div() .w_full() + .whitespace_nowrap() .text_center() .text_size(px(TypeRole::DisplayMd.size())) .font_family(grotesk.clone()) .text_color(primary) - .child("Your local AI command workspace"), + .child(HERO_TAGLINE), ) .child(div().h(px(spacing.sm as f32))) .child( div() .w_full() - .max_w(px(HERO_MAX_WIDTH)) - .text_center() + .text_left() .text_size(px(TypeRole::MonoSm.size())) .line_height(relative(TypeRole::MonoSm.line_height())) .font_family(grotesk) @@ -277,11 +284,26 @@ fn hero_block(theme: OpenCoreTheme, hero_height: f32) -> impl IntoElement { ) } -fn action_row(theme: OpenCoreTheme, callbacks: WelcomeCallbacks) -> impl IntoElement { +fn hero_copy_column(reveal_progress: f32) -> gpui::Div { + div() + .w(px(HERO_TAGLINE_COLUMN_WIDTH)) + .max_w_full() + .flex() + .flex_col() + .items_stretch() + .opacity(reveal_progress) +} + +fn action_row( + theme: OpenCoreTheme, + callbacks: WelcomeCallbacks, + reveal_progress: f32, +) -> impl IntoElement { let spacing = theme.spacing; let on_enter = callbacks.on_enter; div() .w_full() + .opacity(reveal_progress) .flex() .items_center() .justify_center() @@ -290,7 +312,7 @@ fn action_row(theme: OpenCoreTheme, callbacks: WelcomeCallbacks) -> impl IntoEle Button::new("enter-opencore") .primary() .label("Enter OpenCore") - .h(px(ENTER_BUTTON_HEIGHT)) + .h(px(WELCOME_ENTER_BUTTON_HEIGHT)) .on_click(move |_, window, cx| { on_enter(window, cx); }), @@ -325,8 +347,32 @@ mod tests { #[test] fn welcome_hero_layout_constants() { - assert_eq!(HERO_MAX_WIDTH, 680.0); + assert_eq!(HERO_TAGLINE_COLUMN_WIDTH, 596.0); assert_eq!(HERO_GLOW_INSET_H, 44.0); - assert_eq!(ENTER_BUTTON_HEIGHT, 48.0); + assert_eq!(WELCOME_ENTER_BUTTON_HEIGHT, 48.0); + } + + #[gpui::test] + fn hero_tagline_column_width_matches_measured_tagline(cx: &mut gpui::TestAppContext) { + use crate::shared::assets::AppAssets; + use crate::shared::theme::TypeRole; + use gpui::{font, px}; + + cx.update(|app| AppAssets.load_fonts(app).unwrap()); + + let measured = cx.update(|app| { + let text_system = app.text_system(); + let font_id = text_system.resolve_font(&font("Space Grotesk")); + let font_size = px(TypeRole::DisplayMd.size()); + HERO_TAGLINE + .chars() + .map(|ch| text_system.layout_width(font_id, font_size, ch).as_f32()) + .sum::() + }); + + assert!( + (measured - HERO_TAGLINE_COLUMN_WIDTH).abs() < 2.0, + "update HERO_TAGLINE_COLUMN_WIDTH to {measured}", + ); } } diff --git a/src/shared/preferences/mod.rs b/src/shared/preferences/mod.rs index f775211..69de2b9 100644 --- a/src/shared/preferences/mod.rs +++ b/src/shared/preferences/mod.rs @@ -172,7 +172,7 @@ mod tests { fn app_preferences_default_serializes_to_prd_schema() { let json = serde_json::to_string(&AppPreferences::default()).expect("serialize"); let value: serde_json::Value = serde_json::from_str(&json).expect("parse"); - assert_eq!(value["theme_mode"], "dark"); + assert_eq!(value["theme_mode"], "light"); assert_eq!(value["onboarding_completed"], false); assert_eq!(value.get("shell"), None); assert_eq!(value.get("dock_layout"), None); @@ -181,7 +181,7 @@ mod tests { #[test] fn app_preferences_default_matches_schema() { let prefs = AppPreferences::default(); - assert_eq!(prefs.theme_mode, ThemeMode::Dark); + assert_eq!(prefs.theme_mode, ThemeMode::Light); assert!(!prefs.onboarding_completed); assert!(prefs.dock_layout.is_none()); } @@ -202,7 +202,7 @@ mod tests { fn app_preferences_deserializes_with_missing_fields() { let restored: AppPreferences = serde_json::from_str(r#"{"onboarding_completed":true}"#).expect("deserialize"); - assert_eq!(restored.theme_mode, ThemeMode::Dark); + assert_eq!(restored.theme_mode, ThemeMode::Light); assert!(restored.onboarding_completed); } diff --git a/src/shared/theme/mod.rs b/src/shared/theme/mod.rs index d5d2b01..56f3ceb 100644 --- a/src/shared/theme/mod.rs +++ b/src/shared/theme/mod.rs @@ -21,8 +21,8 @@ pub const WARNING_AMBER: u32 = 0xD4_A8_43; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ThemeMode { - Light, #[default] + Light, Dark, } @@ -379,8 +379,8 @@ mod tests { } #[test] - fn default_theme_mode_is_dark() { - assert_eq!(ThemeMode::default(), ThemeMode::Dark); + fn default_theme_mode_is_light() { + assert_eq!(ThemeMode::default(), ThemeMode::Light); } #[test]