diff --git a/Cargo.lock b/Cargo.lock index 0c37337dc..a6d06e386 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1466,7 +1466,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "gpui_util", "indexmap", @@ -2113,7 +2113,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "proc-macro2", "quote", @@ -3266,7 +3266,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "accesskit", "anyhow", @@ -3352,7 +3352,7 @@ dependencies = [ [[package]] name = "gpui-base" version = "0.5.2" -source = "git+https://github.com/zeronsh/gpui-component?rev=8c3af053189209db83b92b64aaa5cbcbedd9b72f#8c3af053189209db83b92b64aaa5cbcbedd9b72f" +source = "git+https://github.com/zeronsh/gpui-component?rev=94c1bbaf6311b9f36f5e7438aaf5aeadd38740da#94c1bbaf6311b9f36f5e7438aaf5aeadd38740da" dependencies = [ "aho-corasick", "anyhow", @@ -3382,7 +3382,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "accesskit", "accesskit_unix", @@ -3434,7 +3434,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "accesskit", "accesskit_macos", @@ -3483,7 +3483,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3494,7 +3494,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "console_error_panic_hook", "gpui", @@ -3507,7 +3507,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "schemars", "serde", @@ -3517,7 +3517,7 @@ dependencies = [ [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "anyhow", "gpui", @@ -3528,7 +3528,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "anyhow", "log", @@ -3538,7 +3538,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3562,7 +3562,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "anyhow", "bytemuck", @@ -3591,7 +3591,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "accesskit", "accesskit_windows", @@ -3923,7 +3923,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "anyhow", "async-compression", @@ -5049,7 +5049,7 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "anyhow", "bindgen", @@ -6192,7 +6192,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "collections", "serde", @@ -7263,7 +7263,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "derive_refineable", ] @@ -7915,7 +7915,7 @@ checksum = "c62751faa8bc286982334a082fe125184a29fc89d17775766e4f891b7d726980" [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "async-task", "backtrace", @@ -8661,7 +8661,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "heapless 0.9.3", "log", @@ -9913,7 +9913,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zeronsh/zui?rev=07fd941ad72e7edc812fed317aab66adb69fa8cc#07fd941ad72e7edc812fed317aab66adb69fa8cc" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "perf", "quote", diff --git a/Cargo.toml b/Cargo.toml index 052e96e3c..c91a6fff2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,15 +64,16 @@ loro-protocol = "0.3" # stopped vending CABackdropLayer for Selection — window blur went dead); # b68970e rasterizes BackdropBlur in the wgpu renderer (frosted floats on # Linux — the Metal path's snapshot/blur/composite, ported). -gpui = { git = "https://github.com/zeronsh/zui", rev = "07fd941ad72e7edc812fed317aab66adb69fa8cc" } -gpui_platform = { git = "https://github.com/zeronsh/zui", rev = "07fd941ad72e7edc812fed317aab66adb69fa8cc", features = [ +# Paint-time rounded image masks (zeronsh/zui#9). +gpui = { git = "https://github.com/zeronsh/zui", rev = "aa009411c2dbfb39556bff1b1febed0512fdf609" } +gpui_platform = { git = "https://github.com/zeronsh/zui", rev = "aa009411c2dbfb39556bff1b1febed0512fdf609", features = [ "wayland", "x11", "font-kit", "runtime_shaders", ] } -gpui_tokio = { git = "https://github.com/zeronsh/zui", rev = "07fd941ad72e7edc812fed317aab66adb69fa8cc" } -gpui-base = { git = "https://github.com/zeronsh/gpui-component", rev = "8c3af053189209db83b92b64aaa5cbcbedd9b72f" } +gpui_tokio = { git = "https://github.com/zeronsh/zui", rev = "aa009411c2dbfb39556bff1b1febed0512fdf609" } +gpui-base = { git = "https://github.com/zeronsh/gpui-component", rev = "94c1bbaf6311b9f36f5e7438aaf5aeadd38740da" } # diffs similar = "2" diff --git a/crates/ui/examples/browser-fixture.rs b/crates/ui/examples/browser-fixture.rs index 8bd265f5c..4dbf1ea16 100644 --- a/crates/ui/examples/browser-fixture.rs +++ b/crates/ui/examples/browser-fixture.rs @@ -353,11 +353,13 @@ fn main() -> anyhow::Result<()> { let (left,top)=first.read_with(cx,|b,_|b.fixture_origin()); // Real resize-handle drag, including crossing into the native page. eprintln!("Browser fixture: starting resize drag"); - // Both halves must reach GPUI before a drag exists. - for offset in [-6., -2., 0., 3., 4.5] { + // The 20px shell target overlaps the native browser by 9px + // after its one-point panel border. That full overlap must + // reach GPUI, while content beyond it stays native. + for offset in [-6., -2., 0., 3., 6., 8.5] { anyhow::ensure!(!first.read_with(cx,|b,_|b.fixture_page_hit((left+offset) as f64,(top+120.) as f64)),"native page stole the resize target at offset {offset}"); } - anyhow::ensure!(first.read_with(cx,|b,_|b.fixture_page_hit((left+6.) as f64,(top+120.) as f64)),"resize target blocked adjacent page content"); + anyhow::ensure!(first.read_with(cx,|b,_|b.fixture_page_hit((left+10.) as f64,(top+120.) as f64)),"resize target blocked adjacent page content"); let start=gpui::point(px(left+3.),px(top+120.)); gpui::AnyWindowHandle::from(window).update(cx,|_,w,cx| {w.dispatch_event(gpui::PlatformInput::MouseDown(gpui::MouseDownEvent{position:start,button:gpui::MouseButton::Left,click_count:1,..Default::default()}),cx);})?; let mut widths=Vec::new(); diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 4b27db34d..b8c9abfec 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -69,12 +69,27 @@ pub const COMPOSER_MAX_HEIGHT: f32 = TEXTAREA_MAX + ACTIONS_ROW_HEIGHT + PILL_BO /// compact cluster (`py-1.5` + h-8 = 44) is shorter, so the textarea wins. pub const COMPACT_TOTAL_HEIGHT: f32 = 49.0; /// `max-w-3xl`: stable outer width of the centered composer column. -const COMPOSER_MAX_WIDTH: f32 = 768.0; +pub const COMPOSER_MAX_WIDTH: f32 = 768.0; /// The queue reads as a narrower tray emerging from behind the composer. const QUEUE_SIDE_INSET: f32 = 16.0; /// The composer covers the tray's lower padding so the queue reads as emerging /// from behind it instead of as a separate rounded pill. pub(crate) const QUEUE_COMPOSER_OVERLAP: f32 = 18.0; +/// The original floating selector rows use the same 20px chip height as the +/// established-thread footer. Their surrounding rows own no plate or border. +const NEW_THREAD_SELECTOR_ROW_HEIGHT: f32 = 20.0; +// Accommodate the 24px usage indicator and PR badge without overflowing the +// row's equal 8px top/bottom gutters. +const SESSION_FOOTER_HEIGHT: f32 = 24.0; + +/// Route chrome dissolves around the middle of the shared-element move. The +/// two ramps never overlap, which avoids duplicate picker ids/popovers while +/// still letting their surrounding geometry collapse continuously. +fn route_chrome_opacities(new_thread_chrome: f32) -> (f32, f32) { + let new_thread = ((new_thread_chrome.clamp(0.0, 1.0) - 0.5) * 2.0).clamp(0.0, 1.0); + let session = (((1.0 - new_thread_chrome.clamp(0.0, 1.0)) - 0.5) * 2.0).clamp(0.0, 1.0); + (new_thread, session) +} /// Ignore subpixel noise when the shell reports the conversation width. const COMPOSER_WIDTH_EPSILON: f32 = 0.5; /// Below this pill input width the composer always expands. @@ -298,9 +313,10 @@ pub fn comment_strip_height(count: usize) -> f32 { /// Compact↔expanded flip morph (round 9): the flip used to snap between the /// two pill layouts. The original has no height transition (its shell carries /// only `transition-colors`), so this is a native nicety: ONE committed flip -/// starts exactly one 180ms ease-out morph ([`motion::COLLAPSE`], the same -/// manual-drive pattern as shell.rs `WidthTween` — never `with_animation`, -/// whose element-id keying replays tweens on remount, round-6 §1–3). +/// starts exactly one 180ms ease-out morph ([`motion::COLLAPSE`]); the blank- +/// thread handoff swaps in the coordinated 420ms route-transition spec. Both use the +/// manual-drive pattern from shell.rs `WidthTween` — never `with_animation`, +/// whose element-id keying replays tweens on remount, round-6 §1–3. /// /// The morph animates the pill's COMMITTED height: the flip commits its final /// layout immediately (the input entity never remounts — the caret survives, @@ -317,18 +333,37 @@ pub struct FlipMorph { pub from: f32, /// Commit time in ms on the caller's monotonic clock. pub start_ms: f32, + /// Ordinary typing flips use the quick collapse spec; the first-send + /// handoff uses the shell's longer coordinated route timeline. + pub spec: motion::MotionSpec, } impl FlipMorph { - /// Raw timeline position 0..1 over [`motion::COLLAPSE`]'s 180ms. + fn collapse(from: f32, start_ms: f32) -> Self { + Self { + from, + start_ms, + spec: motion::COLLAPSE, + } + } + + fn new_thread_transition(from: f32, start_ms: f32) -> Self { + Self { + from, + start_ms, + spec: motion::NEW_THREAD_TRANSITION, + } + } + + /// Raw timeline position 0..1 over this morph's motion spec. fn raw(&self, now_ms: f32) -> f32 { - let total = motion::COLLAPSE.total().as_secs_f32() * 1000.0; + let total = self.spec.total().as_secs_f32() * 1000.0; ((now_ms - self.start_ms) / total).clamp(0.0, 1.0) } - /// Eased progress 0..1 (ease-out) — also drives the actions fade. + /// Eased progress 0..1 — also drives the inner geometry handoff. pub fn progress(&self, now_ms: f32) -> f32 { - motion::COLLAPSE.progress(self.raw(now_ms)) + self.spec.progress(self.raw(now_ms)) } pub fn done(&self, now_ms: f32) -> bool { @@ -444,10 +479,7 @@ pub fn flip_morph_step( if reduced_motion || last_height <= 0.0 { return None; } - Some(FlipMorph { - from: last_height, - start_ms: now_ms, - }) + Some(FlipMorph::collapse(last_height, now_ms)) } /// Engines at or above this version understand `pending://` attachment refs @@ -3809,6 +3841,10 @@ impl Render for ComposerInput { /// Events the shell listens for. #[derive(Debug, Clone)] pub enum ComposerEvent { + /// Arm the shared-element transition before the draft route is replaced + /// by the newly-created session. Emitting this before `select_chat` keeps + /// the first destination frame on the same timeline as the source frame. + NewThreadTransitionStarted, /// A prompt was sent optimistically — give the transcript its exact row /// identity so it can anchor the prompt at the top with the reply's /// reserved space below it. @@ -3958,9 +3994,8 @@ pub struct Composer { pub(crate) input: Entity, /// Draft displaced while a queued message occupies the composer. pub(crate) queue_edit_draft: Option<(String, Vec, Vec)>, - /// Composer actions row: repo/branch/harness-model/traits (§1.7). - /// Shared with the shell's new-session canvas, which renders the - /// device/project target selectors ([`Pickers::render_target_selectors`]). + /// Composer actions row plus the new-session floating target tab + /// ([`Pickers::render_new_thread_target_selectors`]). pickers: Entity, /// Draft text per chat key ("" = new-chat canvas), surviving navigation. drafts: HashMap, @@ -3999,6 +4034,10 @@ pub struct Composer { popup_bar: crate::popover::MenuScrollbarState, pub(crate) current_key: String, sending: bool, + /// Armed immediately before a blank-canvas send selects its minted chat. + /// The state observer consumes it to distinguish that handoff from normal + /// session navigation, which must continue to snap. + launching_new_chat: bool, pub(crate) failure: Option, /// The chat key `failure` belongs to (`None` = global, e.g. "Engine not /// connected"). Chat-scoped failures survive navigation and render only @@ -4077,6 +4116,9 @@ pub struct Composer { /// Pill height actually rendered last frame — a committed flip morphs /// from here, so mid-flight reversals hand off without a jump. last_rendered_height: f32, + dock_frame: Option, + dock_clearance_correction: f32, + surface_bounds: crate::new_thread_background_mask::SurfaceBounds, last_target_height: f32, height_morph: Option, /// Monotonic clock anchor for the morph timeline. @@ -4093,6 +4135,30 @@ pub struct Composer { impl EventEmitter for Composer {} impl Composer { + pub(crate) fn set_dock_frame( + &mut self, + frame: crate::composer_dock::DockFrame, + cx: &mut Context, + ) { + let changed = self.dock_frame != Some(frame); + self.dock_frame = Some(frame); + if frame.active { + self.flip_morph = None; + self.height_morph = None; + } + if changed { + cx.notify(); + } + } + + pub(crate) fn dock_clearance_correction(&self) -> f32 { + self.dock_clearance_correction + } + + pub(crate) fn surface_bounds(&self) -> crate::new_thread_background_mask::SurfaceBounds { + self.surface_bounds.clone() + } + /// The picker entity, for the shell's canvas target selectors. pub fn pickers(&self) -> &Entity { &self.pickers @@ -4205,6 +4271,7 @@ impl Composer { popup_bar: crate::popover::MenuScrollbarState::default(), current_key, sending: false, + launching_new_chat: false, failure: None, wizard: None, wizard_focus: cx.focus_handle(), @@ -4242,6 +4309,9 @@ impl Composer { settle_task: None, flip_morph: None, last_rendered_height: 0.0, + dock_frame: None, + dock_clearance_correction: 0.0, + surface_bounds: Default::default(), last_target_height: 0.0, height_morph: None, morph_clock: Instant::now(), @@ -5745,6 +5815,10 @@ impl Composer { // Draft swap on chat navigation — the input entity itself survives. if key != self.current_key { + let new_thread_launch = + self.launching_new_chat && self.current_key.is_empty() && !key.is_empty(); + let returning_to_new_thread = !self.current_key.is_empty() && key.is_empty(); + self.launching_new_chat = false; let old_text = self.input.read(cx).text().to_string(); if old_text.is_empty() { self.drafts.remove(&self.current_key); @@ -5768,11 +5842,27 @@ impl Composer { // the nav-driven flip only commits AFTER the swapped draft has // been re-measured, one or two renders later, so the whole // window snaps (see ROUTE_SNAP_MS). - self.flip_morph = None; self.height_morph = None; self.last_target_height = 0.0; - self.last_rendered_height = 0.0; - self.route_snap_until = Some(Instant::now() + Duration::from_millis(ROUTE_SNAP_MS)); + if (new_thread_launch || returning_to_new_thread) + && !motion::reduced_motion(cx) + && self.last_rendered_height > 0.0 + { + // Both directions share one timeline. The blank canvas is + // always expanded; an established session begins compact. + self.expanded_mode = returning_to_new_thread; + let now_ms = + self.morph_clock.elapsed().as_secs_f32() * 1000.0 / motion::speed_scale(); + self.flip_morph = Some(FlipMorph::new_thread_transition( + self.last_rendered_height, + now_ms, + )); + self.route_snap_until = None; + } else { + self.flip_morph = None; + self.last_rendered_height = 0.0; + self.route_snap_until = Some(Instant::now() + Duration::from_millis(ROUTE_SNAP_MS)); + } self.input.update(cx, |input, cx| input.set_text(draft, cx)); } @@ -6149,6 +6239,10 @@ impl Composer { status: None, continuation_of: None, }; + self.launching_new_chat = is_new; + if is_new { + cx.emit(ComposerEvent::NewThreadTransitionStarted); + } // A queued message is not in the transcript yet — the queue panel is // its echo, and it gets a real bubble when the host sends it. self.state.update(cx, |s, cx| { @@ -7190,14 +7284,18 @@ impl Render for Composer { let route_snap = self .route_snap_until .is_some_and(|until| Instant::now() < until); - self.flip_morph = flip_morph_step( - self.flip_morph, - committed_flip && !new_chat, - self.last_rendered_height, - now_ms, - motion::reduced_motion(cx), - route_snap, - ); + self.flip_morph = if self.dock_frame.is_some() { + None + } else { + flip_morph_step( + self.flip_morph, + committed_flip && !new_chat, + self.last_rendered_height, + now_ms, + motion::reduced_motion(cx), + route_snap, + ) + }; let expanded = self.expanded_mode; // Chat-scoped failures render only under their own chat; a global @@ -7376,10 +7474,16 @@ impl Render for Composer { })) }); - // New chats always use the expanded layout: the repo/branch pickers - // need the full-width actions row (zeron composer-actions.tsx - // `mustExpand = isNew || …`). - let expanded = expanded || new_chat; + // The shared main composer keeps one two-row body on both routes. + // Docking reduces its empty textarea by 16px without changing the + // input's origin or moving the controls through a second layout. + let expanded = expanded || new_chat || self.dock_frame.is_some(); + let dock_amount = self.dock_frame.map_or(0.0, |frame| frame.amount); + let dock_height = |amount: f32| { + (content_height + TEXTAREA_PAD_V).clamp(TEXTAREA_MIN - 16.0 * amount, TEXTAREA_MAX) + + ACTIONS_ROW_HEIGHT + + PILL_BORDER_V + }; // Committed-height morph: the layout below is already the NEW mode's; // only the pill's height (and the entrance fade/text glide driven by @@ -7395,21 +7499,52 @@ impl Render for Composer { let appshot_count = self.staged_appshots().len(); let strip_h = attachment_strip_height(staged_count, strip_width_hint); let comment_strip_h = comment_strip_height(self.staged_comments(cx).len()); - let base_height = if expanded { + let base_height = if self.dock_frame.is_some() { + dock_height(dock_amount) + } else if expanded { composer_total_height(content_height) } else { COMPACT_TOTAL_HEIGHT }; let target_height = base_height + strip_h + appshot_strip_height(appshot_count) + comment_strip_h; - self.height_morph = flip_morph_step( - self.height_morph, - (target_height - self.last_target_height).abs() > 0.5, - self.last_rendered_height, - now_ms, - motion::reduced_motion(cx), - route_snap, + let coordinated_route_morph = self + .flip_morph + .filter(|m| m.spec == motion::NEW_THREAD_TRANSITION && !m.done(now_ms)); + // The route state commits before its shared-element animation begins. + // Reconstruct the departing chrome at t=0, then progressively trade + // it for the destination chrome so neither route changes the outer + // composer geometry in a single frame. + let new_thread_chrome = self + .dock_frame + .map(|frame| frame.selectors()) + .unwrap_or_else(|| { + coordinated_route_morph.map_or_else( + || if new_chat { 1.0 } else { 0.0 }, + |morph| { + let progress = morph.progress(now_ms); + if new_chat { progress } else { 1.0 - progress } + }, + ) + }); + let (new_thread_chrome_opacity, session_chrome_opacity) = self.dock_frame.map_or_else( + || route_chrome_opacities(new_thread_chrome), + |frame| (frame.selectors(), frame.footer()), ); + self.height_morph = if self.dock_frame.is_some_and(|frame| frame.active) { + None + } else if coordinated_route_morph.is_some() { + coordinated_route_morph + } else { + flip_morph_step( + self.height_morph, + (target_height - self.last_target_height).abs() > 0.5, + self.last_rendered_height, + now_ms, + motion::reduced_motion(cx), + route_snap, + ) + }; self.last_target_height = target_height; let pill_height = self .height_morph @@ -7430,7 +7565,19 @@ impl Render for Composer { window.request_animation_frame(); } self.last_rendered_height = pill_height; - let text_pt = morph_text_pad(morph_t); + self.dock_clearance_correction = self.dock_frame.map_or(0.0, |frame| { + dock_height(if frame.docked { 1.0 } else { 0.0 }) + + strip_h + + appshot_strip_height(appshot_count) + + comment_strip_h + - pill_height + }); + let text_pt = if self.dock_frame.is_some() { + 16.0 + } else { + morph_text_pad(morph_t) + }; + let surface_radius = COMPOSER_RADIUS - 4.0 * dock_amount; let textarea_height = (pill_height - strip_h - appshot_strip_height(appshot_count) @@ -7522,7 +7669,7 @@ impl Render for Composer { } }), ) - .rounded(px(COMPOSER_RADIUS)) + .rounded(px(surface_radius)) .bg(pill_bg) .border_1() .border_color(theme.border) @@ -7543,7 +7690,6 @@ impl Render for Composer { // top padding eases 12→16. The whole control cluster stays at // full alpha — chips, // attach and send are all (near-)stationary on the bottom anchor. - let text_pt = morph_text_pad(morph_t); pill.h(px(pill_height)) .overflow_hidden() .relative() @@ -7577,9 +7723,13 @@ impl Render for Composer { // Send has a larger structural separation. .gap(px(ACTION_PRIMARY_GAP)) .pl(px(12.0)) - .pr(px(morph_cluster_inset(true, morph_t))) + .pr(px(if self.dock_frame.is_some() { + 12.0 - 2.0 * dock_amount + } else { + morph_cluster_inset(true, morph_t) + })) .pt(px(4.0)) - .pb(px(10.0)) + .pb(px(10.0 - 2.0 * dock_amount)) .child( div() .flex_1() @@ -7658,61 +7808,140 @@ impl Render for Composer { ), ) }; - // New sessions: the TARGET row (device + project chips) sits ABOVE - // the pill, left-aligned like the checkout toolbar below it (user - // request — moved off the canvas). Existing sessions name their - // target in the titlebar instead. - let container = if new_chat { - let selectors = self - .pickers - .update(cx, |pickers, cx| pickers.render_target_selectors(cx)); - container.child(selectors) - } else { - container - }; + let new_thread_target_selectors = (new_thread_chrome_opacity > 0.0).then(|| { + self.pickers.update(cx, |pickers, cx| { + pickers.render_new_thread_target_selectors(cx) + }) + }); + let new_thread_git_selectors = (new_thread_chrome_opacity > 0.0) + .then(|| { + self.pickers.update(cx, |pickers, cx| { + pickers.render_new_thread_git_selectors(cx) + }) + }) + .flatten(); + let has_new_thread_git_selectors = self + .state + .read(cx) + .selected_space_row() + .is_some_and(|space| space.git_detected); // The file dropzone lives in the shell (the whole conversation column, // not just the pill — shell.rs `chat-dropzone`); drops land back here // via `add_paths`. // Frosted: the pill backdrop-blurs the transcript scrolling under it // (the popover glass treatment; radius matches the pill's rounding). - let container = container.child( - div() - .relative() - .child(crate::frost::frosted( - COMPOSER_RADIUS, - 16.0, - motion::fade_quick("composer-input", body), - )) - // Both completion popups span the full pill width above it — - // the file-mention and slash tokens are mutually exclusive. - .children(self.render_file_mention_popup(&theme, cx)) - .children(self.render_slash_popup(&theme, cx)), - ); - // Branch/worktree toolbar under the pill (t3code BranchToolbar): the - // checkout-kind selector + ref picker for new sessions, read-only - // labels once the session exists. Git spaces only. - let footer = self - .pickers - .update(cx, |pickers, cx| pickers.render_footer(cx)); - let container = - if !new_chat { - let usage = self.state.read(cx).context_usage; - container.child( - div() - .w_full() - .flex() - .items_center() - .child(div().flex_1().min_w_0().children(footer)) - .child(div().pr(px(10.0)).mb(px(-8.0)).child( - crate::context_usage::render(usage, self.state.clone(), &theme), - )), + // The shell keeps this entity under one parent on both routes. The + // surface itself never fades, and frost follows the same morph radius. + let pill_surface = div() + .relative() + .id("composer-surface") + .child(crate::frost::frosted(surface_radius, 16.0, body)) + .child({ + let measured = self.surface_bounds.clone(); + // All prepaint completes before any paint. The background + // reads this cell during paint, never last frame's geometry. + gpui::canvas( + move |bounds, _, _| measured.set(Some(bounds)), + |_, _, _, _| {}, ) - } else { - match footer { - Some(footer) => container.child(footer), - None => container, - } - }; + .absolute() + .inset_0() + }) + // Both completion popups span the full pill width above it — + // the file-mention and slash tokens are mutually exclusive. + .children(self.render_file_mention_popup(&theme, cx)) + .children(self.render_slash_popup(&theme, cx)); + // Restore the original chip-only selector treatment: destination at + // the top-right, no surrounding surface. Cancel the column gap as the + // row collapses so the pill never jumps at the route boundary. + let container = if self.dock_frame.is_some() { + // Floating selectors share the surface's origin and never change its height. + container.relative().child( + div() + .id("dock-target-selectors") + .absolute() + .top(px(-28.0)) + .left(px(Theme::SPACE_LG + 10.0)) + .right(px(Theme::SPACE_LG + 10.0)) + .h(px(NEW_THREAD_SELECTOR_ROW_HEIGHT)) + .flex() + .items_start() + .justify_end() + .opacity(new_thread_chrome_opacity) + .children(new_thread_target_selectors), + ) + } else if new_thread_chrome > 0.0 { + container.child( + div() + .w_full() + .h(px(NEW_THREAD_SELECTOR_ROW_HEIGHT * new_thread_chrome)) + .mb(px(-Theme::SPACE_SM * (1.0 - new_thread_chrome))) + .px(px(10.0)) + .flex() + .items_start() + .justify_end() + .opacity(new_thread_chrome_opacity) + .children(new_thread_target_selectors), + ) + } else { + container + }; + let container = container.child(pill_surface); + + // The lower slot keeps a stable footprint for Git projects while its + // old floating checkout/ref controls dissolve into the session footer. + // Non-Git sessions grow the slot continuously from zero. + let session_chrome = 1.0 - new_thread_chrome; + let bottom_slot = if has_new_thread_git_selectors || self.dock_frame.is_some() { + 1.0 + } else { + session_chrome + }; + let container = if bottom_slot > 0.0 { + let footer = (session_chrome_opacity > 0.0).then(|| { + self.pickers + .update(cx, |pickers, cx| pickers.render_footer(cx)) + }); + let usage = self.state.read(cx).context_usage; + container.child( + div() + .w_full() + .h(px(SESSION_FOOTER_HEIGHT * bottom_slot)) + .mt(px(-Theme::SPACE_SM * (1.0 - bottom_slot))) + .mb(px(-Theme::SPACE_SM * bottom_slot)) + .relative() + .when(new_thread_chrome_opacity > 0.0, |slot| { + slot.child( + div() + .absolute() + .inset_0() + .px(px(10.0)) + .flex() + .items_center() + .opacity(new_thread_chrome_opacity) + .children(new_thread_git_selectors), + ) + }) + .when(session_chrome_opacity > 0.0, |slot| { + slot.child( + div() + .absolute() + .inset_0() + .w_full() + .h(px(SESSION_FOOTER_HEIGHT)) + .flex() + .items_center() + .opacity(session_chrome_opacity) + .child(div().flex_1().min_w_0().children(footer.flatten())) + .child(div().flex_none().pr(px(10.0)).child( + crate::context_usage::render(usage, self.state.clone(), &theme), + )), + ) + }), + ) + } else { + container + }; // Full-size preview of a staged thumbnail (AttachmentPreviewDialog). if let Some(preview) = self.preview.clone() { if std::mem::take(&mut self.preview_focus_pending) { @@ -7771,6 +8000,46 @@ mod tests { (dir, window) } + #[gpui::test] + fn dock_morph_keeps_editor_origin_and_reserves_final_height(cx: &mut gpui::TestAppContext) { + let (_dir, handle) = composer_focus_window(cx); + let input = handle + .read_with(cx, |composer, _| composer.input.clone()) + .unwrap(); + let mut first_origin = None; + for amount in [0.0, 0.2, 0.6, 0.98, 1.0, 0.7, 0.0] { + handle + .update(cx, |composer, _, cx| { + let mut frame = crate::composer_dock::DockFrame::settled(true); + frame.amount = amount; + frame.active = amount < 1.0; + composer.set_dock_frame(frame, cx); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| { + window.draw(cx).clear(); + }) + .unwrap(); + handle + .read_with(cx, |composer, cx| { + assert_eq!(composer.input, input); + let origin = input.read(cx).last_bounds.unwrap().origin; + let first = *first_origin.get_or_insert(origin); + assert!( + (f32::from(origin.y - first.y)).abs() < 0.1, + "editor jumped at {amount}" + ); + assert!( + (composer.last_rendered_height + composer.dock_clearance_correction + - 108.0) + .abs() + < 0.1 + ); + }) + .unwrap(); + } + } + #[gpui::test] fn composer_padding_and_file_prompt_restore_focus(cx: &mut gpui::TestAppContext) { let (dir, handle) = composer_focus_window(cx); @@ -8859,6 +9128,7 @@ mod tests { let m = FlipMorph { from: 49.0, start_ms: 0.0, + spec: motion::COLLAPSE, }; // Starts exactly at the committed height… let mut prev = m.height(124.0, 0.0); @@ -8878,6 +9148,7 @@ mod tests { let down = FlipMorph { from: 124.0, start_ms: 0.0, + spec: motion::COLLAPSE, }; assert!(down.height(49.0, 90.0) < 124.0); assert!(down.height(49.0, 90.0) > 49.0); @@ -8888,6 +9159,7 @@ mod tests { let m = FlipMorph { from: 49.0, start_ms: 0.0, + spec: motion::COLLAPSE, }; let mid = m.height(124.0, 90.0); assert!(mid > 49.0 && mid < 124.0); @@ -8916,6 +9188,7 @@ mod tests { let m = FlipMorph { from: 49.0, start_ms: 0.0, + spec: motion::COLLAPSE, }; assert_eq!( flip_morph_step(Some(m), false, 80.0, 50.0, false, true), @@ -8982,6 +9255,7 @@ mod tests { let m = FlipMorph { from: 49.0, start_ms: 0.0, + spec: motion::COLLAPSE, }; // Auto-grow can move the target mid-morph: evaluation tracks the // live value instead of finishing on a stale height. @@ -8993,6 +9267,36 @@ mod tests { assert!(mid > 0.0 && mid < 1.0); } + #[test] + fn new_thread_route_changes_use_the_coordinated_timeline() { + let m = FlipMorph::new_thread_transition(124.0, 0.0); + assert_eq!(m.spec, motion::NEW_THREAD_TRANSITION); + assert_eq!(m.height(49.0, 0.0), 124.0); + assert!(m.height(49.0, 250.0) < 124.0); + assert!(m.height(49.0, 250.0) > 49.0); + assert_eq!(m.height(49.0, 420.0), 49.0); + let reverse = FlipMorph::new_thread_transition(49.0, 0.0); + assert_eq!(reverse.height(124.0, 0.0), 49.0); + assert_eq!(reverse.height(124.0, 420.0), 124.0); + } + + #[test] + fn new_thread_selectors_restore_the_compact_floating_row() { + assert_eq!(NEW_THREAD_SELECTOR_ROW_HEIGHT, 20.0); + assert_eq!(SESSION_FOOTER_HEIGHT, 24.0); + } + + #[test] + fn route_chrome_crossfade_never_duplicates_picker_controls() { + assert_eq!(route_chrome_opacities(1.0), (1.0, 0.0)); + assert_eq!(route_chrome_opacities(0.5), (0.0, 0.0)); + assert_eq!(route_chrome_opacities(0.0), (0.0, 1.0)); + for step in 0..=20 { + let (new_thread, session) = route_chrome_opacities(step as f32 / 20.0); + assert!(new_thread == 0.0 || session == 0.0); + } + } + #[test] fn staged_comments_alone_are_content() { assert!(!composer_has_content(" ", 0, 0)); diff --git a/crates/ui/src/composer_dock.rs b/crates/ui/src/composer_dock.rs new file mode 100644 index 000000000..47bce807b --- /dev/null +++ b/crates/ui/src/composer_dock.rs @@ -0,0 +1,676 @@ +//! One retargetable clock for the main composer's route choreography. Geometry +//! is measured in prepaint, so a resize never substitutes a guessed endpoint. + +use std::{cell::RefCell, rc::Rc, time::Instant}; + +mod panel_handoff; + +use gpui::{ + AnyElement, App, Bounds, Element, GlobalElementId, InspectorElementId, IntoElement, LayoutId, + Pixels, Window, point, px, +}; + +/// Critically damped motion: no oscillation, and both position and velocity +/// survive a new target. Twelve time constants settle within a fraction of a +/// pixel over the intended 420/470ms handoff, even across a large window. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Glide { + pub value: f32, + pub velocity: f32, + target: f32, +} + +impl Glide { + pub fn new(value: f32) -> Self { + Self { + value, + velocity: 0.0, + target: value, + } + } + + pub fn advance(&mut self, target: f32, seconds: f32, duration: f32) { + self.target = target; + let omega = 12.0 / duration; + let displacement = self.value - target; + let c = self.velocity + omega * displacement; + let decay = (-omega * seconds).exp(); + self.value = target + (displacement + c * seconds) * decay; + self.velocity = (self.velocity - omega * c * seconds) * decay; + if !self.active() { + *self = Self::new(target); + } + } + + fn active(&self) -> bool { + (self.value - self.target).abs() > 0.0005 || self.velocity.abs() > 0.005 + } +} + +pub(crate) fn stage(value: f32, start: f32, end: f32) -> f32 { + let t = ((value - start) / (end - start)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct DockFrame { + /// Canonical position: zero is the hero, one is the established thread. + pub amount: f32, + pub docked: bool, + pub active: bool, + visuals: Visuals, +} + +impl DockFrame { + pub fn settled(docked: bool) -> Self { + Self { + amount: if docked { 1.0 } else { 0.0 }, + docked, + active: false, + visuals: Visuals::settled(docked), + } + } + + pub fn transcript(self) -> f32 { + self.visuals.transcript + } + pub fn selectors(self) -> f32 { + self.visuals.selectors + } + pub fn footer(self) -> f32 { + self.visuals.footer + } + pub fn dissolve(self) -> f32 { + self.visuals.dissolve + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Visuals { + transcript: f32, + selectors: f32, + footer: f32, + dissolve: f32, +} + +impl Visuals { + fn settled(docked: bool) -> Self { + let value = if docked { 1.0 } else { 0.0 }; + Self { + transcript: value, + selectors: 1.0 - value, + footer: value, + dissolve: value, + } + } + + fn advance(self, docked: bool, time: f32) -> Self { + let target = Self::settled(docked); + let blend = |from, to, start, end| crate::motion::lerp(from, to, stage(time, start, end)); + if docked { + Self { + transcript: blend(self.transcript, target.transcript, 0.20, 0.65), + selectors: blend(self.selectors, target.selectors, 0.55, 0.78), + footer: blend(self.footer, target.footer, 0.78, 1.0), + dissolve: blend(self.dissolve, target.dissolve, 0.06, 0.88), + } + } else { + // On return, release thread chrome first; unfold the hero behind + // the rising input and restore destination selectors near arrival. + Self { + transcript: blend(self.transcript, target.transcript, 0.0, 0.25), + selectors: blend(self.selectors, target.selectors, 0.50, 0.95), + footer: blend(self.footer, target.footer, 0.0, 0.18), + dissolve: blend(self.dissolve, target.dissolve, 0.08, 0.85), + } + } + } + + fn return_from_panel(self, time: f32) -> Self { + // The short fade-through has its own clock: destination controls must + // arrive with the input, not trail the longer vertical-glide schedule. + Self { + transcript: self.transcript * (1.0 - stage(time, 0.0, 0.18)), + footer: self.footer * (1.0 - stage(time, 0.0, 0.18)), + selectors: crate::motion::lerp(self.selectors, 1.0, stage(time, 0.26, 0.85)), + // The mask follows the actual surface. Keep it hidden through + // the 0.22 horizontal geometry switch, then reveal both together. + dissolve: self.dissolve * (1.0 - stage(time, 0.26, 0.80)), + } + } +} + +pub(crate) struct DockState { + pane: panel_handoff::PanelHandoff, + phase: Glide, + last_frame: Option, + pub frame: DockFrame, + position: Option<(Glide, Glide)>, + last_geometry: Option, + last_docked: bool, + moving: bool, + width: Option, + last_width_frame: Option, + route_changed: bool, + choreography: Option<(Instant, Visuals)>, + panel_return: bool, + panel_departure: bool, + column_width: Option, + departing_column_width: Option, +} + +impl Default for DockState { + fn default() -> Self { + Self { + pane: Default::default(), + phase: Glide::new(0.0), + last_frame: None, + frame: DockFrame::settled(false), + position: None, + last_geometry: None, + last_docked: false, + moving: false, + width: None, + last_width_frame: None, + route_changed: false, + choreography: None, + panel_return: false, + panel_departure: false, + column_width: None, + departing_column_width: None, + } + } +} + +impl DockState { + /// Retained transcript pixels belong to the source column. Letting them + /// reflow into the hero's wider layout before fading creates an exit flash. + pub fn transcript_width(&mut self, target: f32, docked: bool, panel_handoff: bool) -> f32 { + if !docked && self.frame.docked && panel_handoff { + self.departing_column_width = self.column_width; + } + if docked || !panel_handoff { + self.departing_column_width = None; + } + self.column_width = Some(target); + self.departing_column_width.unwrap_or(target) + } + + pub fn observe_pane(&mut self, docked: bool, target: f32, enabled: bool, now: Instant) -> bool { + self.pane.sample( + docked, + target, + enabled, + now, + 0.320 * crate::motion::speed_scale(), + ) + } + + pub fn opacity(&self) -> f32 { + self.pane.opacity() + } + + pub fn layout_width(&mut self, target: f32, reduced: bool, now: Instant) -> f32 { + let dt = if self.route_changed { + 0.0 + } else { + self.last_width_frame.map_or(0.0, |last| { + now.saturating_duration_since(last).as_secs_f32() + }) + }; + self.last_width_frame = Some(now); + let width = self.width.get_or_insert(Glide::new(target)); + if let Some(progress) = self.pane.progress { + // Change horizontal geometry only inside the invisible interval. + if progress >= 0.22 { + *width = Glide::new(target); + } + } else if reduced || (!self.frame.active && !self.moving) { + *width = Glide::new(target); + } else { + width.advance(target, dt, duration(self.frame.docked)); + } + width.value.max(0.0) + } + + pub fn tick(&mut self, docked: bool, reduced: bool, now: Instant) -> DockFrame { + self.route_changed = docked != self.frame.docked; + if self.route_changed || reduced { + self.panel_return = !reduced && !docked && self.pane.progress.is_some(); + self.panel_departure = !reduced && docked && self.pane.progress.is_some(); + } + let target = if docked { 1.0 } else { 0.0 }; + if reduced || self.last_frame.is_none() || self.position.is_none() { + self.phase = Glide::new(target); + self.choreography = None; + } else { + // A click after an idle window is the START of the new motion, + // not elapsed animation time. Keep the last painted velocity. + let dt = if docked != self.frame.docked { + 0.0 + } else { + now.saturating_duration_since(self.last_frame.unwrap()) + .as_secs_f32() + }; + self.phase.advance(target, dt, duration(docked)); + if self.route_changed { + // Capture the exact previous visual state on interruption. + self.choreography = Some((now, self.frame.visuals)); + } + } + self.last_frame = Some(now); + let visuals = if let Some((started, from)) = self.choreography { + let total = if self.panel_return { + 0.320 * crate::motion::speed_scale() + } else { + duration(docked) + }; + let time = now.saturating_duration_since(started).as_secs_f32() / total; + if time >= 1.0 { + self.choreography = None; + } + let mut visuals = if self.panel_return { + from.return_from_panel(time) + } else { + from.advance(docked, time) + }; + if self.panel_departure { + let panel_time = now.saturating_duration_since(started).as_secs_f32() + / (0.320 * crate::motion::speed_scale()); + visuals.dissolve = + crate::motion::lerp(from.dissolve, 1.0, stage(panel_time, 0.0, 0.18)); + } + visuals + } else { + Visuals::settled(docked) + }; + if self.panel_return { + let amount = if self.pane.progress.is_some_and(|p| p < 0.22) { + self.frame.amount + } else { + 0.0 + }; + // Keep the retargetable state aligned with what was painted so a + // reversal cannot revive the old, longer height animation. + self.phase = Glide::new(amount); + } + self.frame = DockFrame { + amount: self.phase.value.clamp(0.0, 1.0), + docked, + active: self.phase.active() || self.choreography.is_some(), + visuals, + }; + self.frame + } +} + +fn duration(docked: bool) -> f32 { + (if docked { 0.420 } else { 0.470 }) * crate::motion::speed_scale() +} + +pub(crate) type SharedDock = Rc>; + +/// The child stays in this same layout slot on both routes. Only its prepaint +/// origin changes; input hitboxes, selection and caret travel with its pixels. +pub(crate) struct DockedComposer { + child: AnyElement, + state: SharedDock, + viewport_height: f32, + reduced: bool, + now: Instant, +} + +pub(crate) fn docked_composer( + child: impl IntoElement, + state: SharedDock, + viewport_height: f32, + reduced: bool, + now: Instant, +) -> DockedComposer { + DockedComposer { + child: child.into_any_element(), + state, + viewport_height, + reduced, + now, + } +} + +impl Element for DockedComposer { + type RequestLayoutState = (); + type PrepaintState = (); + fn id(&self) -> Option { + None + } + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, ()) { + (self.child.request_layout(window, cx), ()) + } + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + _: &mut (), + window: &mut Window, + cx: &mut App, + ) { + let mut state = self.state.borrow_mut(); + let docked = state.frame.docked; + let x = f32::from(bounds.left()); + // Anchor by the top of the input surface, not its shrinking bottom. + let y = if docked { + f32::from(bounds.top()) + } else { + (self.viewport_height - f32::from(bounds.size.height)) * 0.5 + 8.0 + }; + let dt = if state.last_docked != docked { + 0.0 + } else { + state.last_geometry.map_or(0.0, |last| { + self.now.saturating_duration_since(last).as_secs_f32() + }) + }; + state.last_geometry = Some(self.now); + state.moving |= state.last_docked != docked || state.frame.active; + state.last_docked = docked; + let moving = state.moving; + let handoff = state.pane.progress; + let position = state.position.get_or_insert((Glide::new(x), Glide::new(y))); + if let Some(progress) = handoff { + if progress >= 0.22 { + let travel = if docked { 12.0 } else { 8.0 }; + *position = ( + Glide::new(x), + Glide::new(y + travel * (1.0 - stage(progress, 0.22, 1.0))), + ); + } + } else if self.reduced || !moving { + *position = (Glide::new(x), Glide::new(y)); + } else { + position.0.advance(x, dt, duration(docked)); + position.1.advance(y, dt, duration(docked)); + } + let offset = point( + px(position.0.value - x), + px(position.1.value - f32::from(bounds.top())), + ); + let unsettled = (position.0.value - x).abs() > 0.1 + || (position.1.value - y).abs() > 0.1 + || position.0.velocity.abs() > 1.0 + || position.1.velocity.abs() > 1.0; + state.moving = !self.reduced + && (unsettled || state.frame.active || state.width.is_some_and(|width| width.active())); + if state.moving { + window.request_animation_frame(); + } + drop(state); + window.with_element_offset(offset, |window| self.child.prepaint(window, cx)); + } + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut (), + _: &mut (), + window: &mut Window, + cx: &mut App, + ) { + self.child.paint(window, cx); + } +} + +impl IntoElement for DockedComposer { + type Element = Self; + fn into_element(self) -> Self { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Context, Render, canvas, div, prelude::*}; + + #[test] + fn panel_handoff_hides_background_during_geometry_switch_in_both_sidebar_states() { + for sidebar in [0.0, 224.0] { + for docked in [false, true] { + let mut state = DockState::default(); + let now = Instant::now(); + let source_pane = if docked { 0.0 } else { 480.0 }; + let target_pane = if docked { 480.0 } else { 0.0 }; + state.observe_pane(!docked, source_pane, true, now); + state.tick(!docked, false, now); + state.position = Some((Glide::new(sidebar), Glide::new(360.0))); + state.observe_pane(docked, target_pane, true, now); + state.tick(docked, false, now); + for progress in [0.19, 0.22, 0.25] { + let at = now + + std::time::Duration::from_secs_f32( + progress * 0.320 * crate::motion::speed_scale(), + ); + state.observe_pane(docked, target_pane, true, at); + assert_eq!(state.tick(docked, false, at).dissolve(), 1.0); + } + let at = + now + std::time::Duration::from_secs_f32(0.321 * crate::motion::speed_scale()); + state.observe_pane(docked, target_pane, true, at); + assert_eq!( + state.tick(docked, false, at).dissolve(), + if docked { 1.0 } else { 0.0 } + ); + } + } + } + + #[test] + fn panel_exit_retains_source_transcript_width_only_until_handoff_ends() { + let mut state = DockState::default(); + assert_eq!(state.transcript_width(540.0, true, false), 540.0); + state.frame = DockFrame::settled(true); + assert_eq!(state.transcript_width(1040.0, false, true), 540.0); + state.frame = DockFrame::settled(false); + assert_eq!(state.transcript_width(1040.0, false, true), 540.0); + assert_eq!(state.transcript_width(1040.0, false, false), 1040.0); + assert_eq!(state.transcript_width(540.0, true, true), 540.0); + state.frame = DockFrame::settled(true); + assert_eq!(state.transcript_width(1040.0, false, false), 1040.0); + } + + #[test] + fn panel_return_sizes_while_hidden_and_finishes_controls_with_input() { + let now = Instant::now(); + let mut state = DockState::default(); + state.observe_pane(true, 480.0, true, now); + state.tick(true, false, now); + state.position = Some((Glide::new(100.0), Glide::new(700.0))); + state.observe_pane(false, 0.0, true, now); + assert_eq!(state.tick(false, false, now).amount, 1.0); + let hidden = now + std::time::Duration::from_secs_f32(0.075 * crate::motion::speed_scale()); + state.observe_pane(false, 0.0, true, hidden); + let frame = state.tick(false, false, hidden); + assert_eq!(state.opacity(), 0.0); + assert_eq!(frame.amount, 0.0); + for seconds in [0.321, 0.400, 0.500] { + let at = + now + std::time::Duration::from_secs_f32(seconds * crate::motion::speed_scale()); + state.observe_pane(false, 0.0, true, at); + let frame = state.tick(false, false, at); + assert_eq!(frame.selectors(), 1.0); + assert_eq!(frame.dissolve(), 0.0); + assert_eq!(frame.amount, 0.0); + } + } + + #[gpui::test] + fn measured_dock_retargets_without_a_first_frame_jump(cx: &mut gpui::TestAppContext) { + struct Fixture { + state: SharedDock, + now: Instant, + docked: bool, + width: f32, + measured: Rc>>>, + } + impl Render for Fixture { + fn render(&mut self, window: &mut Window, _: &mut Context) -> impl IntoElement { + self.state.borrow_mut().tick(self.docked, false, self.now); + let width = self + .state + .borrow_mut() + .layout_width(self.width, false, self.now); + let measured = self.measured.clone(); + div() + .size_full() + .flex() + .flex_col() + .child(div().flex_1()) + .child(docked_composer( + div().relative().w(px(width)).h(px(124.0)).mx_auto().child( + canvas( + move |bounds, _, _| measured.set(Some(bounds)), + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ), + self.state.clone(), + f32::from(window.viewport_size().height), + false, + self.now, + )) + } + } + let measured = Rc::new(std::cell::Cell::new(None)); + let now = Instant::now(); + let handle = cx.add_window(|_, _| Fixture { + state: Default::default(), + now, + docked: false, + width: 400.0, + measured: measured.clone(), + }); + let draw = |cx: &mut gpui::TestAppContext| { + cx.update_window(handle.into(), |_, window, cx| { + window.draw(cx).clear(); + }) + .unwrap(); + measured.get().unwrap() + }; + let origin = draw(cx); + handle + .update(cx, |fixture, _, cx| { + fixture.now = now + std::time::Duration::from_secs(30); + fixture.docked = true; + cx.notify(); + }) + .unwrap(); + assert_eq!(draw(cx), origin); + handle + .update(cx, |fixture, _, cx| { + fixture.now += std::time::Duration::from_millis(100); + cx.notify(); + }) + .unwrap(); + let moving = draw(cx); + assert!(moving.top() > origin.top()); + handle + .update(cx, |fixture, _, cx| { + fixture.docked = false; + fixture.width = 300.0; + cx.notify(); + }) + .unwrap(); + assert_eq!( + draw(cx), + moving, + "reversal and resize must start at the painted bounds" + ); + for _ in 0..90 { + handle + .update(cx, |fixture, _, cx| { + fixture.now += std::time::Duration::from_millis(16); + cx.notify(); + }) + .unwrap(); + draw(cx); + } + let settled = draw(cx); + assert!((f32::from(settled.top() - origin.top())).abs() < 0.1); + assert!((f32::from(settled.size.width) - 300.0).abs() < 0.1); + } + + #[test] + fn idle_time_is_not_consumed_by_a_new_target() { + let mut state = DockState::default(); + let now = Instant::now(); + state.tick(false, false, now); + state.position = Some((Glide::new(0.0), Glide::new(300.0))); + let click = now + std::time::Duration::from_secs(30); + assert_eq!(state.tick(true, false, click).amount, 0.0); + let moving = state.tick(true, false, click + std::time::Duration::from_millis(100)); + assert!(moving.amount > 0.0 && moving.amount < 1.0); + let reverse = state.tick(false, false, click + std::time::Duration::from_millis(100)); + assert_eq!(moving.amount, reverse.amount); + assert_eq!(moving.visuals, reverse.visuals); + } + + #[test] + fn choreography_is_direction_specific_and_selectors_never_duplicate() { + let new = Visuals::settled(false); + let thread = Visuals::settled(true); + assert_eq!(new.advance(true, 0.19).transcript, 0.0); + assert_eq!(new.advance(true, 0.65).transcript, 1.0); + assert_eq!(new.advance(true, 0.55).selectors, 1.0); + assert_eq!(thread.advance(false, 0.25).transcript, 0.0); + assert_eq!(thread.advance(false, 0.49).selectors, 0.0); + for step in 0..=100 { + let time = step as f32 / 100.0; + for values in [new.advance(true, time), thread.advance(false, time)] { + assert!(values.selectors == 0.0 || values.footer == 0.0); + } + } + } + #[test] + fn reversal_preserves_position_and_velocity() { + let mut glide = Glide::new(300.0); + glide.advance(800.0, 0.12, 0.42); + let before = glide; + glide.advance(300.0, 0.0, 0.47); + assert!((glide.value - before.value).abs() < 0.001); + assert!((glide.velocity - before.velocity).abs() < 0.001); + for _ in 0..60 { + glide.advance(300.0, 1.0 / 120.0, 0.47); + } + assert!((glide.value - 300.0).abs() < 0.5); + } + #[test] + fn normal_dock_is_monotone_and_frame_rate_independent() { + for hz in [30, 60, 120] { + let mut glide = Glide::new(300.0); + for _ in 0..(hz / 2) { + let old = glide.value; + glide.advance(800.0, 1.0 / hz as f32, 0.42); + assert!(glide.value >= old && glide.value <= 800.0); + } + assert!((glide.value - 800.0).abs() < 0.1); + } + } + #[test] + fn initial_and_reduced_motion_frames_snap() { + let mut state = DockState::default(); + let now = Instant::now(); + assert_eq!(state.tick(true, false, now).amount, 1.0); + assert_eq!(state.tick(false, true, now).amount, 0.0); + assert!(!state.frame.active); + } +} diff --git a/crates/ui/src/composer_dock/panel_handoff.rs b/crates/ui/src/composer_dock/panel_handoff.rs new file mode 100644 index 000000000..d40d29be9 --- /dev/null +++ b/crates/ui/src/composer_dock/panel_handoff.rs @@ -0,0 +1,99 @@ +//! A fade-through when navigation changes the conversation's horizontal frame. +use std::time::Instant; + +fn ease(value: f32, start: f32, end: f32) -> f32 { + let t = ((value - start) / (end - start)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +#[derive(Default)] +pub(super) struct PanelHandoff { + previous: Option<(bool, f32)>, + started: Option, + from_opacity: f32, + pub progress: Option, +} + +impl PanelHandoff { + pub fn opacity(&self) -> f32 { + self.progress.map_or(1.0, |p| { + self.from_opacity * (1.0 - ease(p, 0.0, 0.18)) + ease(p, 0.26, 1.0) + }) + } + + pub fn sample( + &mut self, + docked: bool, + width: f32, + enabled: bool, + now: Instant, + duration: f32, + ) -> bool { + if !enabled { + *self = Self::default(); + return false; + } + if self.previous.is_some_and(|(old_docked, old_width)| { + old_docked != docked && ((old_width - width).abs() > 0.5 || self.started.is_some()) + }) { + self.from_opacity = self.opacity(); + self.started = Some(now); + } + self.previous = Some((docked, width)); + self.progress = self.started.and_then(|start| { + let p = now.saturating_duration_since(start).as_secs_f32() / duration; + (p < 1.0).then_some(p) + }); + if self.progress.is_none() { + self.started = None; + } + self.progress.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn geometry_switch_is_hidden_in_both_directions() { + for (docked, from, to) in [(true, 0.0, 480.0), (false, 480.0, 0.0)] { + let mut handoff = PanelHandoff::default(); + let now = Instant::now(); + handoff.sample(!docked, from, true, now, 0.320); + assert!(handoff.sample(docked, to, true, now, 0.320)); + assert_eq!(handoff.opacity(), 1.0); + for millis in [60, 70, 80] { + handoff.sample(docked, to, true, now + Duration::from_millis(millis), 0.320); + assert_eq!(handoff.opacity(), 0.0); + } + assert!(!handoff.sample(docked, to, true, now + Duration::from_millis(321), 0.320)); + assert_eq!(handoff.opacity(), 1.0); + } + } + + #[test] + fn reversal_preserves_opacity_and_reduced_motion_cancels() { + let mut handoff = PanelHandoff::default(); + let now = Instant::now(); + handoff.sample(false, 0.0, true, now, 0.320); + handoff.sample(true, 480.0, true, now, 0.320); + let later = now + Duration::from_millis(180); + handoff.sample(true, 480.0, true, later, 0.320); + let alpha = handoff.opacity(); + handoff.sample(false, 0.0, true, later, 0.320); + assert_eq!(handoff.opacity(), alpha); + assert!(!handoff.sample(false, 0.0, false, later, 0.320)); + assert_eq!(handoff.opacity(), 1.0); + } + + #[test] + fn ordinary_resizing_and_same_column_navigation_do_not_fade() { + let mut handoff = PanelHandoff::default(); + let now = Instant::now(); + for (docked, width) in [(true, 0.0), (true, 480.0), (true, 0.0), (false, 0.0)] { + assert!(!handoff.sample(docked, width, true, now, 0.320)); + } + } +} diff --git a/crates/ui/src/edge_fade.rs b/crates/ui/src/edge_fade.rs index 8be7e8a49..1329f0e1a 100644 --- a/crates/ui/src/edge_fade.rs +++ b/crates/ui/src/edge_fade.rs @@ -187,6 +187,7 @@ impl Element for EdgeFaded { bounds.origin.y += inset; bounds.size.height -= inset; bounds.size.height += px(self.outset_bottom); + bounds.size.height = bounds.size.height.max(px(0.0)); EdgeFade { bounds, band: px(self.band), diff --git a/crates/ui/src/files/mod.rs b/crates/ui/src/files/mod.rs index 2b73e8b85..894aa8095 100644 --- a/crates/ui/src/files/mod.rs +++ b/crates/ui/src/files/mod.rs @@ -309,14 +309,19 @@ impl Render for FilesSurface { .child(content); let is_editor = self.presentation.is_editor(); let mut header = None; + let mut preview_split_right = None; let body = if split_editor { let wide = self.preview.is_wide(); let tree_width = if wide { - self.preview.tree_width() + self.preview.tree_width_frame(window, cx) } else { self.preview.narrow_tree_width() }; let openness = self.preview.tree_sidebar_frame(window, cx); + if wide && self.preview.tree_sidebar_visible() { + preview_split_right = + Some(tree_width * openness - preview::TREE_SPLIT_HITBOX_HALF_WIDTH); + } // Same arrangement as the outer right-sidebar toggle: the trigger // is outside the animated controls, in a permanently mounted slot. let toggle_width = @@ -380,10 +385,7 @@ impl Render for FilesSurface { .border_color(theme.border) .child(tree_pane), ), - ) - .when(wide && self.preview.tree_sidebar_visible(), |pane| { - pane.child(self.preview_split_handle(cx)) - }), + ), ) .into_any_element() } else { @@ -392,6 +394,8 @@ impl Render for FilesSurface { let measured_width = self.preview.width_cell(); let entity = cx.entity(); let editor_context_menu = self.render_editor_context_menu(&theme, cx); + let preview_split_handle = + preview_split_right.map(|right| self.preview_split_handle(right, cx)); div() .id(SharedString::from(format!( "files-surface-{}", @@ -424,6 +428,7 @@ impl Render for FilesSurface { .flex_col() .children(header) .child(div().flex_1().min_h_0().w_full().child(body)) + .children(preview_split_handle) .children(editor_context_menu) } } diff --git a/crates/ui/src/files/preview.rs b/crates/ui/src/files/preview.rs index 2ad202e6c..9be71fb9c 100644 --- a/crates/ui/src/files/preview.rs +++ b/crates/ui/src/files/preview.rs @@ -34,6 +34,9 @@ use crate::{ const PREVIEW_LINE_HEIGHT: f32 = 20.0; const WIDE_BREAKPOINT: f32 = 680.0; const TREE_SPLIT_DEFAULT: f32 = 286.0; +const TREE_SPLIT_MIN: f32 = 220.0; +const TREE_SPLIT_MAX: f32 = 360.0; +pub(super) const TREE_SPLIT_HITBOX_HALF_WIDTH: f32 = 10.0; const EDITOR_COMMENT_CARD_WIDTH: f32 = 320.0; const EDITOR_COMMENT_CARD_MARGIN: f32 = 8.0; const EDITOR_COMMENT_CARD_MIN_ANCHORED_WIDTH: f32 = 220.0; @@ -149,6 +152,10 @@ pub(super) struct FilePreviewState { tree_sidebar_dismissed: bool, tree_width: f32, tree_motion: TreeSidebarMotion, + tree_edge_bounce: Option, + tree_resize_edge: Option, + tree_resize_active: bool, + tree_resize_dragging: bool, comment_anchors: HashMap>, comment_draft: Option, active_comment: Option, @@ -181,6 +188,10 @@ impl FilePreviewState { tree_sidebar_dismissed: false, tree_width: TREE_SPLIT_DEFAULT, tree_motion: TreeSidebarMotion::default(), + tree_edge_bounce: None, + tree_resize_edge: None, + tree_resize_active: false, + tree_resize_dragging: false, comment_anchors: HashMap::new(), comment_draft: None, active_comment: None, @@ -197,6 +208,10 @@ impl FilePreviewState { self.close_requested = false; self.tree_sidebar_visible = false; self.tree_motion = TreeSidebarMotion::default(); + self.tree_edge_bounce = None; + self.tree_resize_edge = None; + self.tree_resize_active = false; + self.tree_resize_dragging = false; self.comment_anchors.clear(); self.comment_draft = None; self.active_comment = None; @@ -326,6 +341,10 @@ impl FilePreviewState { } fn toggle_tree_sidebar(&mut self) { + self.tree_edge_bounce = None; + self.tree_resize_edge = None; + self.tree_resize_active = false; + self.tree_resize_dragging = false; let previous = self.tree_sidebar_visible(); if previous { self.tree_sidebar_visible = false; @@ -381,8 +400,38 @@ impl FilePreviewState { openness } - pub(super) fn tree_width(&self) -> f32 { - self.tree_width + pub(super) fn tree_width_frame(&self, window: &mut Window, cx: &App) -> f32 { + let Some(bounce) = self.tree_edge_bounce else { + return self.tree_width; + }; + if crate::motion::reduced_motion(cx) || !self.tree_sidebar_visible() { + return self.tree_width; + } + let total = Duration::from_millis(crate::motion::RESIZE_EDGE_BOUNCE_MS) + .mul_f32(crate::motion::speed_scale()); + let raw = Instant::now() + .saturating_duration_since(bounce.started) + .as_secs_f32() + / total.as_secs_f32(); + if raw >= 1.0 { + return self.tree_width; + } + window.request_animation_frame(); + self.tree_width + crate::motion::resize_bounce_offset(bounce.edge, raw) + } + + pub(super) fn tree_resize_active(&self) -> bool { + self.tree_resize_active + } + + pub(super) fn tree_resize_constrained(&self) -> bool { + self.tree_resize_dragging && !self.tree_resize_active + } + + fn finish_tree_resize(&mut self) { + self.tree_resize_active = false; + self.tree_resize_dragging = false; + self.tree_resize_edge = None; } pub(super) fn has_unsaved_changes(&self) -> bool { @@ -3120,23 +3169,80 @@ impl FilesSurface { _window: &mut Window, cx: &mut Context, ) { - let width = f32::from(event.bounds.right() - event.event.position.x); - self.preview.tree_width = width.clamp(220.0, 360.0); + let requested = f32::from(event.bounds.right() - event.event.position.x); + let sample = crate::motion::resize_drag_sample( + requested, + TREE_SPLIT_MIN, + TREE_SPLIT_MAX, + self.preview.tree_resize_edge, + crate::motion::reduced_motion(cx), + ); + self.preview.tree_width = sample.width; + self.preview.tree_resize_dragging = true; + self.preview.tree_resize_active = sample.edge.is_none(); + if sample.starts_bounce { + self.preview.tree_edge_bounce = sample.edge.map(crate::motion::ResizeEdgeBounce::new); + } else if sample.edge.is_none() { + self.preview.tree_edge_bounce = None; + } + self.preview.tree_resize_edge = sample.edge; cx.notify(); } - pub(super) fn preview_split_handle(&self, cx: &mut Context) -> AnyElement { - let color = Theme::of(cx).border_strong; + pub(super) fn preview_split_handle(&self, right: f32, cx: &mut Context) -> AnyElement { + let theme = Theme::of(cx); + let fade_key = "pane-resize-files-preview-split"; + let hover_highlight = crate::motion::hover_blend( + fade_key, + theme.border_strong.opacity(0.0), + theme.border_strong, + ); + let highlight = if self.preview.tree_resize_constrained() { + theme.border_strong.opacity(0.0) + } else if self.preview.tree_resize_active() { + theme.border_strong + } else { + hover_highlight + }; + let clear = highlight.opacity(0.0); div() .id("files-preview-split") .absolute() - .left(px(-3.0)) + .right(px(right)) .top_0() .bottom_0() - .w(px(6.0)) + .w(px(TREE_SPLIT_HITBOX_HALF_WIDTH * 2.0)) .occlude() .cursor_col_resize() - .hover(move |style| style.bg(color)) + .on_hover(crate::motion::hover_listener(fade_key)) + .child( + div() + .absolute() + .top_0() + .bottom_0() + .left(px(TREE_SPLIT_HITBOX_HALF_WIDTH)) + .w(px(1.0)) + .flex() + .flex_col() + .child(div().flex_1().bg(gpui::linear_gradient( + 180.0, + gpui::linear_color_stop(clear, 0.0), + gpui::linear_color_stop(highlight, 1.0), + ))) + .child(div().flex_1().bg(gpui::linear_gradient( + 180.0, + gpui::linear_color_stop(highlight, 0.0), + gpui::linear_color_stop(clear, 1.0), + ))), + ) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, cx| { + this.preview.tree_resize_dragging = true; + this.preview.tree_resize_active = true; + cx.notify(); + }), + ) .on_drag( PreviewSplitResize, |_, _point: Point, _, cx| { @@ -3144,6 +3250,28 @@ impl FilesSurface { cx.new(|_| PreviewDragGhost) }, ) + .on_mouse_up( + gpui::MouseButton::Left, + cx.listener(|this, event: &gpui::MouseUpEvent, window, cx| { + if event.click_count == 2 { + this.preview.tree_width = TREE_SPLIT_DEFAULT; + this.preview.tree_edge_bounce = None; + } + this.preview.finish_tree_resize(); + crate::motion::set_hover(fade_key, false, crate::motion::reduced_motion(cx)); + window.refresh(); + cx.notify(); + }), + ) + .on_mouse_up_out( + gpui::MouseButton::Left, + cx.listener(|this, _, window, cx| { + this.preview.finish_tree_resize(); + crate::motion::set_hover(fade_key, false, crate::motion::reduced_motion(cx)); + window.refresh(); + cx.notify(); + }), + ) .into_any_element() } } @@ -3241,6 +3369,31 @@ fn read_only_message(reason: Option) -> SharedString { mod tests { use super::*; + #[test] + fn tree_split_uses_the_standard_resize_geometry_and_limits() { + assert_eq!(TREE_SPLIT_HITBOX_HALF_WIDTH * 2.0, 20.0); + let min = crate::motion::resize_drag_sample( + TREE_SPLIT_MIN - 1.0, + TREE_SPLIT_MIN, + TREE_SPLIT_MAX, + None, + false, + ); + let max = crate::motion::resize_drag_sample( + TREE_SPLIT_MAX + 1.0, + TREE_SPLIT_MIN, + TREE_SPLIT_MAX, + None, + false, + ); + assert_eq!(min.width, TREE_SPLIT_MIN); + assert_eq!(min.edge, Some(crate::motion::ResizeEdge::Min)); + assert!(min.starts_bounce); + assert_eq!(max.width, TREE_SPLIT_MAX); + assert_eq!(max.edge, Some(crate::motion::ResizeEdge::Max)); + assert!(max.starts_bounce); + } + fn cached_document(path: &str, text: &str) -> FileDocument { let mut document = FileDocument::loading(DocumentKey { chat_id: "chat-1".into(), diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 30726385b..f3af6f072 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -23,6 +23,7 @@ pub mod changes; mod comment_ui; pub mod comments; pub mod composer; +mod composer_dock; mod context_usage; pub mod edge_fade; pub mod files; @@ -35,6 +36,9 @@ pub mod links; pub mod loaders; pub mod markdown; pub mod motion; +mod new_thread_background_effects; +mod new_thread_background_image; +mod new_thread_background_mask; pub mod notify; pub mod pickers; pub mod popover; @@ -287,8 +291,8 @@ fn open_main_window( titlebar: Some(TitlebarOptions { title: None, appears_transparent: true, - // Centered on the titlebar's content line (40px bar, content - // shifted 4px down, lights ~12px tall → center 22). + // Native lights are 14px tall: top 14 → center 21, matching + // the 38px titlebar row with 4px top-only content padding. traffic_light_position: Some(gpui::point(px(14.), px(14.))), }), // Our own titlebar strip drags the window (WindowControlArea:: diff --git a/crates/ui/src/motion.rs b/crates/ui/src/motion.rs index 768347f1e..4849c5832 100644 --- a/crates/ui/src/motion.rs +++ b/crates/ui/src/motion.rs @@ -359,6 +359,11 @@ pub const RESIZE: MotionSpec = MotionSpec::new(200, EASE_OUT); pub const TAB_SLIDE: MotionSpec = MotionSpec::new(150, EASE_OUT); /// Diff-pane per-file collapse: 180ms height (§1.11). pub const COLLAPSE: MotionSpec = MotionSpec::new(180, EASE_OUT); +/// Reversible new-thread ↔ session handoff. The shared composer moves and +/// morphs on a fast-starting, soft-landing curve while the canvas/transcript +/// crossfade is staged around it. Slightly longer than a utility transition, +/// but still short enough to acknowledge a send immediately. +pub const NEW_THREAD_TRANSITION: MotionSpec = MotionSpec::new(420, EASE_RESORT); /// Diff-pane chevron rotate: 200ms (§1.11; approximated as a crossfade — gpui /// divs have no rotation transform at the pinned rev, same caveat as scale). pub const CHEVRON: MotionSpec = MotionSpec::new(200, EASE); @@ -377,6 +382,91 @@ pub const ZERON_PULSE: MotionSpec = MotionSpec::new(2400, EASE); /// Gradient matrix spinner wave period: 750ms. pub const GRADIENT_SPIN: MotionSpec = MotionSpec::new(750, EASE); +// --------------------------------------------------------------------------- +// Resize-edge feedback +// --------------------------------------------------------------------------- + +/// Pane resize limits acknowledge a held pointer without persisting an +/// out-of-range size. The small displacement is shared by the shell panes and +/// nested surface splits so every seam has the same physical response. +pub const RESIZE_EDGE_NUDGE: f32 = 5.0; +pub const RESIZE_EDGE_BOUNCE_MS: u64 = 220; +pub const RESIZE_EDGE_BOUNCE_OUT_FRACTION: f32 = 0.32; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResizeEdge { + Min, + Max, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ResizeDragSample { + pub width: f32, + pub edge: Option, + pub starts_bounce: bool, +} + +/// Clamp a resize sample while latching its constrained edge. A held pointer +/// starts one bounce rather than restarting it for every drag event. +pub fn resize_drag_sample( + requested: f32, + min: f32, + max: f32, + latched_edge: Option, + reduced_motion: bool, +) -> ResizeDragSample { + debug_assert!(min <= max); + let edge = if requested <= min { + Some(ResizeEdge::Min) + } else if requested >= max { + Some(ResizeEdge::Max) + } else { + None + }; + ResizeDragSample { + width: requested.clamp(min, max), + starts_bounce: !reduced_motion && edge.is_some() && edge != latched_edge, + edge, + } +} + +#[derive(Debug, Clone, Copy)] +pub struct ResizeEdgeBounce { + pub edge: ResizeEdge, + pub started: Instant, +} + +impl ResizeEdgeBounce { + pub fn new(edge: ResizeEdge) -> Self { + Self { + edge, + started: Instant::now(), + } + } +} + +fn smoothstep(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// Rounded two-phase pulse: ease out to the overshoot, then take a little +/// longer to ease home. Both joins have zero velocity. +pub fn resize_bounce_offset(edge: ResizeEdge, raw: f32) -> f32 { + let raw = raw.clamp(0.0, 1.0); + let magnitude = if raw < RESIZE_EDGE_BOUNCE_OUT_FRACTION { + smoothstep(raw / RESIZE_EDGE_BOUNCE_OUT_FRACTION) + } else { + 1.0 - smoothstep( + (raw - RESIZE_EDGE_BOUNCE_OUT_FRACTION) / (1.0 - RESIZE_EDGE_BOUNCE_OUT_FRACTION), + ) + } * RESIZE_EDGE_NUDGE; + match edge { + ResizeEdge::Min => -magnitude, + ResizeEdge::Max => magnitude, + } +} + // --------------------------------------------------------------------------- // Element helpers (paint-layer entrances/exits) // --------------------------------------------------------------------------- @@ -391,6 +481,19 @@ where }) } +/// New-thread composition entrance: opacity 0→1 while settling 10px down into +/// place over [`FADE_IN`]. Keeping the logo, target selectors, composer, and +/// checkout row under one animation makes the blank canvas arrive as a single +/// object instead of four independently moving pieces. +pub fn settle_down(id: impl Into, element: E) -> AnimationElement +where + E: Styled + IntoElement + 'static, +{ + element.with_animation(id, FADE_IN.animation(), |el, t| { + el.relative().opacity(t).top(px(-10.0 * (1.0 - t))) + }) +} + /// Quick opacity-only fade over [`FADE_QUICK`]. pub fn fade_quick(id: impl Into, element: E) -> AnimationElement where @@ -844,6 +947,8 @@ mod tests { assert_eq!(RESIZE.duration_ms, 200); assert_eq!(TAB_SLIDE.duration_ms, 150); assert_eq!(COLLAPSE.duration_ms, 180); + assert_eq!(NEW_THREAD_TRANSITION.duration_ms, 420); + assert_eq!(NEW_THREAD_TRANSITION.curve, EASE_RESORT); assert_eq!(CHEVRON.duration_ms, 200); assert_eq!(ZERON_PULSE.duration_ms, 2400); assert_eq!(GRADIENT_SPIN.duration_ms, 750); diff --git a/crates/ui/src/new_thread_background_effects.rs b/crates/ui/src/new_thread_background_effects.rs new file mode 100644 index 000000000..17a21d69f --- /dev/null +++ b/crates/ui/src/new_thread_background_effects.rs @@ -0,0 +1,499 @@ +//! Effects remain cached source-space images; a separate alpha mask follows layout. +use crate::settings::NewThreadBackgroundEffect; +use crate::theme::Theme; +use gpui::{Pixels, px}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Instant; + +/// Loading is independent of route motion. A cold result may arrive late, but +/// it must not suddenly appear at the route clock's already-advanced opacity. +#[derive(Default)] +pub(crate) struct Readiness { + image: Option<(gpui::ImageId, Instant)>, +} + +impl Readiness { + pub fn opacity(&mut self, image: Option, reduced: bool, now: Instant) -> f32 { + let Some(image) = image else { + self.image = None; + return 0.0; + }; + if self.image.is_none_or(|(previous, _)| previous != image) { + self.image = Some((image, now)); + } + if reduced { + return 1.0; + } + let elapsed = now + .saturating_duration_since(self.image.unwrap().1) + .as_secs_f32(); + crate::composer_dock::stage(elapsed / (0.120 * crate::motion::speed_scale()), 0.0, 1.0) + } +} +type EffectEntry = ( + (NewThreadBackgroundEffect, bool), + Option>, +); +#[derive(Debug)] +struct BackgroundLuminance { + width: u32, + height: u32, + pixels: Box<[u8]>, + colors: Box<[[u8; 4]]>, + effects: Mutex>, +} +impl BackgroundLuminance { + fn raster_image( + self: &Arc, + effect: NewThreadBackgroundEffect, + light: bool, + cx: &mut gpui::App, + ) -> Option> { + let mut effects = self.effects.lock().unwrap(); + let key = ( + effect, + light + && !matches!( + effect, + NewThreadBackgroundEffect::Dither | NewThreadBackgroundEffect::None + ), + ); + if let Some((_, image)) = effects.iter().find(|(cached, _)| *cached == key) { + return image.clone(); + } + // None marks the single pending job for this source/effect, not a viewport. + effects.push((key, None)); + drop(effects); + let source = self.clone(); + cx.spawn(async move |cx| { + let worker = source.clone(); + let image = cx + .background_executor() + .spawn(async move { + let pixels = match effect { + NewThreadBackgroundEffect::None => { + image::RgbaImage::from_fn(worker.width, worker.height, |x, y| { + let [r, g, b, a] = worker.colors[(y * worker.width + x) as usize]; + image::Rgba([b, g, r, a]) + }) + } + NewThreadBackgroundEffect::Dither => { + worker.dither_pixels(worker.width, worker.height) + } + NewThreadBackgroundEffect::Halftone => { + worker.halftone_pixels(worker.width, worker.height, light) + } + NewThreadBackgroundEffect::Ascii => worker.ascii_pixels(light), + _ => worker.scanline_pixels(light), + }; + Arc::new(gpui::RenderImage::new([image::Frame::new(pixels)])) + }) + .await; + cx.update(|cx| { + if let Some((_, ready)) = source + .effects + .lock() + .unwrap() + .iter_mut() + .find(|(cached, _)| *cached == key) + { + *ready = Some(image); + } + cx.refresh_windows(); + }); + }) + .detach(); + None + } + fn scanline_pixels(&self, light: bool) -> image::RgbaImage { + image::RgbaImage::from_fn(self.width, self.height, |x, y| { + let [r, g, b, a] = self.colors[(y * self.width + x) as usize]; + let gain = if y % 3 == 0 { 0.52 } else { 1.0 }; + let channel = |value: u8| { + if light { + (value as f32 + (255.0 - value as f32) * (1.0 - gain)) as u8 + } else { + (value as f32 * gain) as u8 + } + }; + image::Rgba([channel(b), channel(g), channel(r), a]) + }) + } + fn ascii_pixels(&self, light: bool) -> image::RgbaImage { + // Five-column bitmap glyphs, one column/row of spacing. These are + // artwork pixels rather than thousands of shaped UI text runs. + const GLYPHS: [[u8; 7]; 10] = [ + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 4, 0], + [0, 4, 0, 0, 4, 0, 0], + [0, 0, 0, 14, 0, 0, 0], + [0, 0, 14, 0, 14, 0, 0], + [0, 4, 4, 31, 4, 4, 0], + [0, 21, 14, 31, 14, 21, 0], + [10, 10, 31, 10, 31, 10, 10], + [17, 2, 4, 4, 8, 16, 17], + [14, 17, 23, 21, 23, 16, 14], + ]; + image::RgbaImage::from_fn(self.width, self.height, |x, y| { + let sx = (x / 6 * 6 + 3).min(self.width - 1); + let sy = (y / 8 * 8 + 4).min(self.height - 1); + let sample = (sy * self.width + sx) as usize; + let ink_density = if light { + 255 - self.pixels[sample] + } else { + self.pixels[sample] + }; + let index = ((ink_density as f32 / 255.0).sqrt() * 9.0) as usize; + let ink = + x % 6 < 5 && y % 8 < 7 && GLYPHS[index][y as usize % 8] & (1 << (4 - x % 6)) != 0; + let [r, g, b, a] = self.colors[(y * self.width + x) as usize]; + let [cr, cg, cb, _] = self.colors[sample]; + let mix = |base: u8, glyph: u8| { + let paper = if light { 255.0 } else { 0.0 }; + // Keep a colored image beneath the glyph texture in both themes. + (base as f32 * 0.60 + + if ink { + glyph as f32 * 0.40 + } else { + paper * 0.40 + }) as u8 + }; + image::Rgba([mix(b, cb), mix(g, cg), mix(r, cr), a]) + }) + } + fn halftone_pixels(&self, width: u32, height: u32, light: bool) -> image::RgbaImage { + let bounds = gpui::size(px(width as f32), px(height as f32)); + let paper = if light { 255 } else { 0 }; + let mut pixels = + image::RgbaImage::from_pixel(width, height, image::Rgba([paper, paper, paper, 255])); + for y in (0..height).step_by(4) { + for x in (0..width).step_by(4) { + let luma = self.sample_cover(bounds, x as f32, y as f32); + let luma = if light { 255 - luma } else { luma }; + let radius = 2.0 * (0.3 + 0.7 * (luma as f32 / 255.0).sqrt()); + let [r, g, b, a] = + self.colors[self.cover_index(bounds, x as f32 + 2.0, y as f32 + 2.0)]; + for dy in 0..4.min(height - y) { + for dx in 0..4.min(width - x) { + let distance = + ((dx as f32 - 1.5).powi(2) + (dy as f32 - 1.5).powi(2)).sqrt(); + let coverage = (radius + 0.5 - distance).clamp(0.0, 1.0) * a as f32 / 255.0; + let [sr, sg, sb, sa] = self.colors[((y + dy) * width + x + dx) as usize]; + let blend = |source: u8, dot: u8| { + (source as f32 * 0.60 + + (dot as f32 * coverage + paper as f32 * (1.0 - coverage)) * 0.40) + as u8 + }; + pixels.put_pixel( + x + dx, + y + dy, + image::Rgba([blend(sb, b), blend(sg, g), blend(sr, r), sa]), + ); + } + } + } + } + pixels + } + + fn dither_pixels(&self, width: u32, height: u32) -> image::RgbaImage { + const BAYER: [[u8; 4]; 4] = [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]; + let bounds = gpui::size(px(width as f32), px(height as f32)); + let mut pixels = image::RgbaImage::new(width, height); + for y in (0..height).step_by(2) { + for x in (0..width).step_by(2) { + // Sample/quantize once per dot, not four times per 2x2 cell. + let index = self.cover_index(bounds, (x + 1) as f32, (y + 1) as f32); + let [r, g, b, a] = dither_color( + self.colors[index], + BAYER[y as usize / 2 % 4][x as usize / 2 % 4], + ); + for dy in 0..2.min(height - y) { + for dx in 0..2.min(width - x) { + // RenderImage consumes BGRA. + pixels.put_pixel(x + dx, y + dy, image::Rgba([b, g, r, a])); + } + } + } + } + pixels + } + + fn sample_cover(&self, bounds: gpui::Size, x: f32, y: f32) -> u8 { + self.pixels[self.cover_index(bounds, x, y)] + } + + fn cover_index(&self, bounds: gpui::Size, x: f32, y: f32) -> usize { + let width = f32::from(bounds.width).max(1.0); + let height = f32::from(bounds.height).max(1.0); + let source_width = self.width as f32; + let source_height = self.height as f32; + let scale = (width / source_width).max(height / source_height); + let visible_width = width / scale; + let visible_height = height / scale; + let source_x = ((source_width - visible_width) * 0.5 + x / scale) + .clamp(0.0, source_width - 1.0) as u32; + let source_y = ((source_height - visible_height) * 0.5 + y / scale) + .clamp(0.0, source_height - 1.0) as u32; + (source_y * self.width + source_x) as usize + } +} + +fn background_luminance(path: &Path, cx: &mut gpui::App) -> Option> { + type Source = Arc>>>; + type Cache = Vec<(PathBuf, Source)>; + static CACHE: OnceLock> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(Vec::new())); + if let Some(source) = cache + .lock() + .ok()? + .iter() + .find_map(|(key, source)| (key == path).then(|| source.clone())) + { + return source.lock().ok()?.clone(); + } + let pending = Arc::new(Mutex::new(None)); + { + let mut cache = cache.lock().ok()?; + cache.push((path.to_path_buf(), pending.clone())); + if cache.len() > 4 { + cache.remove(0); + } + } + let path = path.to_path_buf(); + cx.spawn(async move |cx| { + let source = cx + .background_executor() + .spawn(async move { + let bytes = std::fs::read(path).ok()?; + let proxy = crate::new_thread_background_image::decode(&bytes) + .ok()? + .thumbnail(2048, 2048); + let gray = proxy.to_luma8(); + Some(Arc::new(BackgroundLuminance { + width: gray.width(), + height: gray.height(), + pixels: gray.into_raw().into_boxed_slice(), + colors: proxy.to_rgba8().pixels().map(|pixel| pixel.0).collect(), + effects: Mutex::new(Vec::new()), + })) + }) + .await; + cx.update(|cx| { + *pending.lock().unwrap() = source; + cx.refresh_windows(); + }); + }) + .detach(); + None +} + +/// Safe to call on both routes: loading/decoding/effects happen once off-thread, +/// before the hero is requested, and never depend on composer/sidebar geometry. +pub(super) fn prepare( + effect: NewThreadBackgroundEffect, + theme: &Theme, + path: &Path, + cx: &mut gpui::App, +) -> Option> { + let light = matches!(theme.appearance, crate::theme::Appearance::Light); + background_luminance(path, cx).and_then(|source| source.raster_image(effect, light, cx)) +} +fn dither_color([r, g, b, a]: [u8; 4], threshold: u8) -> [u8; 4] { + let peak = r.max(g).max(b) as f32; + let bright = peak / 255.0 > (threshold as f32 + 0.5) / 16.0; + let gain = if bright { 255.0 / peak.max(1.0) } else { 0.08 }; + [ + (r as f32 * gain).round() as u8, + (g as f32 * gain).round() as u8, + (b as f32 * gain).round() as u8, + a, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cold_artwork_fades_in_once_and_warm_navigation_does_not_restart_it() { + let image = gpui::RenderImage::new([image::Frame::new(image::RgbaImage::new(1, 1))]); + let next = gpui::RenderImage::new([image::Frame::new(image::RgbaImage::new(1, 1))]); + let mut ready = Readiness::default(); + let now = Instant::now(); + assert_eq!(ready.opacity(None, false, now), 0.0); + let loaded = now + std::time::Duration::from_secs(30); + assert_eq!(ready.opacity(Some(image.id), false, loaded), 0.0); + let halfway = + loaded + std::time::Duration::from_secs_f32(0.060 * crate::motion::speed_scale()); + assert!((ready.opacity(Some(image.id), false, halfway) - 0.5).abs() < 0.001); + let later = loaded + std::time::Duration::from_secs(30); + assert_eq!(ready.opacity(Some(image.id), false, later), 1.0); + assert_eq!(ready.opacity(Some(image.id), false, later), 1.0); + assert_eq!(ready.opacity(Some(next.id), false, later), 0.0); + assert_eq!(ready.opacity(Some(next.id), true, later), 1.0); + } + + #[gpui::test] + fn prewarming_decodes_off_thread_and_reuses_artwork_without_hero_geometry( + cx: &mut gpui::TestAppContext, + ) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("background.png"); + image::RgbaImage::from_pixel(32, 24, image::Rgba([173, 89, 231, 180])) + .save(&path) + .unwrap(); + cx.update(|cx| assert!(background_luminance(&path, cx).is_none())); + cx.run_until_parked(); + let source = cx.update(|cx| background_luminance(&path, cx).unwrap()); + cx.update(|cx| { + assert!( + source + .raster_image(NewThreadBackgroundEffect::None, false, cx) + .is_none() + ) + }); + cx.run_until_parked(); + cx.update(|cx| { + let warm = background_luminance(&path, cx).unwrap(); + assert!(Arc::ptr_eq(&source, &warm)); + let image = warm + .raster_image(NewThreadBackgroundEffect::None, false, cx) + .unwrap(); + assert_eq!(image.as_bytes(0).unwrap()[..4], [231, 89, 173, 180]); + for _ in 0..20 { + assert!(Arc::ptr_eq( + &image, + &warm + .raster_image(NewThreadBackgroundEffect::None, false, cx) + .unwrap() + )); + } + }); + } + fn fixture() -> Arc { + Arc::new(BackgroundLuminance { + width: 60, + height: 32, + pixels: vec![128; 1920].into_boxed_slice(), + colors: vec![[128, 64, 32, 200]; 1920].into_boxed_slice(), + effects: Mutex::new(Vec::new()), + }) + } + #[gpui::test] + fn every_effect_is_generated_once_independently_of_viewport(cx: &mut gpui::TestAppContext) { + let source = fixture(); + for effect in [ + NewThreadBackgroundEffect::Dither, + NewThreadBackgroundEffect::Ascii, + NewThreadBackgroundEffect::Halftone, + NewThreadBackgroundEffect::Scanlines, + ] { + cx.update(|cx| { + for _ in 0..100 { + assert!(source.raster_image(effect, false, cx).is_none()); + } + assert_eq!( + source + .effects + .lock() + .unwrap() + .iter() + .filter(|(key, _)| *key == (effect, false)) + .count(), + 1 + ); + }); + cx.run_until_parked(); + cx.update(|cx| { + let first = source.raster_image(effect, false, cx).unwrap(); + assert_eq!(first.size(0).width.0, 60); + assert_eq!(first.size(0).height.0, 32); + for _ in 0..100 { + assert!(Arc::ptr_eq( + &first, + &source.raster_image(effect, false, cx).unwrap() + )); + } + }); + } + } + #[test] + fn light_treatments_use_light_paper_without_inverting_source_hues() { + let source = fixture(); + for (light, dark) in [ + (source.ascii_pixels(true), source.ascii_pixels(false)), + (source.scanline_pixels(true), source.scanline_pixels(false)), + ( + source.halftone_pixels(60, 32, true), + source.halftone_pixels(60, 32, false), + ), + ] { + let brightness = |image: &image::RgbaImage| -> u64 { + image + .pixels() + .map(|p| p.0[..3].iter().map(|c| u64::from(*c)).sum::()) + .sum() + }; + assert!(brightness(&light) > brightness(&dark)); + assert_eq!(light.dimensions(), dark.dimensions()); + // Raster output is BGRA; the warm source remains warm on light paper. + assert!(light.pixels().all(|p| p.0[2] >= p.0[1] && p.0[1] >= p.0[0])); + } + } + + #[gpui::test] + fn appearance_changes_cache_both_variants_and_share_unchanged_dither( + cx: &mut gpui::TestAppContext, + ) { + let source = fixture(); + for effect in [ + NewThreadBackgroundEffect::Ascii, + NewThreadBackgroundEffect::Halftone, + NewThreadBackgroundEffect::Scanlines, + NewThreadBackgroundEffect::Dither, + ] { + cx.update(|cx| { + source.raster_image(effect, false, cx); + source.raster_image(effect, true, cx); + }); + cx.run_until_parked(); + cx.update(|cx| { + let dark = source.raster_image(effect, false, cx).unwrap(); + let light = source.raster_image(effect, true, cx).unwrap(); + assert_eq!( + Arc::ptr_eq(&dark, &light), + effect == NewThreadBackgroundEffect::Dither + ); + for _ in 0..100 { + assert!(Arc::ptr_eq( + &dark, + &source.raster_image(effect, false, cx).unwrap() + )); + assert!(Arc::ptr_eq( + &light, + &source.raster_image(effect, true, cx).unwrap() + )); + } + }); + } + assert_eq!(source.effects.lock().unwrap().len(), 7); + } + + #[test] + fn raster_treatments_preserve_source_dimensions_and_alpha() { + let source = fixture(); + for image in [ + source.dither_pixels(60, 32), + source.ascii_pixels(false), + source.scanline_pixels(false), + source.ascii_pixels(true), + source.scanline_pixels(true), + ] { + assert_eq!(image.dimensions(), (60, 32)); + assert!(image.pixels().all(|pixel| pixel.0[3] == 200)); + } + assert_eq!(source.halftone_pixels(60, 32, false).dimensions(), (60, 32)); + } +} diff --git a/crates/ui/src/new_thread_background_image.rs b/crates/ui/src/new_thread_background_image.rs new file mode 100644 index 000000000..da4136810 --- /dev/null +++ b/crates/ui/src/new_thread_background_image.rs @@ -0,0 +1,10 @@ +//! One decoding contract for background installation and rendering. Attachment +//! formats are broader (notably SVG), so attachment staging is not validation. + +pub(crate) fn decode(bytes: &[u8]) -> image::ImageResult { + // Inspect the exact bytes that will be saved, not the source extension or + // a second read of a file that could change between validation and copy. + image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format()? + .decode() +} diff --git a/crates/ui/src/new_thread_background_mask.rs b/crates/ui/src/new_thread_background_mask.rs new file mode 100644 index 000000000..3105c7cf6 --- /dev/null +++ b/crates/ui/src/new_thread_background_mask.rs @@ -0,0 +1,168 @@ +//! Paint-time source-alpha feather. Resizing changes only GPU parameters, not +//! the image identity, pixels, atlas entry, or an asynchronous raster job. +use gpui::{Bounds, ImageAlphaMask, Pixels, RenderImage, Window, point, px, size}; +use std::{cell::Cell, rc::Rc, sync::Arc}; + +pub(crate) type SurfaceBounds = Rc>>>; + +fn mask(bounds: Bounds, composer: Bounds) -> ImageAlphaMask { + let height = f32::from(bounds.size.height); + ImageAlphaMask { + bounds: composer, + radius: px(crate::composer::COMPOSER_RADIUS), + feather: px((height * 0.52).clamp(120.0, 220.0)), + clearance: px(8.0), + bottom_fade: Some((bounds.bottom(), px((height * 0.22).max(1.0)))), + } +} + +/// All elements have finished prepaint before this reads the measured surface, +/// so the first visible frame uses the current composer, including on sidebar +/// resize and right-panel handoffs. Object-fit cropping is independent. +pub(crate) fn paint( + source: Arc, + bounds: Bounds, + composer: Bounds, + window: &mut Window, +) { + let width = f32::from(bounds.size.width); + let height = f32::from(bounds.size.height); + let source_size = source.size(0); + if width <= 0.0 || height <= 0.0 || source_size.width.0 <= 0 || source_size.height.0 <= 0 { + return; + } + let scale = (width / source_size.width.0 as f32).max(height / source_size.height.0 as f32); + let fitted_size = size( + px(source_size.width.0 as f32 * scale), + px(source_size.height.0 as f32 * scale), + ); + let fitted = Bounds::new( + bounds.center() - point(fitted_size.width * 0.5, fitted_size.height * 0.5), + fitted_size, + ); + let _ = window.paint_image_fitted_masked( + bounds, + fitted, + Default::default(), + source, + 0, + false, + Some(mask(bounds, composer)), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{Context, Render, canvas, div, prelude::*}; + + #[gpui::test] + fn background_paint_sees_same_frame_composer_bounds_even_when_painted_first( + cx: &mut gpui::TestAppContext, + ) { + struct Fixture { + surface: SurfaceBounds, + painted: SurfaceBounds, + source: Arc, + left: f32, + width: f32, + } + impl Render for Fixture { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let surface = self.surface.clone(); + let painted = self.painted.clone(); + let measured = self.surface.clone(); + let source = self.source.clone(); + div() + .relative() + .size_full() + .child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + painted.set(surface.get()); + if let Some(composer) = surface.get() { + paint(source, bounds, composer, window); + } + }, + ) + .absolute() + .inset_0(), + ) + .child( + div() + .absolute() + .left(px(self.left)) + .top(px(360.25)) + .w(px(self.width)) + .h(px(124.0)) + .child( + canvas( + move |bounds, _, _| measured.set(Some(bounds)), + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ), + ) + } + } + let surface: SurfaceBounds = Default::default(); + let painted: SurfaceBounds = Default::default(); + let handle = cx.add_window(|_, _| Fixture { + surface: surface.clone(), + painted: painted.clone(), + source: Arc::new(RenderImage::new([image::Frame::new( + image::RgbaImage::from_pixel(2, 2, image::Rgba([79, 151, 233, 180])), + )])), + left: 40.0, + width: 768.0, + }); + for (left, width) in [ + (40.0, 768.0), + (264.0, 544.0), + (152.25, 656.0), + (40.0, 408.0), + (40.0, 768.0), + ] { + handle + .update(cx, |fixture, _, cx| { + fixture.left = left; + fixture.width = width; + cx.notify(); + }) + .unwrap(); + cx.update_window(handle.into(), |_, window, cx| { + window.draw(cx).clear(); + }) + .unwrap(); + let actual = painted.get().expect("no cold first-frame geometry"); + assert_eq!(Some(actual), surface.get()); + // GPUI layout snaps to physical pixels; the mask must consume + // that exact measured surface, not the unrounded layout request. + assert!((f32::from(actual.left()) - left).abs() <= 0.5); + assert_eq!(actual.size.width, px(width)); + } + } + + #[test] + fn mask_tracks_current_surface_in_window_space_without_rounding() { + for sidebar in [0.0, 112.25, 224.0] { + for right_panel in [0.0, 360.0] { + let hero = Bounds::new( + point(px(sidebar), px(40.0)), + size(px(1200.0 - sidebar), px(440.0)), + ); + let composer = Bounds::new( + point(px(sidebar + 40.5), px(360.25)), + size(px(900.0 - sidebar - right_panel), px(124.0)), + ); + let mask = mask(hero, composer); + assert_eq!(mask.bounds, composer); + assert_eq!(mask.bottom_fade, Some((px(480.0), px(96.8)))); + assert_eq!(mask.feather, px(220.0)); + assert_eq!(mask.clearance, px(8.0)); + } + } + } +} diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index 58d6d1547..38eafb60f 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -31,6 +31,20 @@ use zeron_rpc::methods; /// pagination plumbing). const MAX_REF_ROWS: usize = 300; +const FOOTER_CHIP_RADIUS: f32 = 6.0; + +/// Both sides of the composer handoff share one leading-aligned workspace +/// cluster. Available width belongs after the pair, never between its labels. +fn workspace_footer_row() -> gpui::Div { + div() + .w_full() + .min_w_0() + .flex() + .flex_row() + .items_center() + .gap(px(4.0)) +} + use crate::composer::{ComposerInput, ComposerInputEvent}; use crate::motion; use crate::popover::{self, Loadable, MenuKey}; @@ -2346,7 +2360,7 @@ impl Pickers { .items_center() .gap(px(6.0)) .px(px(8.0)) - .rounded(px(6.0)) + .rounded(px(FOOTER_CHIP_RADIUS)) .text_size(crate::typography::ui_rems(12.0)) .font_weight(gpui::FontWeight::MEDIUM) .text_color(motion::hover_blend( @@ -2371,12 +2385,14 @@ impl Pickers { .child( crate::icons::icon(icon_path) .size(px(12.0)) + .flex_none() .text_color(theme.text_muted.opacity(0.7)), ) .child(div().min_w_0().truncate().child(label)) .child( crate::icons::icon(crate::icons::ALT_ARROW_DOWN) .size(px(12.0)) + .flex_none() .text_color(theme.text_muted.opacity(0.5)), ) } @@ -2408,11 +2424,9 @@ impl Pickers { .child(div().min_w_0().truncate().child(label)) } - /// The new-session target row — device + project selector chips rendered - /// ABOVE the composer pill, left-aligned like the checkout toolbar (the - /// composer footer carries only checkout + ref, and sessions show their - /// target in the titlebar instead). - pub fn render_target_selectors(&mut self, cx: &mut Context) -> AnyElement { + /// New-session destination controls. Machine and project form the + /// original chip-only cluster floating above the composer's trailing edge. + pub fn render_new_thread_target_selectors(&mut self, cx: &mut Context) -> AnyElement { let theme = Theme::of(cx).clone(); let closing = self.open.closing_since(); let mut overlay: Option<(PickerKind, AnyElement)> = match self.mounted_kind() { @@ -2463,17 +2477,12 @@ impl Pickers { &theme, cx, ); - // Same left-edge geometry as the checkout toolbar under the pill - // (`render_footer`'s row): full-width, 10px inset, chips hugging the - // left. The row sits just above the composer pill, so the menus open - // UPWARD. div() - .w_full() + .flex_none() .flex() .flex_row() .items_center() .gap(px(4.0)) - .px(px(10.0)) .child(attach_overlay( device_chip, &mut overlay, @@ -2481,7 +2490,7 @@ impl Pickers { "device-popover", closing, )) - .child(attach_overlay( + .child(attach_overlay_end( project_chip, &mut overlay, PickerKind::Space, @@ -2491,10 +2500,77 @@ impl Pickers { .into_any_element() } + /// New-session Git controls. Checkout mode and branch form the original + /// chip-only cluster floating below the composer's leading edge. + pub fn render_new_thread_git_selectors( + &mut self, + cx: &mut Context, + ) -> Option { + let git = self + .state + .read(cx) + .selected_space_row() + .is_some_and(|space| space.git_detected); + if !git { + return None; + } + self.ensure_refs(false, cx); + let theme = Theme::of(cx).clone(); + let closing = self.open.closing_since(); + let mut overlay: Option<(PickerKind, AnyElement)> = match self.mounted_kind() { + Some(PickerKind::Branch) => { + let content = self.render_branch_popover(cx); + Some((PickerKind::Branch, self.popover_frame(320.0, content, cx))) + } + Some(PickerKind::Checkout) => { + let content = self.render_checkout_popover(cx); + Some((PickerKind::Checkout, self.popover_frame(224.0, content, cx))) + } + _ => None, + }; + let kind_icon = match (self.config.checkout, self.selected_ref_worktree().is_some()) { + (CheckoutKind::Local, false) => crate::icons::FOLDER, + _ => crate::icons::FOLDER_WITH_FILES, + }; + let checkout_chip = self.footer_chip( + PickerKind::Checkout, + "picker-checkout", + kind_icon, + SharedString::from(self.checkout_label()), + &theme, + cx, + ); + let branch_chip = self.footer_chip( + PickerKind::Branch, + "picker-branch", + crate::icons::GIT_BRANCH, + self.ref_label(), + &theme, + cx, + ); + Some( + workspace_footer_row() + .child(attach_overlay_below( + checkout_chip, + &mut overlay, + PickerKind::Checkout, + "checkout-popover", + closing, + )) + .child(attach_overlay_below( + branch_chip, + &mut overlay, + PickerKind::Branch, + "branch-popover", + closing, + )) + .into_any_element(), + ) + } + /// The composer footer row: checkout-kind + ref, LEFT-aligned, only when - /// the picked (or session's) project has git. Device + project moved to - /// the row above the pill ([`Self::render_target_selectors`]); sessions - /// name their target in the titlebar. + /// the picked (or session's) project has git. New sessions use the floating + /// chip clusters; sessions name their target in the titlebar. pub fn render_footer(&mut self, cx: &mut Context) -> Option { let theme = Theme::of(cx).clone(); // A selected chat whose workspace row hasn't synced yet (the moment @@ -2514,22 +2590,15 @@ impl Pickers { (space, session, change_request) }; let row = || { - // Symmetric: the container's 8px gap sits above the toolbar; - // bleeding 8 of the container's 16px bottom padding (mb -8) - // leaves 8 below — equal air on both sides of the row. + // The composer owns the row's animated reveal and negative bottom + // margin. Keeping that geometry outside this reusable content + // lets the new-thread route handoff collapse the footer without + // clipping its controls or changing its steady-state spacing. // `w_full` is load-bearing: without it the canvas layout sizes // the row to CONTENT, and the left cluster's flex_1 (basis 0) // collapsed to zero width — both clusters painted from the same // origin, chips overlapping (user report). - div() - .w_full() - .flex() - .flex_row() - .items_center() - .justify_between() - .gap(px(8.0)) - .px(px(10.0)) - .mb(px(-8.0)) + workspace_footer_row().px(px(10.0)) }; if let Some(chat) = &session { @@ -2545,8 +2614,7 @@ impl Pickers { } else { (crate::icons::FOLDER, "Local checkout") }; - // Mirrors the draft chips: checkout hugs the left edge, ref the - // right. + // Keep the same reading order and leading edge as the draft. let left = div() .flex() .flex_row() @@ -2563,14 +2631,6 @@ impl Pickers { .items_center() .gap(px(4.0)) .min_w_0() - .when_some(change_request, |el, summary| { - el.child(crate::change_requests::pull_request_badge( - "composer-pull-request".into(), - summary, - crate::change_requests::ChangeRequestBadgeSurface::Composer, - &theme, - )) - }) .child(Self::footer_label( crate::icons::GIT_BRANCH, chat.branch @@ -2579,9 +2639,26 @@ impl Pickers { .unwrap_or_else(|| SharedString::from("No ref")), &theme, )); - // The context indicator follows this footer in the composer; - // its own padding supplies the spacing after the branch label. - return Some(row().pr_0().child(left).child(right).into_any_element()); + // Checkout + branch stay together. PR and usage form the trailing + // status group, independently of the branch label's length. + return Some( + row() + .pr_0() + .child(left) + .child(right) + .child(div().flex_1().min_w_0()) + .when_some(change_request, |el, summary| { + el.child(div().flex_none().child( + crate::change_requests::pull_request_badge( + "composer-pull-request".into(), + summary, + crate::change_requests::ChangeRequestBadgeSurface::Composer, + &theme, + ), + )) + }) + .into_any_element(), + ); } // New-session draft: checkout + ref only, LEFT-aligned (device + @@ -2602,8 +2679,7 @@ impl Pickers { let content = self.render_checkout_popover(cx); Some((PickerKind::Checkout, self.popover_frame(224.0, content, cx))) } - // Space/Device popovers mount on the target row above the pill - // (`render_target_selectors`), not here. + // Space/Device popovers mount in the floating row above the pill. _ => None, }; @@ -2628,8 +2704,8 @@ impl Pickers { &theme, cx, ); - // Checkout on the left edge, ref on the right — the row's - // justify_between splits them (user request). + // Match the floating draft's adjacent checkout/ref pair, including + // while the newly created session is waiting for its workspace row. let left = div() .flex() .flex_row() @@ -2647,7 +2723,7 @@ impl Pickers { .flex_row() .items_center() .min_w_0() - .child(attach_overlay_end( + .child(attach_overlay( ref_chip, &mut overlay, PickerKind::Branch, @@ -3995,7 +4071,7 @@ fn offered_harnesses_impl(list: &[HarnessDescriptor], allow_mock: bool) -> Vec, overlay: &mut Option<(PickerKind, AnyElement)>, @@ -4011,8 +4087,24 @@ fn attach_overlay( chip } -/// [`attach_overlay`] with the menu RIGHT-ALIGNED to the trigger (t3code -/// `align="end"` — right-edge triggers like the ref picker open leftward). +/// Attach the (single) open popover below a selector trigger. +fn attach_overlay_below( + chip: gpui::Stateful, + overlay: &mut Option<(PickerKind, AnyElement)>, + kind: PickerKind, + id: &'static str, + closing: Option, +) -> gpui::Stateful { + if overlay.as_ref().is_some_and(|(k, _)| *k == kind) + && let Some((_, element)) = overlay.take() + { + return chip.child(popover::anchored_menu_below(id, element, closing)); + } + chip +} + +/// Attach the menu ABOVE and RIGHT-ALIGNED to the trigger (t3code +/// `align="end"` — right-edge controls like the model picker open leftward). fn attach_overlay_end( chip: gpui::Stateful, overlay: &mut Option<(PickerKind, AnyElement)>, @@ -4247,6 +4339,59 @@ mod tests { use super::*; use zeron_proto::{FolderEntry, Model, ModelOption, ModelOptionChoice}; + #[gpui::test] + fn workspace_footer_pair_keeps_its_leading_edge_and_gap(cx: &mut gpui::TestAppContext) { + struct Fixture { + width: f32, + bounds: std::rc::Rc>>>, + } + impl gpui::Render for Fixture { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let chip = |width| { + let measured = self.bounds.clone(); + gpui::canvas( + move |bounds, _, _| measured.borrow_mut().push(bounds), + |_, _, _, _| {}, + ) + .w(px(width)) + .h(px(20.0)) + .flex_none() + }; + div().w(px(self.width)).child( + workspace_footer_row() + .px(px(10.0)) + .child(chip(120.0)) + .child(chip(90.0)) + .child(div().flex_1().min_w_0()) + .child(chip(60.0)), + ) + } + } + let bounds = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let handle = cx.add_window(|_, _| Fixture { + width: 320.0, + bounds: bounds.clone(), + }); + let mut first_left = None; + for width in [320.0, 680.0, 1000.0, 320.0] { + handle + .update(cx, |fixture, _, cx| { + fixture.width = width; + cx.notify(); + }) + .unwrap(); + bounds.borrow_mut().clear(); + cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + let measured = bounds.borrow(); + let pair = &measured[measured.len() - 3..]; + assert_eq!(pair[0].left(), *first_left.get_or_insert(pair[0].left())); + assert!((f32::from(pair[1].left() - pair[0].right()) - 4.0).abs() < 0.1); + assert_eq!(pair[0].top(), pair[1].top()); + assert!((f32::from(pair[2].right() - pair[0].left()) - (width - 20.0)).abs() < 0.1); + } + } + #[gpui::test] fn picker_completion_and_dismissal_have_distinct_focus_behavior(cx: &mut gpui::TestAppContext) { use std::cell::Cell; diff --git a/crates/ui/src/queue.rs b/crates/ui/src/queue.rs index 54077d425..1949e4d18 100644 --- a/crates/ui/src/queue.rs +++ b/crates/ui/src/queue.rs @@ -77,12 +77,9 @@ const QUEUE_TEXT_SIZE: f32 = 12.5; const ROW_GAP: f32 = 0.0; const ROW_SLOT: f32 = ROW_HEIGHT + ROW_GAP; const ROW_PAD_X: f32 = 8.0; +const ROW_RADIUS: f32 = 8.0; const PANEL_RADIUS: f32 = 16.0; -const PANEL_BORDER: f32 = 1.0; -const PANEL_INSET: f32 = 4.0; -// Concentric with the tray's outer edge, including its layout border. -const ROW_RADIUS: f32 = PANEL_RADIUS - PANEL_BORDER - PANEL_INSET; -const PANEL_PAD_TOP: f32 = PANEL_INSET; +const PANEL_PAD_TOP: f32 = 0.0; /// The custom 24px queue glyphs have quieter geometry than the legacy set, so /// render them slightly larger to preserve the previous optical weight. const QUEUE_ICON_SIZE: f32 = 13.0; @@ -226,12 +223,9 @@ fn queue_panel_surface(theme: &Theme) -> gpui::Div { .border_1() .border_color(theme.border) .when(!theme.is_frost(), |el| el.shadow_lg()) - // Inset hover surfaces so they stay inside the rounded tray. - .px(px(PANEL_INSET)) - .pt(px(PANEL_PAD_TOP)) - // The overlap is hidden behind the composer; retain a visible inset - // below the final row, matching the top and sides. - .pb(px(QUEUE_COMPOSER_OVERLAP + PANEL_INSET)) + // Keep visible rows flush with the tray; only the portion tucked behind + // the composer needs padding. + .pb(px(QUEUE_COMPOSER_OVERLAP)) .flex() .flex_col() } diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index 1c6e774e6..e8f4f3a7e 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -26,7 +26,7 @@ pub mod shortcuts; pub mod widgets; /// Sidebar drag-resize bounds (px). -pub const SIDEBAR_MIN: f32 = 208.0; +pub const SIDEBAR_MIN: f32 = 224.0; pub const SIDEBAR_MAX: f32 = 400.0; pub const SIDEBAR_DEFAULT: f32 = 256.0; @@ -57,6 +57,57 @@ pub const FILES_EDITOR_FONT_SIZE_MIN: f32 = 9.0; pub const FILES_EDITOR_FONT_SIZE_MAX: f32 = 24.0; const FILE_NAME: &str = "ui-settings.json"; +const NEW_THREAD_BACKGROUND_DIR: &str = "new-thread-backgrounds"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NewThreadComposerBackground { + /// Managed copy inside Zeron's device-local data directory. + pub path: String, + /// Original file name shown in Appearance settings. + pub name: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NewThreadBackgroundEffect { + #[default] + None, + Dither, + Ascii, + Halftone, + Scanlines, +} + +impl NewThreadBackgroundEffect { + pub const ALL: [Self; 5] = [ + Self::None, + Self::Dither, + Self::Ascii, + Self::Halftone, + Self::Scanlines, + ]; + + pub const fn label(self) -> &'static str { + match self { + Self::None => "None", + Self::Dither => "Dither", + Self::Ascii => "ASCII", + Self::Halftone => "Halftone", + Self::Scanlines => "Scanlines", + } + } + + pub const fn description(self) -> &'static str { + match self { + Self::None => "Shows the original artwork.", + Self::Dither => "Rebuilds the artwork with a dithered color palette.", + Self::Ascii => "Recreates the artwork with colored characters on black.", + Self::Halftone => "Recreates the artwork with colored print dots on black.", + Self::Scanlines => "Adds a pronounced horizontal display-line texture.", + } + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -224,6 +275,115 @@ pub fn current(cx: &App) -> UiSettings { .unwrap_or_default() } +/// Copy a selected image into Zeron's device-local data directory and make it +/// the new-thread canvas background. A unique file name avoids stale image +/// caches when the background is replaced. +pub fn install_new_thread_composer_background(source: &Path, cx: &mut App) -> Result<(), String> { + let staged = crate::attachments::stage_file(source)?; + // Do not persist the candidate or retire the old managed file until the + // renderer's decoder has accepted the exact bytes we are about to save. + crate::new_thread_background_image::decode(staged.bytes()).map_err(|_| { + "This background image is unsupported or damaged. Choose a valid image such as PNG or JPEG.".to_string() + })?; + let data_dir = cx + .try_global::() + .map(|store| store.data_dir.clone()) + .ok_or_else(|| "Unable to save the image. Restart Zeron and try again.".to_string())?; + let backgrounds_dir = data_dir.join(NEW_THREAD_BACKGROUND_DIR); + std::fs::create_dir_all(&backgrounds_dir).map_err(|_| { + "Unable to save the image. Check folder permissions and try again.".to_string() + })?; + + let extension = Path::new(&staged.name) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("png"); + let destination = backgrounds_dir.join(format!( + "new-thread-background-{}.{}", + uuid::Uuid::new_v4(), + extension + )); + let temporary = destination.with_extension(format!("{extension}.tmp")); + if std::fs::write(&temporary, staged.bytes()) + .and_then(|_| std::fs::rename(&temporary, &destination)) + .is_err() + { + let _ = std::fs::remove_file(&temporary); + return Err( + "Unable to save the image. Check folder permissions and try again.".to_string(), + ); + } + + let replacement = NewThreadComposerBackground { + path: destination.to_string_lossy().into_owned(), + name: staged.name, + }; + let mut next = current(cx); + let previous = next + .new_thread_composer_background + .replace(replacement.clone()); + // Persist the pointer before retiring the old file. `update(Immediate)` + // updates memory first and only logs an I/O failure; for a file-backed + // setting that order can leave disk pointing at an image we just deleted. + if next.save(&data_dir).is_err() { + let _ = std::fs::remove_file(&destination); + return Err( + "Unable to save the image. Check folder permissions and try again.".to_string(), + ); + } + replace(next, SavePolicy::Immediate, cx); + remove_managed_new_thread_background(previous.as_ref(), &backgrounds_dir); + cx.refresh_windows(); + Ok(()) +} + +pub fn remove_new_thread_composer_background(cx: &mut App) -> Result<(), String> { + let data_dir = cx + .try_global::() + .map(|store| store.data_dir.clone()) + .ok_or_else(|| "Unable to remove the image. Restart Zeron and try again.".to_string())?; + let mut next = current(cx); + let previous = next.new_thread_composer_background.take(); + if previous.is_none() { + return Ok(()); + } + if next.save(&data_dir).is_err() { + return Err( + "Unable to remove the image. Check folder permissions and try again.".to_string(), + ); + } + replace(next, SavePolicy::Immediate, cx); + remove_managed_new_thread_background( + previous.as_ref(), + &data_dir.join(NEW_THREAD_BACKGROUND_DIR), + ); + cx.refresh_windows(); + Ok(()) +} + +pub fn set_new_thread_background_effect(effect: NewThreadBackgroundEffect, cx: &mut App) { + if update(SavePolicy::Immediate, cx, |settings| { + settings.new_thread_background_effect = effect; + }) { + cx.refresh_windows(); + } +} + +fn remove_managed_new_thread_background( + background: Option<&NewThreadComposerBackground>, + backgrounds_dir: &Path, +) { + let Some(background) = background else { + return; + }; + let path = Path::new(&background.path); + // Never delete an arbitrary legacy or hand-edited path. Only files copied + // directly into the directory owned by this setting are disposable. + if path.parent() == Some(backgrounds_dir) { + let _ = std::fs::remove_file(path); + } +} + /// Monotonic id of the global code-fence layout choice. Every transcript /// compares this during render so inactive subagent tabs can observe all mode /// transitions when they next become visible. @@ -454,6 +614,11 @@ pub struct UiSettings { pub accent: zeron_theme::AccentSelection, /// Glass policy, independent from the selected appearance, theme, and accent. pub surface: zeron_theme::SurfacePreference, + /// Optional device-local artwork behind the blank new-thread composer. + #[serde(skip_serializing_if = "Option::is_none")] + pub new_thread_composer_background: Option, + /// Non-destructive treatment composited inside the artwork's fade mask. + pub new_thread_background_effect: NewThreadBackgroundEffect, /// Pre-theme settings used `accentColor`. Read it once, migrate to /// [`Self::accent`], and never write it again. #[serde(default, rename = "accentColor", skip_serializing)] @@ -510,6 +675,8 @@ impl Default for UiSettings { files_show_all: false, accent: zeron_theme::AccentSelection::default(), surface: zeron_theme::SurfacePreference::default(), + new_thread_composer_background: None, + new_thread_background_effect: NewThreadBackgroundEffect::None, legacy_accent_color: None, } } @@ -1124,6 +1291,11 @@ mod tests { let loaded = UiSettings::load(dir.path()); assert_eq!(loaded.composer_send_behavior, ComposerSendBehavior::Enter); + assert!(loaded.new_thread_composer_background.is_none()); + assert_eq!( + loaded.new_thread_background_effect, + NewThreadBackgroundEffect::None + ); assert_eq!(loaded.sidebar_width, 300.0); assert!(!loaded.sound_enabled); for sound in [ @@ -1213,6 +1385,130 @@ mod tests { assert!(!restored.sound_enabled); } + #[test] + fn background_cleanup_only_removes_files_owned_by_the_setting() { + let dir = tempfile::tempdir().unwrap(); + let backgrounds = dir.path().join(NEW_THREAD_BACKGROUND_DIR); + std::fs::create_dir(&backgrounds).unwrap(); + let managed = backgrounds.join("new-thread-background-owned.png"); + let unrelated = dir.path().join("keep.png"); + std::fs::write(&managed, b"managed").unwrap(); + std::fs::write(&unrelated, b"unrelated").unwrap(); + + remove_managed_new_thread_background( + Some(&NewThreadComposerBackground { + path: unrelated.to_string_lossy().into_owned(), + name: "keep.png".into(), + }), + &backgrounds, + ); + assert!(unrelated.exists()); + remove_managed_new_thread_background( + Some(&NewThreadComposerBackground { + path: managed.to_string_lossy().into_owned(), + name: "owned.png".into(), + }), + &backgrounds, + ); + assert!(!managed.exists()); + } + + #[gpui::test] + fn invalid_background_replacement_preserves_previous_image_and_settings( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + let original = dir.path().join("original.png"); + image::RgbaImage::from_pixel(8, 8, image::Rgba([20, 100, 200, 255])) + .save(&original) + .unwrap(); + cx.update(|cx| { + init(UiSettings::default(), dir.path(), cx); + install_new_thread_composer_background(&original, cx).unwrap(); + let before = current(cx); + let previous = PathBuf::from(&before.new_thread_composer_background.as_ref().unwrap().path); + let saved = std::fs::read(UiSettings::path(dir.path())).unwrap(); + let previous_bytes = std::fs::read(&previous).unwrap(); + for (name, bytes) in [ + ("replacement.svg", br#""#.as_slice()), + ("corrupt.png", b"not a PNG".as_slice()), + ("truncated.png", &previous_bytes[..previous_bytes.len() / 2]), + ] { + let candidate = dir.path().join(name); + std::fs::write(&candidate, bytes).unwrap(); + let result = install_new_thread_composer_background(&candidate, cx); + assert!(result.is_err(), "accepted invalid replacement: {name}"); + assert_eq!(current(cx), before); + assert_eq!(std::fs::read(UiSettings::path(dir.path())).unwrap(), saved); + assert_eq!(std::fs::read(&previous).unwrap(), previous_bytes); + assert_eq!(std::fs::read_dir(dir.path().join(NEW_THREAD_BACKGROUND_DIR)).unwrap().count(), 1); + assert!(candidate.exists(), "source files must never be deleted"); + } + }); + } + + #[gpui::test] + fn valid_background_replacement_persists_renderable_image_before_retiring_previous( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("first.png"); + let second = dir.path().join("second.jpg"); + image::RgbaImage::from_pixel(8, 8, image::Rgba([20, 100, 200, 255])) + .save(&first) + .unwrap(); + image::RgbImage::from_pixel(12, 10, image::Rgb([200, 100, 20])) + .save(&second) + .unwrap(); + cx.update(|cx| { + let initial = UiSettings { + new_thread_background_effect: NewThreadBackgroundEffect::Ascii, + ..Default::default() + }; + init(initial, dir.path(), cx); + install_new_thread_composer_background(&first, cx).unwrap(); + let old_path = current(cx).new_thread_composer_background.unwrap().path; + install_new_thread_composer_background(&second, cx).unwrap(); + let settings = current(cx); + let replacement = settings.new_thread_composer_background.as_ref().unwrap(); + assert_ne!(replacement.path, old_path); + let saved_image = std::fs::read(&replacement.path).unwrap(); + assert_eq!(saved_image, std::fs::read(&second).unwrap()); + let decoded = crate::new_thread_background_image::decode(&saved_image).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (12, 10)); + assert_eq!(UiSettings::load(dir.path()), settings); + assert_eq!( + settings.new_thread_background_effect, + NewThreadBackgroundEffect::Ascii + ); + assert!(!Path::new(&old_path).exists()); + assert!(first.exists() && second.exists()); + assert_eq!( + std::fs::read_dir(dir.path().join(NEW_THREAD_BACKGROUND_DIR)) + .unwrap() + .count(), + 1 + ); + }); + } + + #[gpui::test] + fn invalid_initial_background_import_does_not_create_managed_files_or_settings( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + let candidate = dir.path().join("corrupt.png"); + std::fs::write(&candidate, b"not a PNG").unwrap(); + cx.update(|cx| { + init(UiSettings::default(), dir.path(), cx); + assert!(install_new_thread_composer_background(&candidate, cx).is_err()); + assert!(current(cx).new_thread_composer_background.is_none()); + assert!(!dir.path().join(NEW_THREAD_BACKGROUND_DIR).exists()); + assert!(!UiSettings::path(dir.path()).exists()); + assert!(candidate.exists()); + }); + } + #[test] fn obsolete_steering_preference_does_not_reset_other_settings() { let loaded: UiSettings = serde_json::from_str( @@ -1301,6 +1597,11 @@ mod tests { files_show_all: true, accent: zeron_theme::AccentSelection::Preset(zeron_theme::AccentPreset::Cyan), surface: zeron_theme::SurfacePreference::Frosted, + new_thread_composer_background: Some(NewThreadComposerBackground { + path: "/tmp/zeron/new-thread-background.png".into(), + name: "background.png".into(), + }), + new_thread_background_effect: NewThreadBackgroundEffect::Ascii, legacy_accent_color: None, }; settings.save(dir.path()).unwrap(); @@ -1308,6 +1609,7 @@ mod tests { assert!(json.contains(r#""diffWrap": true"#)); assert_eq!(UiSettings::load(dir.path()), settings); assert!(json.contains(r#""codeFencesFitContent": true"#)); + assert!(json.contains(r#""newThreadBackgroundEffect": "ascii""#)); } #[test] @@ -1555,6 +1857,15 @@ mod tests { assert_eq!(loaded.sidebar_width, SIDEBAR_MAX); assert_eq!(loaded.right_pane_width, RIGHT_PANE_MIN); assert!(!loaded.code_fences_fit_content); + assert_eq!( + UiSettings { + sidebar_width: 1.0, + ..Default::default() + } + .clamped() + .sidebar_width, + SIDEBAR_MIN + ); assert_eq!( UiSettings { files_autosave_delay_ms: 1, diff --git a/crates/ui/src/settings/appearance.rs b/crates/ui/src/settings/appearance.rs index 546d1d09c..48b87efff 100644 --- a/crates/ui/src/settings/appearance.rs +++ b/crates/ui/src/settings/appearance.rs @@ -5,8 +5,9 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use gpui::{ - AnyElement, Context, Entity, FocusHandle, Focusable, Hsla, IntoElement, KeyDownEvent, Render, - SharedString, Subscription, Window, div, prelude::*, px, + AnyElement, Context, Entity, FocusHandle, Focusable, Hsla, IntoElement, KeyDownEvent, + ObjectFit, Render, SharedString, StyledImage as _, Subscription, Window, div, img, prelude::*, + px, }; use zeron_theme::vscode::{ImportReport, SourceCompilation}; use zeron_theme::{ @@ -49,6 +50,7 @@ pub struct AppearancePage { import_dialog: Option, review_entry: Option, library_error: Option, + background_error: Option, } impl AppearancePage { @@ -67,6 +69,7 @@ impl AppearancePage { import_dialog: None, review_entry: None, library_error: None, + background_error: None, } } @@ -364,6 +367,40 @@ impl AppearancePage { .detach(); } + fn choose_new_thread_background(&mut self, cx: &mut Context) { + self.background_error = None; + let receiver = cx.prompt_for_paths(gpui::PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("Choose New Thread Composer Background".into()), + }); + cx.spawn(async move |this, cx| { + let path = match receiver.await { + Ok(Ok(Some(mut paths))) => paths.pop(), + _ => None, + }; + let Some(path) = path else { + return; + }; + let _ = this.update(cx, |page, cx| { + page.background_error = + crate::settings::install_new_thread_composer_background(&path, cx) + .err() + .map(SharedString::from); + cx.notify(); + }); + }) + .detach(); + } + + fn remove_new_thread_background(&mut self, cx: &mut Context) { + self.background_error = crate::settings::remove_new_thread_composer_background(cx) + .err() + .map(SharedString::from); + cx.notify(); + } + fn finish_import(&mut self, cx: &mut Context) { let Some(dialog) = self.import_dialog.as_mut() else { return; @@ -544,6 +581,46 @@ fn surface_choice( .child(surface_label(surface)) } +fn background_effect_choice( + theme: &Theme, + effect: crate::settings::NewThreadBackgroundEffect, + selected: bool, +) -> gpui::Stateful { + div() + .id(SharedString::from(format!( + "new-thread-background-effect-{}", + effect.label().to_lowercase() + ))) + .h(px(28.0)) + .px(px(9.0)) + .rounded(px(7.0)) + .border_1() + .border_color(if selected { theme.accent } else { theme.border }) + .bg(if selected { + theme.accent_wash + } else { + theme.surface_raised.opacity(0.28) + }) + .text_size(crate::typography::ui_rems(11.0)) + .font_weight(if selected { + gpui::FontWeight::MEDIUM + } else { + gpui::FontWeight::NORMAL + }) + .text_color(if selected { + theme.accent + } else { + theme.text_muted + }) + .flex() + .items_center() + .cursor_pointer() + .when(!selected, |control| { + control.hover(|style| style.bg(theme.surface_raised_hover)) + }) + .child(effect.label()) +} + #[derive(Clone, Copy, PartialEq, Eq)] enum Corners { All, @@ -1913,6 +1990,9 @@ impl Render for AppearancePage { let current_themes = appearance::themes(cx); let current_accent = appearance::accent(cx); let current_surface = appearance::surface(cx); + let ui_settings = crate::settings::current(cx); + let current_background = ui_settings.new_thread_composer_background; + let current_background_effect = ui_settings.new_thread_background_effect; let cards = AppearanceMode::ALL .into_iter() .map(|mode| { @@ -2050,6 +2130,159 @@ impl Render for AppearancePage { ) .into_any_element(), ); + let background_available = current_background + .as_ref() + .is_some_and(|background| Path::new(&background.path).is_file()); + let background_tile: AnyElement = if let Some(background) = + current_background.as_ref().filter(|_| background_available) + { + div() + .flex_none() + .size(px(36.0)) + .rounded(px(10.0)) + .overflow_hidden() + .border_1() + .border_color(crate::theme::hairline(0.10)) + .child( + img(PathBuf::from(background.path.clone())) + .size(px(34.0)) + .rounded(px(9.0)) + .object_fit(ObjectFit::Cover), + ) + .into_any_element() + } else { + widgets::row_tile(&theme, icons::FILE_IMAGE).into_any_element() + }; + let background_meta = match current_background.as_ref() { + Some(background) if background_available => vec![ + div() + .child(SharedString::from(background.name.clone())) + .into_any_element(), + div() + .child("Softened automatically on frosted themes.") + .into_any_element(), + ], + Some(_) => vec![ + div().child("Image unavailable").into_any_element(), + div() + .child("Choose a replacement or remove it.") + .into_any_element(), + ], + None => vec![ + div() + .child("Add an image behind the composer on empty new threads.") + .into_any_element(), + ], + }; + settings_rows.push( + widgets::card_row(&theme, false) + .child(background_tile) + .child( + div() + .flex_1() + .min_w_0() + .child(widgets::row_title(&theme, "New thread composer background")) + .child(widgets::meta_line(&theme, background_meta)), + ) + .child( + div() + .flex_none() + .ml(px(10.0)) + .flex() + .items_center() + .gap(px(6.0)) + .when(current_background.is_some(), |actions| { + actions + .child( + compact_action( + &theme, + "Replace image", + "new-thread-background-replace", + ) + .on_click(cx.listener( + |this, _, _, cx| this.choose_new_thread_background(cx), + )), + ) + .child( + compact_action( + &theme, + "Remove", + "new-thread-background-remove", + ) + .text_color(theme.danger) + .on_click(cx.listener( + |this, _, _, cx| this.remove_new_thread_background(cx), + )), + ) + }) + .when(current_background.is_none(), |actions| { + actions.child( + compact_action( + &theme, + "Choose image", + "new-thread-background-choose", + ) + .on_click(cx.listener( + |this, _, _, cx| this.choose_new_thread_background(cx), + )), + ) + }), + ) + .into_any_element(), + ); + if background_available { + let effect_controls = crate::settings::NewThreadBackgroundEffect::ALL + .into_iter() + .map(|effect| { + background_effect_choice(&theme, effect, effect == current_background_effect) + .on_click(cx.listener(move |_, _, _, cx| { + crate::settings::set_new_thread_background_effect(effect, cx); + cx.notify(); + })) + }) + .collect::>(); + settings_rows.push( + widgets::card_row(&theme, false) + .child(widgets::row_tile(&theme, icons::TUNING)) + .child( + div() + .flex_1() + .min_w_0() + .child(widgets::row_title(&theme, "Background effect")) + .child(widgets::meta_line( + &theme, + vec![ + div() + .child(current_background_effect.description()) + .into_any_element(), + ], + )), + ) + .child( + div() + .flex_none() + .ml(px(10.0)) + .max_w(px(430.0)) + .flex() + .flex_wrap() + .justify_end() + .gap(px(6.0)) + .children(effect_controls), + ) + .into_any_element(), + ); + } + if let Some(error) = self.background_error.clone() { + settings_rows.push( + div() + .px(px(20.0)) + .py(px(10.0)) + .border_t_1() + .border_color(theme.border) + .child(widgets::error_strip(&theme, error)) + .into_any_element(), + ); + } settings_rows.extend(self.render_theme_library_rows(&theme, cx)); let library_warning = self .library_error diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 802842961..759f60ceb 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -1,7 +1,7 @@ //! The app shell (zeron `__root.tsx`): sidebar column + main panel + optional //! right "Changes" pane, plus the boot splash and the connection gate. //! -//! Layout is zeron's: collapsible drag-resizable sidebar (208–400px, default +//! Layout is zeron's: collapsible drag-resizable sidebar (224–400px, default //! 256) with a 200ms ease-out width transition; main panel with an h-11 header, //! content outlet, and a reserved h-6 status strip so later content never //! shifts; right pane scaffold (360px floor, default 520), hidden by default. @@ -46,8 +46,9 @@ use crate::settings::shortcuts::{ShortcutsEvent, ShortcutsPage}; use crate::settings::{ self, CHAT_PANEL_MIN, ComposerSendBehavior, JUMP_SLOTS, KeymapConfig, RIGHT_PANE_DEFAULT, RIGHT_PANE_MIN, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN, SavePolicy, ShortcutId, - SidebarOrganization, SidebarSort, TERMINAL_DEFAULT_HEIGHT, UiSettings, badge_combo, - jump_hints_visible, modifier_send_hint_visible, platform_combo, + SidebarOrganization, SidebarSort, TERMINAL_DEFAULT_HEIGHT, TERMINAL_MAX_VH, + TERMINAL_MIN_HEIGHT, UiSettings, badge_combo, jump_hints_visible, modifier_send_hint_visible, + platform_combo, }; use crate::state::{ AppState, ConnectionStatus, EngineBootConfig, EngineMode, GatePhase, Indicator, OrgRow, @@ -170,8 +171,9 @@ impl SidebarDisclosureMotion { /// Vertical pane resize hitboxes yield the global titlebar. Keeping this in /// the shared constructor makes left/right seams mirror each other and avoids /// relying on paint order when chrome crosses an animated pane boundary. -const PANE_RESIZE_HITBOX_HALF_WIDTH: f32 = 6.0; +const PANE_RESIZE_HITBOX_HALF_WIDTH: f32 = 10.0; const PANE_RESIZE_HITBOX_TOP: f32 = Theme::TITLEBAR_HEIGHT; +const TERMINAL_RESIZE_HITBOX_HEIGHT: f32 = 10.0; fn stable_panel_content_width(target: f32, transition: Option<(f32, f32)>) -> f32 { transition.map(|(from, to)| from.max(to)).unwrap_or(target) @@ -687,11 +689,42 @@ const SIDEBAR_ARCHIVED_HARNESS_TITLE_GAP: f32 = 10.0; /// [`gpui::EdgeFade`] scope — per-primitive, so text fades per glyph). const SIDEBAR_GLASS_FADE_BAND: f32 = 24.0; +/// New-thread controls float over the tail of a top-anchored image hero. The +/// hero never occupies half the viewport, and its lower mask dissolves into +/// the page before the otherwise empty lower canvas. +const NEW_THREAD_BACKGROUND_FROSTED_OPACITY: f32 = 0.84; +const NEW_THREAD_BACKGROUND_VIEWPORT_RATIO: f32 = 0.46; +const NEW_THREAD_BACKGROUND_MAX_HEIGHT: f32 = 440.0; + /// Drag marker for the sidebar resize handle. struct SidebarResize; /// Drag marker for the right-pane resize handle. struct RightPaneResize; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PaneResizeKind { + Sidebar, + Right, + Terminal, +} + +/// Resolve one pointer sample while keeping the persisted width legal. The +/// edge is latched by the caller, so a held pointer produces one nudge rather +/// than restarting the animation for every drag event. +fn sidebar_drag_sample( + pointer_x: f32, + latched_edge: Option, + reduced_motion: bool, +) -> motion::ResizeDragSample { + motion::resize_drag_sample( + pointer_x, + SIDEBAR_MIN, + SIDEBAR_MAX, + latched_edge, + reduced_motion, + ) +} + /// The dragged surface-tab payload (strip reorder). struct RightTabDrag { panel_key: String, @@ -791,6 +824,80 @@ impl WidthTween { } } +fn titlebar_island_vertical_geometry(progress: f32) -> (f32, f32) { + // Match the padded flex row's center, not the raw titlebar center. + // Keep the native 24px controls untouched and give them 4px of air. + let height = 28.0 + 4.0 * progress.clamp(0.0, 1.0); + let center = (Theme::TITLEBAR_HEIGHT + Theme::TITLEBAR_TOP_PAD) * 0.5; + (center - height * 0.5, height) +} + +fn bottom_stack_measurement_matches( + measured_has_composer: bool, + expected_has_composer: bool, +) -> bool { + measured_has_composer == expected_has_composer +} + +fn new_thread_background_opacity(is_frost: bool) -> f32 { + if is_frost { + NEW_THREAD_BACKGROUND_FROSTED_OPACITY + } else { + 1.0 + } +} + +fn new_thread_background_height(viewport_height: f32) -> f32 { + (viewport_height.max(0.0) * NEW_THREAD_BACKGROUND_VIEWPORT_RATIO) + .min(NEW_THREAD_BACKGROUND_MAX_HEIGHT) +} + +fn new_thread_background( + artwork: Option>, + viewport_height: f32, + hero_width: f32, + composer_bounds: crate::new_thread_background_mask::SurfaceBounds, + dissolve: f32, + opacity: f32, +) -> AnyElement { + let Some(artwork) = artwork else { + return Empty.into_any_element(); + }; + let hero_height = new_thread_background_height(viewport_height); + let dissolve = dissolve.clamp(0.0, 1.0); + // Image and treatment share a fixed crop and fade together in place. + // The hero uses the full conversation canvas even while the destination + // right pane clips it. Navigation must never rescale the artwork. + div() + .absolute() + .top_0() + .left_0() + .w(px(hero_width)) + .h(px(hero_height)) + .overflow_hidden() + .opacity((1.0 - dissolve) * opacity) + // Alpha resolves into the real canvas, including translucent themes; + // no theme-colored overlay bleaches or darkens the source pixels. + .child( + gpui::canvas( + |_, _, _| {}, + move |bounds, _, window, _cx| { + if let Some(composer) = composer_bounds.get() { + crate::new_thread_background_mask::paint( + artwork.clone(), + bounds, + composer, + window, + ); + } + }, + ) + .absolute() + .inset_0(), + ) + .into_any_element() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SplashPhase { Visible, @@ -1171,11 +1278,16 @@ pub struct Shell { /// column; a drop stages an image or inserts a file-mention chip. file_drag_active: bool, /// Measured height of the bottom chrome stack (status strip + composer + - /// terminal dock) the full-height transcript scrolls under — written by a - /// paint-time canvas each frame, read the NEXT frame for the fade inset, - /// the transcript's bottom clearance, and the jump pill's anchor (the - /// same one-frame lag every fade here rides). + /// terminal dock) the full-height transcript scrolls under. Paint-time + /// measurement schedules another frame whenever this value changes. bottom_stack: std::rc::Rc>, + /// Whether `bottom_stack` was measured with the session composer present. + /// A newly selected transcript stays hidden until this matches its route, + /// preventing one frame at the blank canvas's stale bottom clearance. + bottom_stack_has_composer: std::rc::Rc>, + /// Shared route clock and measured prepaint geometry for the persistent composer. + composer_dock: crate::composer_dock::SharedDock, + new_thread_artwork_ready: crate::new_thread_background_effects::Readiness, /// The sidebar's archived accordion (t3code Sidebar): OPEN by default /// (user request), session-transient. `archived_shown` pages the /// expanded list ("Show more" reveals another page). @@ -1343,7 +1455,18 @@ pub struct Shell { debug_gate: Option, debug_upload: Option, sidebar_tween: Option, + sidebar_edge_bounce: Option, + /// Boundary currently held during a sidebar drag. Cleared on re-entry or + /// release so the next genuine edge crossing can acknowledge the limit. + sidebar_resize_edge: Option, + /// Gesture-owned resize feedback. Unlike hover, this stays active while + /// the seam moves away from the pointer and clears only on release or when + /// a constrained edge takes over with its bounce cue. + pane_resize_active: Option, + pane_resize_dragging: Option, right_tween: Option, + right_edge_bounce: Option, + right_resize_edge: Option, /// Mirrors `right_tween` only for takeover entry/exit, allowing the visible /// right-panel contents to resize with their outer frame in that mode. right_takeover_content_tween: Option, @@ -1358,12 +1481,14 @@ pub struct Shell { /// width target and the physical ceiling for free-form resizing /// ([`Self::right_target`] has no `Window`). viewport_width: f32, + viewport_height: f32, terminal_tween: Option, /// Last observed `window.is_fullscreen()` (`None` before first paint) — /// flips key the traffic-light inset tween. fullscreen: Option, /// 200ms ease-out tween of the cluster start on fullscreen toggles. titlebar_tween: Option, + titlebar_island: Option, /// Armed by mouse-down on a titlebar strip; the next mouse-move hands the /// drag to the compositor (zed's platform-titlebar pattern). titlebar_should_move: bool, @@ -1419,6 +1544,7 @@ impl Shell { cx.notify(); }); let transcript = cx.new(|cx| Transcript::new(state.clone(), cx)); + transcript.update(cx, |transcript, _| transcript.retain_for_route_exit()); let composer = cx.new(|cx| Composer::new(state.clone(), cx)); let shell = cx.weak_entity(); transcript.update(cx, |transcript, _| { @@ -1437,6 +1563,10 @@ impl Shell { let composer_events = cx.subscribe(&composer, { let transcript = transcript.clone(); move |_this: &mut Shell, _, event: &ComposerEvent, cx| match event { + ComposerEvent::NewThreadTransitionStarted => { + // Route observation drives the dock once selection commits. + cx.notify(); + } ComposerEvent::Sent { chat_id, message_id, @@ -1562,6 +1692,9 @@ impl Shell { // Seed with the compact composer stack's rough height so the // first frame's clearance isn't zero (the measure corrects it). bottom_stack: std::rc::Rc::new(std::cell::Cell::new(120.0)), + bottom_stack_has_composer: std::rc::Rc::new(std::cell::Cell::new(false)), + composer_dock: Default::default(), + new_thread_artwork_ready: Default::default(), archived_open: true, archived_shown: 0, archived_hover: None, @@ -1651,14 +1784,22 @@ impl Shell { debug_gate, debug_upload, sidebar_tween: None, + sidebar_edge_bounce: None, + sidebar_resize_edge: None, + pane_resize_active: None, + pane_resize_dragging: None, right_tween: None, + right_edge_bounce: None, + right_resize_edge: None, right_takeover_content_tween: None, main_takeover_tween: None, right_pane_expanded: false, viewport_width: 1280.0, + viewport_height: 880.0, terminal_tween: None, fullscreen: None, titlebar_tween: None, + titlebar_island: None, titlebar_should_move: false, linux_captions: None, button_layout_sub: None, @@ -2101,7 +2242,7 @@ impl Shell { // Manual sizing preserves a usable conversation column. Takeover // intentionally consumes it completely. Both ride the sidebar // tween so toggling it remains seamless. - let sidebar_now = self.eval_tween(self.sidebar_tween, self.sidebar_target()); + let sidebar_now = self.sidebar_now(); if self.right_pane_expanded { right_pane_takeover_width(self.viewport_width, sidebar_now) } else { @@ -2113,7 +2254,11 @@ impl Shell { } fn toggle_sidebar(&mut self, cx: &mut Context) { - let from = self.eval_tween(self.sidebar_tween, self.sidebar_target()); + let from = self.sidebar_now(); + self.sidebar_edge_bounce = None; + self.sidebar_resize_edge = None; + self.pane_resize_active = None; + self.pane_resize_dragging = None; self.settings.sidebar_collapsed = !self.settings.sidebar_collapsed; self.sidebar_tween = Some(WidthTween::new(from, self.sidebar_target())); self.schedule_save(cx); @@ -2123,7 +2268,10 @@ impl Shell { fn toggle_right_pane(&mut self, cx: &mut Context) { // Reverse from the visible width when toggled during an animation. let from = self.eval_tween(self.right_tween, self.right_target(cx)); - let sidebar_now = self.eval_tween(self.sidebar_tween, self.sidebar_target()); + self.right_edge_bounce = None; + self.right_resize_edge = None; + self.finish_pane_resize(PaneResizeKind::Right); + let sidebar_now = self.sidebar_now(); let from_main = conversation_width(self.viewport_width, sidebar_now, from); let was_expanded = self.right_pane_expanded; let key = self.panel_key(cx); @@ -3149,7 +3297,12 @@ impl Shell { }; let dy = anchor_y - f32::from(event.event.position.y); let viewport_h = f32::from(window.viewport_size().height); - self.settings.terminal_height = clamp_terminal_height(anchor_h + dy, viewport_h); + let requested = anchor_h + dy; + let max = (viewport_h * TERMINAL_MAX_VH).max(TERMINAL_MIN_HEIGHT); + self.settings.terminal_height = clamp_terminal_height(requested, viewport_h); + self.pane_resize_dragging = Some(PaneResizeKind::Terminal); + self.pane_resize_active = (requested > TERMINAL_MIN_HEIGHT && requested < max) + .then_some(PaneResizeKind::Terminal); self.terminal_tween = None; // live drag tracks the pointer self.schedule_save(cx); cx.notify(); @@ -3162,13 +3315,36 @@ impl Shell { cx: &mut Context, ) { let x = f32::from(event.event.position.x); - self.settings.sidebar_width = x.clamp(SIDEBAR_MIN, SIDEBAR_MAX); + let sample = sidebar_drag_sample(x, self.sidebar_resize_edge, self.reduced_motion); + self.settings.sidebar_width = sample.width; self.settings.sidebar_collapsed = false; + self.pane_resize_dragging = Some(PaneResizeKind::Sidebar); self.sidebar_tween = None; // live drag tracks the pointer directly + if sample.starts_bounce { + self.sidebar_edge_bounce = sample.edge.map(motion::ResizeEdgeBounce::new); + } else if sample.edge.is_none() { + self.sidebar_edge_bounce = None; + } + self.pane_resize_active = sample.edge.is_none().then_some(PaneResizeKind::Sidebar); + self.sidebar_resize_edge = sample.edge; self.schedule_save(cx); cx.notify(); } + fn finish_pane_resize(&mut self, kind: PaneResizeKind) { + if self.pane_resize_active == Some(kind) { + self.pane_resize_active = None; + } + if self.pane_resize_dragging == Some(kind) { + self.pane_resize_dragging = None; + } + match kind { + PaneResizeKind::Sidebar => self.sidebar_resize_edge = None, + PaneResizeKind::Terminal => self.terminal_drag_anchor = None, + PaneResizeKind::Right => self.right_resize_edge = None, + } + } + fn on_right_pane_drag( &mut self, event: &gpui::DragMoveEvent, @@ -3180,11 +3356,30 @@ impl Shell { // No arbitrary percentage ceiling, but retain the chat's usable 300px // floor instead of allowing the conversation to collapse to zero. let max = right_pane_max_width(viewport, self.sidebar_target()); - self.settings.right_pane_width = if max >= RIGHT_PANE_MIN { - width.clamp(RIGHT_PANE_MIN, max) + let sample = if max >= RIGHT_PANE_MIN { + motion::resize_drag_sample( + width, + RIGHT_PANE_MIN, + max, + self.right_resize_edge, + self.reduced_motion, + ) } else { - max + motion::ResizeDragSample { + width: max, + edge: None, + starts_bounce: false, + } }; + self.settings.right_pane_width = sample.width; + self.pane_resize_dragging = Some(PaneResizeKind::Right); + if sample.starts_bounce { + self.right_edge_bounce = sample.edge.map(motion::ResizeEdgeBounce::new); + } else if sample.edge.is_none() { + self.right_edge_bounce = None; + } + self.pane_resize_active = sample.edge.is_none().then_some(PaneResizeKind::Right); + self.right_resize_edge = sample.edge; self.right_tween = None; self.right_takeover_content_tween = None; self.main_takeover_tween = None; @@ -3203,11 +3398,20 @@ impl Shell { self.settings.theme_selection = crate::appearance::themes(cx); self.settings.accent = crate::appearance::accent(cx); self.settings.surface = crate::appearance::surface(cx); + self.sync_background_settings(cx); self.settings.ui_font_family = crate::typography::requested(cx); self.settings.ui_font_size = crate::typography::font_size(cx); settings::replace(self.settings.clone(), SavePolicy::Debounced, cx); } + /// Appearance owns these choices. A geometry save must never publish the + /// shell's older effect value over a selection made since its last render. + fn sync_background_settings(&mut self, cx: &App) { + let current = settings::current(cx); + self.settings.new_thread_composer_background = current.new_thread_composer_background; + self.settings.new_thread_background_effect = current.new_thread_background_effect; + } + fn retry_engine(&mut self, cx: &mut Context) { AppState::bootstrap(self.state.clone(), self.boot.clone(), cx); } @@ -4335,6 +4539,41 @@ impl Shell { motion::lerp(from, to, RESIZE.progress(raw)) } + fn eval_resize_edge_bounce( + &self, + bounce: Option, + enabled: bool, + ) -> f32 { + let Some(bounce) = bounce else { + return 0.0; + }; + if self.reduced_motion || !enabled { + return 0.0; + } + let total = + Duration::from_millis(motion::RESIZE_EDGE_BOUNCE_MS).mul_f32(motion::speed_scale()); + let raw = self.tween_elapsed(bounce.started).as_secs_f32() / total.as_secs_f32(); + if raw >= 1.0 { + return 0.0; + } + self.motion_active.set(true); + motion::resize_bounce_offset(bounce.edge, raw) + } + + pub(super) fn sidebar_now(&self) -> f32 { + self.eval_tween(self.sidebar_tween, self.sidebar_target()) + + self + .eval_resize_edge_bounce(self.sidebar_edge_bounce, !self.settings.sidebar_collapsed) + } + + fn right_now(&self, cx: &App) -> f32 { + self.eval_tween(self.right_tween, self.right_target(cx)) + + self.eval_resize_edge_bounce( + self.right_edge_bounce, + self.right_pane_open(cx) && !self.right_pane_expanded, + ) + } + fn tween_active(&self, tween: Option) -> bool { tween.is_some_and(|tween| { !self.reduced_motion @@ -4352,23 +4591,6 @@ impl Shell { .map(|transition| (transition.from, transition.to)) } - /// Animated width container: tweens 200ms ease-out on collapse/expand, and - /// clips a fixed-width inner so content never reflows mid-transition. - fn pane_container( - &self, - tween: Option, - target: f32, - inner: AnyElement, - ) -> AnyElement { - div() - .h_full() - .flex_none() - .overflow_hidden() - .w(px(self.eval_tween(tween, target))) - .child(inner) - .into_any_element() - } - /// Right-anchored variant for the changes pane. The outer width follows the /// existing shell tween, while descendants retain the larger endpoint's /// geometry for that 200ms transition. This mirrors the sidebar's stable @@ -4378,19 +4600,21 @@ impl Shell { &self, tween: Option, target: f32, + edge_offset: f32, inner: AnyElement, ) -> AnyElement { let takeover_width = self .active_tween_endpoints(self.right_takeover_content_tween) .map(|_| self.eval_tween(self.right_takeover_content_tween, target)); let content_width = - right_panel_content_width(target, self.active_tween_endpoints(tween), takeover_width); + right_panel_content_width(target, self.active_tween_endpoints(tween), takeover_width) + + edge_offset; div() .h_full() .flex_none() .relative() .overflow_hidden() - .w(px(self.eval_tween(tween, target))) + .w(px(self.eval_tween(tween, target) + edge_offset)) .child( div() .absolute() @@ -4529,6 +4753,30 @@ impl Shell { // leave two competing + placements across the responsive variants. let plus_alpha = self.titlebar_plus_alpha(cx); let show_plus = plus_alpha > 0.01; + let island_target = if matches!(self.route, Route::Chat) + && self.state.read(cx).selected_chat.is_none() + && self.settings.sidebar_collapsed + && settings::current(cx) + .new_thread_composer_background + .as_ref() + .is_some_and(|background| std::path::Path::new(&background.path).is_file()) + { + 1.0 + } else { + 0.0 + }; + // Persistent manual tween: reversals start from the painted value, + // initial presentation is settled, and reduced motion snaps. + match self.titlebar_island { + None => self.titlebar_island = Some(WidthTween::new(island_target, island_target)), + Some(previous) if previous.to != island_target => { + let from = self.eval_tween(Some(previous), previous.to); + self.titlebar_island = Some(WidthTween::new(from, island_target)); + } + _ => {} + } + let island = self.eval_tween(self.titlebar_island, island_target); + let (island_top, island_height) = titlebar_island_vertical_geometry(island); div() .absolute() .top_0() @@ -4539,6 +4787,26 @@ impl Shell { .items_center() .pt(px(Theme::TITLEBAR_TOP_PAD)) .px(px(TITLEBAR_CLUSTER_PAD)) + .child( + div() + .absolute() + .left(px(6.0)) + .right_0() + .top(px(island_top)) + .h(px(island_height)) + .opacity(island) + .children((island > 0.001).then(|| { + crate::frost::frosted( + 12.0, + 20.0, + div() + .size_full() + .rounded(px(12.0)) + .bg(theme.glass_overlay()) + .shadow_sm(), + ) + })), + ) .children(self.titlebar_spacer(TITLEBAR_CLUSTER_PAD)) // Left-side Linux captions (GNOME `close:…` layouts): the // root-level caption overlay owns the buttons; the cluster row @@ -4795,20 +5063,17 @@ impl Shell { .h_full() .flex_none(), ); - let target = self.sidebar_target(); // Transparent — the sidebar sits directly on the frost shell; the main // card's own border provides the separation. The content row spans the // full window height (the titlebar overlays it), so the column pads // itself below the chrome. - self.pane_container( - self.sidebar_tween, - target, - div() - .h_full() - .pt(px(Theme::TITLEBAR_HEIGHT)) - .child(inner) - .into_any_element(), - ) + div() + .h_full() + .flex_none() + .overflow_hidden() + .w(px(self.sidebar_now())) + .child(div().h_full().pt(px(Theme::TITLEBAR_HEIGHT)).child(inner)) + .into_any_element() } /// Settings-mode sidebar (zeron settings-sidebar.tsx): window-control @@ -6662,6 +6927,7 @@ impl Shell { fn resize_handle( &self, id: &'static str, + kind: PaneResizeKind, marker: fn() -> T, reset: fn(&mut Shell, &mut Context), cx: &mut Context, @@ -6671,12 +6937,23 @@ impl Shell { { let theme = Theme::of(cx); let fade_key = format!("pane-resize-{id}"); - let highlight = motion::hover_blend( + let hover_highlight = motion::hover_blend( &fade_key, theme.border_strong.opacity(0.0), theme.border_strong, ); + let active = self.pane_resize_active == Some(kind); + let constrained = self.pane_resize_dragging == Some(kind) && !active; + let highlight = if constrained { + theme.border_strong.opacity(0.0) + } else if active { + theme.border_strong + } else { + hover_highlight + }; let clear = highlight.opacity(0.0); + let release_key = fade_key.clone(); + let release_out_key = fade_key.clone(); div() .id(id) .absolute() @@ -6695,7 +6972,7 @@ impl Shell { .absolute() .top_0() .bottom_0() - .left(px(6.0)) + .left(px(PANE_RESIZE_HITBOX_HALF_WIDTH)) .w(px(1.0)) .flex() .flex_col() @@ -6710,23 +6987,48 @@ impl Shell { gpui::linear_color_stop(clear, 1.0), ))), ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _, cx| { + this.pane_resize_dragging = Some(kind); + this.pane_resize_active = Some(kind); + cx.notify(); + }), + ) .on_drag(marker(), |_, _point: Point, _, cx| { cx.stop_propagation(); cx.new(|_| DragGhost) }) .on_mouse_up( MouseButton::Left, - cx.listener(move |this, event: &MouseUpEvent, _, cx| { + cx.listener(move |this, event: &MouseUpEvent, window, cx| { if event.click_count == 2 { reset(this, cx); this.schedule_save(cx); cx.notify(); } + this.finish_pane_resize(kind); + motion::set_hover(&release_key, false, this.reduced_motion); + window.refresh(); + }), + ) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(move |this, _, window, _| { + this.finish_pane_resize(kind); + motion::set_hover(&release_out_key, false, this.reduced_motion); + window.refresh(); }), ) } - fn render_main(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + fn render_main( + &mut self, + window: &mut Window, + main_content_width: f32, + transcript_width: f32, + cx: &mut Context, + ) -> AnyElement { let theme_owned = Theme::of(cx).clone(); let theme = &theme_owned; let (border, text, faint) = (theme.border, theme.text, theme.text_faint); @@ -6752,15 +7054,94 @@ impl Shell { let has_spaces = !self.state.read(cx).spaces.is_empty(); let has_appshots = !self.composer.read(cx).staged_appshots().is_empty(); let no_project = self.state.read(cx).no_project; + let transcript_geometry_ready = bottom_stack_measurement_matches( + self.bottom_stack_has_composer.get(), + (has_spaces || no_project || has_appshots) && has_selection, + ); + let ui_settings = settings::current(cx); + let new_thread_background_setting = ui_settings.new_thread_composer_background; + let new_thread_background_effect = ui_settings.new_thread_background_effect; + let frame_time = self.render_time.unwrap_or_else(std::time::Instant::now); + // Prewarm even in an established thread. Decode/effect work is not + // contingent on a hero measurement or a navigation gesture. + let artwork = new_thread_background_setting + .as_ref() + .and_then(|background| { + crate::new_thread_background_effects::prepare( + new_thread_background_effect, + theme, + std::path::Path::new(&background.path), + cx, + ) + }); + let artwork_opacity = self.new_thread_artwork_ready.opacity( + artwork.as_ref().map(|image| image.id), + self.reduced_motion, + frame_time, + ); + let dock_frame = + self.composer_dock + .borrow_mut() + .tick(has_selection, self.reduced_motion, frame_time); + if dock_frame.active { + self.motion_active.set(true); + } + self.composer + .update(cx, |composer, cx| composer.set_dock_frame(dock_frame, cx)); + let composer_width = self.composer_dock.borrow_mut().layout_width( + main_content_width.min(crate::composer::COMPOSER_MAX_WIDTH), + self.reduced_motion, + frame_time, + ); + self.composer.update(cx, |composer, cx| { + composer.set_available_width(composer_width, cx) + }); + let term_h = self.eval_tween(self.terminal_tween, self.terminal_target(cx)); + let new_thread_background_layer = (!has_selection || dock_frame.active).then(|| { + if artwork.is_some() && artwork_opacity < 1.0 { + window.request_animation_frame(); + } + new_thread_background( + artwork, + self.viewport_height, + (self.viewport_width - self.sidebar_now()).max(0.0), + self.composer.read(cx).surface_bounds(), + dock_frame.dissolve(), + artwork_opacity * new_thread_background_opacity(theme.is_frost()), + ) + }); - // Content outlet: selected chat → transcript; nothing selected → a - // bare canvas (the composer stack carries the affordances); no spaces - // at all → the onboarding card. The composer sits below the first two - // (new-chat mode mints the chat id on first send). - let outlet: AnyElement = if has_selection { + // Content outlet: selected chat → transcript; nothing selected → the + // centered new-thread composition; no spaces at all → the onboarding + // card. New-chat mode mints the chat id on first send. + let departing_transcript = !has_selection && dock_frame.transcript() > 0.0; + if !has_selection && !departing_transcript { self.transcript - .clone() - .cached(gpui::StyleRefinement::default().size_full()) + .update(cx, |transcript, cx| transcript.finish_route_exit(cx)); + } + let outlet: AnyElement = if has_selection || departing_transcript { + div() + .relative() + .size_full() + .overflow_hidden() + .child( + div() + .relative() + .top(px(8.0 * (1.0 - dock_frame.transcript()))) + .size_full() + .when(departing_transcript, |el| el.w(px(transcript_width))) + .opacity(if transcript_geometry_ready || departing_transcript { + dock_frame.transcript() + } else { + 0.0 + }) + .child(self.transcript.clone()), + ) + // A departing transcript is visual history, not an active + // interaction surface bound to the newly blank route. + .when(departing_transcript, |el| { + el.child(div().absolute().inset_0().occlude()) + }) .into_any_element() } else if !has_spaces && !no_project { // Onboarding (first boot / after the destructive wipe): no folders @@ -6810,11 +7191,7 @@ impl Shell { )) .into_any_element() } else { - // New-chat canvas: intentionally bare (user request — no logo, no - // helper line). The device + project selectors live above the - // composer pill (composer.rs renders them via - // `render_target_selectors`). - div().size_full().into_any_element() + Empty.into_any_element() }; let status = self.render_status_strip(cx); @@ -6886,6 +7263,10 @@ impl Shell { } cx.notify(); })) + // The hero is deliberately outside the transcript EdgeFade below: + // it must paint under the overlaid titlebar instead of becoming + // fully transparent across the titlebar's inset band. + .children(new_thread_background_layer) .child( // Full-height underlay: the transcript viewport spans the // whole column, scrolling UNDER the titlebar above and the @@ -6907,31 +7288,25 @@ impl Shell { // tween the dock animates with; `stack_h` below is only // the chrome that still overlaps the transcript (status // strip + composer). - let term_h = self.eval_tween(self.terminal_tween, self.terminal_target(cx)); let stack_h = (self.bottom_stack.get() - term_h).max(0.0); // Opaque from the composer PILL's top (the reserved // status strip above it is empty air), zero at the // underlay's bottom edge. let bottom_band = (stack_h - Theme::STATUS_STRIP_HEIGHT).max(1.0); - div() - .absolute() - .inset_0() - .bottom(px(term_h)) - .child( - crate::edge_fade::edge_faded( - Theme::TRANSCRIPT_FADE_BAND, - true, - true, - div().size_full().child(outlet), - ) - // Fully faded BY the titlebar's bottom edge (the - // title text is opaque — overlap read as collision), - // ramping in the band just below it. - .inset_top(Theme::TITLEBAR_HEIGHT) - .band_top(Theme::TRANSCRIPT_FADE_BAND) - .band_bottom(bottom_band), + div().absolute().inset_0().bottom(px(term_h)).child( + crate::edge_fade::edge_faded( + Theme::TRANSCRIPT_FADE_BAND, + true, + true, + div().size_full().child(outlet), ) - .children(self.render_jump_to_bottom(stack_h, cx)) + // Fully faded BY the titlebar's bottom edge (the + // title text is opaque — overlap read as collision), + // ramping in the band just below it. + .inset_top(Theme::TITLEBAR_HEIGHT) + .band_top(Theme::TRANSCRIPT_FADE_BAND) + .band_bottom(bottom_band), + ) }, ) // The glass chrome stack, floating over the transcript's bottom: @@ -6943,6 +7318,9 @@ impl Shell { .child(div().flex_1().min_h_0()) .child({ let measured = self.bottom_stack.clone(); + let measured_has_composer = self.bottom_stack_has_composer.clone(); + let contains_composer = (has_spaces || no_project || has_appshots) && has_selection; + let composer = self.composer.clone(); div() .flex_none() .relative() @@ -6950,15 +7328,44 @@ impl Shell { .flex_col() .child( gpui::canvas( - move |bounds, _, _| measured.set(f32::from(bounds.size.height)), + move |bounds, window, cx| { + // Reserve the destination footprint, never the animated height. + let next_height = f32::from(bounds.size.height) + + composer.read(cx).dock_clearance_correction(); + let changed = (measured.get() - next_height).abs() > 0.5 + || measured_has_composer.get() != contains_composer; + measured.set(next_height); + measured_has_composer.set(contains_composer); + if changed { + window.request_animation_frame(); + } + }, |_, _, _, _| {}, ) .absolute() .inset_0(), ) .child(status) - .when(has_spaces || has_appshots, |el| { - el.child(self.composer.clone()) + .when(has_spaces || no_project || has_appshots, |el| { + let composer_opacity = self.composer_dock.borrow().opacity(); + el.child(crate::composer_dock::docked_composer( + div() + .id("persistent-composer") + .relative() + .w(px(composer_width)) + .opacity(composer_opacity) + .mx_auto() + .child(self.composer.clone()) + .children(if has_selection { + self.render_jump_to_bottom(cx) + } else { + None + }), + self.composer_dock.clone(), + self.viewport_height, + self.reduced_motion, + frame_time, + )) }) .child(self.render_terminal_container(cx)) }) @@ -6982,25 +7389,19 @@ impl Shell { /// The "↓ Scroll to bottom" pill (round-9 §3): a LABELED rounded-full /// chip — down-arrow glyph + 13px label on a near-opaque raised surface /// with a hairline — horizontally centered over the transcript column and - /// floating a small gap above the composer. It hangs 14px below the - /// conversation region (through the reserved h-6 status strip, whose - /// content is left-aligned) so its bottom edge sits ~10px above the pill. - /// Shown past the transcript's 320px threshold; 180ms fade + 2px rise in. - /// `stack_h` is the measured bottom chrome stack the full-height - /// transcript scrolls under — the pill anchors just above it (the -14 - /// carries the old status-strip overlap). - fn render_jump_to_bottom( - &mut self, - stack_h: f32, - cx: &mut Context, - ) -> Option { + /// floating six pixels above the composer. It shares the composer's + /// measured dock transform and paints after it, outside the transcript fade. + fn render_jump_to_bottom(&mut self, cx: &mut Context) -> Option { if !self.transcript.read(cx).jump_button_shown() { return None; } Some( div() .absolute() - .bottom(px(stack_h - 14.0)) + // Share the composer's measured translation, not its final + // bottom-stack target. Paint after the composer so it cannot + // pass over this control during docking. + .top(px(-36.0)) .left_0() .right(px(10.0)) .flex() @@ -7104,21 +7505,48 @@ impl Shell { return gpui::Empty.into_any_element(); }; let border = Theme::of(cx).border; - let handle_hover = Theme::of(cx).border_strong; + let handle_key = "pane-resize-terminal-resize"; + let handle_hover = motion::hover_blend( + handle_key, + Theme::of(cx).border_strong.opacity(0.0), + Theme::of(cx).border_strong, + ); + let terminal_active = self.pane_resize_active == Some(PaneResizeKind::Terminal); + let terminal_constrained = + self.pane_resize_dragging == Some(PaneResizeKind::Terminal) && !terminal_active; + let handle_highlight = if terminal_constrained { + Theme::of(cx).border_strong.opacity(0.0) + } else if terminal_active { + Theme::of(cx).border_strong + } else { + handle_hover + }; let height = self.settings.terminal_height; let handle = div() .id("terminal-resize") - .h(px(5.0)) + .h(px(TERMINAL_RESIZE_HITBOX_HEIGHT)) .w_full() .flex_none() .cursor_row_resize() - .hover(move |s| s.bg(handle_hover)) + .on_hover(motion::hover_listener(handle_key)) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .h(px(1.0)) + .bg(handle_highlight), + ) .on_mouse_down( MouseButton::Left, - cx.listener(|this, event: &gpui::MouseDownEvent, _, _| { + cx.listener(|this, event: &gpui::MouseDownEvent, _, cx| { this.terminal_drag_anchor = Some((f32::from(event.position.y), this.settings.terminal_height)); + this.pane_resize_dragging = Some(PaneResizeKind::Terminal); + this.pane_resize_active = Some(PaneResizeKind::Terminal); + cx.notify(); }), ) .on_drag(TerminalResize, |_, _point: Point, _, cx| { @@ -7127,19 +7555,30 @@ impl Shell { }) .on_mouse_up( MouseButton::Left, - cx.listener(|this, event: &MouseUpEvent, _, cx| { + cx.listener(|this, event: &MouseUpEvent, window, cx| { if event.click_count == 2 { this.settings.terminal_height = TERMINAL_DEFAULT_HEIGHT; this.schedule_save(cx); cx.notify(); } + this.finish_pane_resize(PaneResizeKind::Terminal); + motion::set_hover(handle_key, false, this.reduced_motion); + window.refresh(); + }), + ) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|this, _, window, _| { + this.finish_pane_resize(PaneResizeKind::Terminal); + motion::set_hover(handle_key, false, this.reduced_motion); + window.refresh(); }), ); // Fixed-height inner clipped by the animated container: content never // reflows mid-transition (same trick as the side panes). The handle // FLOATS over the panel's top edge (painted after, so it wins hit - // testing) instead of stacking above it — stacked, its 5px read as + // testing) instead of stacking above it — stacked, its hitbox would read as // dead air between the seam and the tab bar (user report). let inner = div() .h(px(height)) @@ -7370,9 +7809,14 @@ impl Shell { .pt(px(Theme::TITLEBAR_HEIGHT)) .child(content); let target = self.right_target(cx); + let edge_offset = self.eval_resize_edge_bounce( + self.right_edge_bounce, + self.right_pane_open(cx) && !self.right_pane_expanded, + ); self.right_pane_container( self.right_tween, target, + edge_offset, div().h_full().relative().child(panel).into_any_element(), ) } @@ -8050,7 +8494,10 @@ impl Shell { /// width. Rides the same width tween as open/close so the jump glides. fn toggle_right_pane_expand(&mut self, cx: &mut Context) { let from = self.right_target(cx); - let sidebar_now = self.eval_tween(self.sidebar_tween, self.sidebar_target()); + self.right_edge_bounce = None; + self.right_resize_edge = None; + self.finish_pane_resize(PaneResizeKind::Right); + let sidebar_now = self.sidebar_now(); let from_main = conversation_width(self.viewport_width, sidebar_now, from); self.right_pane_expanded = !self.right_pane_expanded; let to = self.right_target(cx); @@ -8756,6 +9203,7 @@ impl Render for Shell { self.settings.theme_selection = crate::appearance::themes(cx); self.settings.accent = crate::appearance::accent(cx); self.settings.surface = crate::appearance::surface(cx); + self.sync_background_settings(cx); let theme = Theme::of(cx); // The shell tone (zeron `.frost`): the surface the sidebar sits on and // the main panel floats over as an inset rounded card. On macOS the @@ -9031,52 +9479,84 @@ impl Render for Shell { } // MessageRail width gate: hide below 48rem of main-panel width. let viewport = f32::from(window.viewport_size().width); + self.viewport_height = f32::from(window.viewport_size().height); // Stamped for `right_target` — the expanded changes panel // sizes itself to the viewport. self.viewport_width = viewport; + let on_chat = matches!(self.route, Route::Chat); + let right_target_width = if on_chat { self.right_now(cx) } else { 0.0 }; + let panel_handoff = self.composer_dock.borrow_mut().observe_pane( + self.state.read(cx).selected_chat.is_some(), + right_target_width, + on_chat && !self.reduced_motion, + self.render_time.unwrap_or_else(std::time::Instant::now), + ); + if panel_handoff { + self.motion_active.set(true); + } let main_target_width = - conversation_width(viewport, self.sidebar_target(), self.right_target(cx)); + conversation_width(viewport, self.sidebar_target(), right_target_width); let main_transition = self.active_tween_endpoints(self.main_takeover_tween); let main_content_width = stable_panel_content_width(main_target_width, main_transition); - let main_width = (main_content_width - 10.0).max(0.0); - self.composer.update(cx, |composer, cx| { - composer.set_available_width(main_width, cx) - }); + let transcript_width = self.composer_dock.borrow_mut().transcript_width( + main_content_width, + self.state.read(cx).selected_chat.is_some(), + panel_handoff, + ); + let main_width = (transcript_width - 10.0).max(0.0); // Clearance excludes the terminal dock: the transcript // viewport ends at the dock's top (see the underlay in // `render_main`), so only the chrome above it overlaps. let term_h = self.eval_tween(self.terminal_tween, self.terminal_target(cx)); let stack_h = (self.bottom_stack.get() - term_h).max(0.0); + let expected_has_composer = { + let state = self.state.read(cx); + (!state.spaces.is_empty() || state.no_project) && state.selected_chat.is_some() + }; + let bottom_stack_ready = bottom_stack_measurement_matches( + self.bottom_stack_has_composer.get(), + expected_has_composer, + ); self.transcript.update(cx, |t, cx| { t.set_rail_enabled(rail::rail_visible(main_width), cx); - t.set_bottom_clearance(stack_h, cx); + if bottom_stack_ready && expected_has_composer { + t.set_bottom_clearance(stack_h, cx); + } }); let sidebar = self.render_sidebar(cx); let sidebar_handle = self.resize_handle( "sidebar-resize", + PaneResizeKind::Sidebar, || SidebarResize, - |shell, _| shell.settings.sidebar_width = SIDEBAR_DEFAULT, + |shell, _| { + shell.settings.sidebar_width = SIDEBAR_DEFAULT; + shell.sidebar_edge_bounce = None; + }, cx, ); - let main = self.render_main(window, cx); + let main = self.render_main(window, main_content_width, transcript_width, cx); // The Changes pane is chat-scoped chrome: the Settings route // never renders it (zeron __root.tsx `!isSettings && activeChat` // around the diff column) — the per-session open flags stay // intact for the return trip. - let on_chat = matches!(self.route, Route::Chat); let right_open = on_chat && self.right_pane_open(cx); // Takeover mode derives its width from the viewport, so a // manual drag handle would fight the expanded target. let right_handle = (right_open + && !panel_handoff && !self.right_pane_expanded && !self.tween_active(self.right_tween)) .then(|| { self.resize_handle( "right-pane-resize", + PaneResizeKind::Right, || RightPaneResize, - |shell, _| shell.settings.right_pane_width = RIGHT_PANE_DEFAULT, + |shell, _| { + shell.settings.right_pane_width = RIGHT_PANE_DEFAULT; + shell.right_edge_bounce = None; + }, cx, ) // A forgiving transparent hit target centered on the @@ -9127,7 +9607,7 @@ impl Render for Shell { .h_full() .flex_none() .relative() - .child(sidebar_handle.left(px(-6.0))); + .child(sidebar_handle.left(px(-PANE_RESIZE_HITBOX_HALF_WIDTH))); // Keep the right resize target outside the pane's // overflow-hidden width container. This mirrors the sidebar // seam and lets the target straddle both adjacent panes. @@ -9152,7 +9632,7 @@ impl Render for Shell { // through the titlebar, down to the bottom edge). Its width // rides the same tween as the sidebar, so the tone melts away // with the collapse instead of vanishing in a frame. - let sidebar_now = self.eval_tween(self.sidebar_tween, self.sidebar_target()); + let sidebar_now = self.sidebar_now(); // Hairline on its right edge — full height like the tone, // so the sidebar column reads as its own surface. let sidebar_tone = div() @@ -9270,6 +9750,107 @@ impl Render for Shell { mod tests { use super::*; + #[test] + fn sidebar_drag_nudges_each_edge_once_until_rearmed() { + let min = sidebar_drag_sample(SIDEBAR_MIN, None, false); + assert_eq!(min.width, SIDEBAR_MIN); + assert_eq!(min.edge, Some(motion::ResizeEdge::Min)); + assert!(min.starts_bounce); + + let held_min = sidebar_drag_sample(SIDEBAR_MIN - 80.0, min.edge, false); + assert_eq!(held_min.width, SIDEBAR_MIN); + assert_eq!(held_min.edge, min.edge); + assert!(!held_min.starts_bounce); + + let inside = sidebar_drag_sample(SIDEBAR_MIN + 1.0, held_min.edge, false); + assert_eq!(inside.edge, None); + assert!(!inside.starts_bounce); + + let rearmed_min = sidebar_drag_sample(SIDEBAR_MIN - 1.0, inside.edge, false); + assert!(rearmed_min.starts_bounce); + + let max = sidebar_drag_sample(SIDEBAR_MAX, rearmed_min.edge, false); + assert_eq!(max.width, SIDEBAR_MAX); + assert_eq!(max.edge, Some(motion::ResizeEdge::Max)); + assert!(max.starts_bounce); + + let held_max = sidebar_drag_sample(SIDEBAR_MAX + 80.0, max.edge, false); + assert_eq!(held_max.width, SIDEBAR_MAX); + assert!(!held_max.starts_bounce); + } + + #[test] + fn sidebar_drag_stays_exact_in_range_and_reduced_motion_never_nudges() { + let middle = sidebar_drag_sample(312.0, None, false); + assert_eq!(middle.width, 312.0); + assert_eq!(middle.edge, None); + assert!(!middle.starts_bounce); + + for pointer_x in [ + SIDEBAR_MIN - 100.0, + SIDEBAR_MIN, + SIDEBAR_MAX, + SIDEBAR_MAX + 100.0, + ] { + let sample = sidebar_drag_sample(pointer_x, None, true); + assert!((SIDEBAR_MIN..=SIDEBAR_MAX).contains(&sample.width)); + assert!(!sample.starts_bounce); + } + } + + #[test] + fn right_pane_uses_the_shared_clamp_and_edge_latch() { + let min = + motion::resize_drag_sample(RIGHT_PANE_MIN - 40.0, RIGHT_PANE_MIN, 820.0, None, false); + assert_eq!(min.width, RIGHT_PANE_MIN); + assert_eq!(min.edge, Some(motion::ResizeEdge::Min)); + assert!(min.starts_bounce); + + let held = motion::resize_drag_sample( + RIGHT_PANE_MIN - 80.0, + RIGHT_PANE_MIN, + 820.0, + min.edge, + false, + ); + assert!(!held.starts_bounce); + + let max = motion::resize_drag_sample(900.0, RIGHT_PANE_MIN, 820.0, None, false); + assert_eq!(max.width, 820.0); + assert_eq!(max.edge, Some(motion::ResizeEdge::Max)); + assert!(max.starts_bounce); + } + + #[test] + fn sidebar_bounce_has_rounded_out_and_return_phases() { + assert_eq!( + motion::resize_bounce_offset(motion::ResizeEdge::Max, 0.0), + 0.0 + ); + assert_eq!( + motion::resize_bounce_offset( + motion::ResizeEdge::Max, + motion::RESIZE_EDGE_BOUNCE_OUT_FRACTION + ), + motion::RESIZE_EDGE_NUDGE + ); + assert_eq!( + motion::resize_bounce_offset(motion::ResizeEdge::Max, 1.0), + 0.0 + ); + + let gentle_start = motion::resize_bounce_offset(motion::ResizeEdge::Max, 0.01); + let outbound = motion::resize_bounce_offset(motion::ResizeEdge::Max, 0.2); + let returning = motion::resize_bounce_offset(motion::ResizeEdge::Max, 0.7); + assert!(gentle_start > 0.0 && gentle_start < 0.1); + assert!(outbound > gentle_start && outbound < motion::RESIZE_EDGE_NUDGE); + assert!(returning > 0.0 && returning < motion::RESIZE_EDGE_NUDGE); + assert_eq!( + motion::resize_bounce_offset(motion::ResizeEdge::Min, 0.2), + -outbound + ); + } + #[test] fn every_default_shortcut_binds_on_this_platform() { // `apply_keymap` silently falls back on an unparseable combo, so a @@ -9284,6 +9865,37 @@ mod tests { } } + #[test] + fn island_stays_centered_on_controls_while_expanding() { + let center = (Theme::TITLEBAR_HEIGHT + Theme::TITLEBAR_TOP_PAD) * 0.5; + for step in 0..=20 { + let (top, height) = titlebar_island_vertical_geometry(step as f32 / 20.0); + assert_eq!(top + height * 0.5, center); + assert!((28.0..=32.0).contains(&height)); + } + let (top, height) = titlebar_island_vertical_geometry(1.0); + assert_eq!(center, 21.0); + assert_eq!(center - 12.0 - top, 4.0); + assert_eq!(top + height - (center + 12.0), 4.0); + } + + #[test] + fn new_thread_handoff_is_continuous_and_staged() { + assert!(bottom_stack_measurement_matches(false, false)); + assert!(bottom_stack_measurement_matches(true, true)); + assert!(!bottom_stack_measurement_matches(false, true)); + assert!(!bottom_stack_measurement_matches(true, false)); + assert_eq!(new_thread_background_opacity(false), 1.0); + assert_eq!( + new_thread_background_opacity(true), + NEW_THREAD_BACKGROUND_FROSTED_OPACITY + ); + assert_eq!(new_thread_background_height(400.0), 184.0); + assert_eq!(new_thread_background_height(600.0), 276.0); + assert_eq!(new_thread_background_height(1_000.0), 440.0); + assert!(new_thread_background_height(848.0) < 848.0 / 2.0); + } + #[test] fn right_pane_ceiling_preserves_the_chat_floor() { assert_eq!(right_pane_max_width(1200.0, 256.0), 644.0); @@ -9414,6 +10026,8 @@ mod tests { #[test] fn pane_resize_hitboxes_yield_the_titlebar_chrome() { assert_eq!(PANE_RESIZE_HITBOX_TOP, Theme::TITLEBAR_HEIGHT); + assert_eq!(PANE_RESIZE_HITBOX_HALF_WIDTH * 2.0, 20.0); + assert_eq!(TERMINAL_RESIZE_HITBOX_HEIGHT, 10.0); } #[test] @@ -10328,6 +10942,66 @@ mod exit_regressions { .unwrap(); } + #[gpui::test] + fn panel_saves_preserve_background_effect_selected_after_shell_creation( + cx: &mut TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + crate::history::init( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + cx, + ); + settings::init(settings::UiSettings::default(), dir.path(), cx); + }); + let window = cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Shell::new( + state, + EngineBootConfig { + data_dir: dir.path().into(), + ipc_port: 0, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: None, + default_harness: zeron_proto::HarnessId::Mock, + }, + cx, + ) + }); + for effect in settings::NewThreadBackgroundEffect::ALL { + window + .update(cx, |shell, _, cx| { + // Selection changes in Appearance, independently of the shell's + // cached snapshot. Include a previously queued geometry save. + shell.settings.sidebar_width = 280.0; + shell.schedule_save(cx); + settings::set_new_thread_background_effect(effect, cx); + for step in 0..3 { + shell.settings.sidebar_width = 290.0 + step as f32; + shell.settings.right_pane_width = 540.0 + step as f32; + shell.settings.terminal_height = 300.0 + step as f32; + shell.schedule_save(cx); + assert_eq!(settings::current(cx).new_thread_background_effect, effect); + } + settings::flush(cx); + let loaded = settings::UiSettings::load(dir.path()); + assert_eq!(loaded.new_thread_background_effect, effect); + assert_eq!(loaded.sidebar_width, 292.0); + assert_eq!(loaded.right_pane_width, 542.0); + assert_eq!(loaded.terminal_height, 302.0); + }) + .unwrap(); + } + } + #[gpui::test] fn opening_terminals_focuses_the_terminal_once(cx: &mut TestAppContext) { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ui/src/shell/tabs.rs b/crates/ui/src/shell/tabs.rs index b32e27f17..0a96ee309 100644 --- a/crates/ui/src/shell/tabs.rs +++ b/crates/ui/src/shell/tabs.rs @@ -176,7 +176,7 @@ impl Shell { // The new-session `+` renders in the WINDOW-CONTROL CLUSTER whenever a // session is selected (`render_titlebar_cluster`) — this row budgets // one button slot so the title never sits under it. - let sidebar_now = self.eval_tween(self.sidebar_tween, self.sidebar_target()); + let sidebar_now = self.sidebar_now(); let plus_inset = TITLEBAR_ACTION_SLOT_WIDTH * self.titlebar_plus_alpha(cx); // Same glide as the old strip: content starts at the inset card's diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 8b3184df8..bc6f88cd6 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -801,8 +801,9 @@ impl Theme { /// rides [`Self::TITLEBAR_TOP_PAD`] lower than center so the air above /// matches the perceived gap to the inset card below (border + card body). pub const TITLEBAR_HEIGHT: f32 = 38.0; - /// Downward shift of titlebar content within the bar. - pub const TITLEBAR_TOP_PAD: f32 = 2.0; + /// Top-only padding moves the flex center by half this value. On macOS, + /// 38 / 2 + 4 / 2 = 21 matches the native traffic lights' center. + pub const TITLEBAR_TOP_PAD: f32 = 4.0; /// Reserved status strip under the content outlet (zeron `h-6`) — the /// WorkingIndicator row; reserving it keeps the composer from shifting. pub const STATUS_STRIP_HEIGHT: f32 = 24.0; diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index 87e7e2fb9..dac339333 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -63,6 +63,17 @@ pub const STICK_THRESHOLD_PX: f32 = 70.0; pub const OVERDRAW_PX: f32 = 320.0; /// Show the scroll-to-bottom button beyond this distance from the end. pub const SCROLL_BUTTON_THRESHOLD_PX: f32 = 320.0; + +fn jump_visibility(was_shown: bool, distance: f32) -> bool { + // Once offered, keep the control until close to the end. A single 320px + // threshold made it disappear halfway through a downward scroll gesture. + distance + > if was_shown { + AT_BOTTOM_PX + } else { + SCROLL_BUTTON_THRESHOLD_PX + } +} /// Bound session-local viewport memory independently of total chat history. const MAX_SAVED_VIEWPORTS: usize = 256; /// Bound locally-authored queue ids waiting to become transcript prompts. @@ -2293,6 +2304,9 @@ pub struct Transcript { rows: Vec, last_source: Option<(Option, TranscriptReplayState, u64)>, chat_id: Option, + /// The shell may retain this already-laid-out view briefly for its exit. + /// Cleared as soon as the exit is invisible; never used for another chat. + retain_on_deselect: bool, /// `Some(doc_id)` pins this instance to a SUBAGENT doc: rows come from /// `AppState::sub_transcript(doc_id)` instead of the selected chat, and /// the instance is READ-ONLY — no echoes, no own-turn hold, and no global @@ -2583,6 +2597,7 @@ impl Transcript { // Pre-set so `sync` never sees an attach edge — an override // instance must not reset (or re-pin) on selection changes. chat_id: doc_override.clone(), + retain_on_deselect: false, land_end_pending: doc_override.is_some() && !follow, doc_live: doc_override.is_some() && follow, doc_override, @@ -2904,7 +2919,7 @@ impl Transcript { if this.pinned { this.wake_spring(); } - this.show_jump_button = distance > SCROLL_BUTTON_THRESHOLD_PX + this.show_jump_button = jump_visibility(this.show_jump_button, distance) && !this.own_turn.as_ref().is_some_and(|a| a.held); cx.notify(); return; @@ -2928,7 +2943,7 @@ impl Transcript { this.wake_spring(); } } - let show = distance > SCROLL_BUTTON_THRESHOLD_PX && !this.pinned; + let show = jump_visibility(this.show_jump_button, distance) && !this.pinned; if show != this.show_jump_button { this.show_jump_button = show; } @@ -2991,7 +3006,8 @@ impl Transcript { } if was_selecting { self.last_scroll_distance = self.distance_from_bottom(); - self.show_jump_button = self.last_scroll_distance > SCROLL_BUTTON_THRESHOLD_PX; + self.show_jump_button = + jump_visibility(self.show_jump_button, self.last_scroll_distance); cx.notify(); } } @@ -3042,7 +3058,7 @@ impl Transcript { self.begin_scroll_navigation(); self.list.scroll_by(px(step)); self.last_scroll_distance = self.distance_from_bottom(); - self.show_jump_button = self.last_scroll_distance > SCROLL_BUTTON_THRESHOLD_PX; + self.show_jump_button = jump_visibility(self.show_jump_button, self.last_scroll_distance); cx.notify(); self.schedule_selection_scroll(cx); } @@ -3208,7 +3224,7 @@ impl Transcript { self.own_turn_last_tick = None; self.remeasure_last_row(); self.last_scroll_distance = self.distance_from_bottom(); - self.show_jump_button = self.last_scroll_distance > SCROLL_BUTTON_THRESHOLD_PX; + self.show_jump_button = jump_visibility(self.show_jump_button, self.last_scroll_distance); self.viewport_finalize_pending = true; } @@ -3259,6 +3275,9 @@ impl Transcript { /// Advance the prompt glide or hand a filled reservation to tail-follow. /// Reservation sizing happens in the list layout, never in this callback. fn step_own_turn(&mut self, cx: &mut Context) { + if self.route_exit_pending(cx) { + return; + } self.own_turn_kick = false; // Layout moves the bottom too (pad refinement, streaming growth): // refresh the wheel handler's escape baseline every frame so only a @@ -3556,6 +3575,9 @@ impl Transcript { /// delta, and park on landing. Runs from `window.on_next_frame`, /// i.e. after layout — measurements are fresh. fn step_spring(&mut self, cx: &mut Context) { + if self.route_exit_pending(cx) { + return; + } self.spring_kick = false; if !self.pinned { self.spring_last_tick = None; @@ -3610,8 +3632,34 @@ impl Transcript { } } + pub(crate) fn retain_for_route_exit(&mut self) { + self.retain_on_deselect = true; + } + + fn route_exit_pending(&self, cx: &gpui::App) -> bool { + self.retain_on_deselect + && self.doc_override.is_none() + && self.state.read(cx).selected_chat.is_none() + && self.chat_id.is_some() + } + + pub(crate) fn finish_route_exit(&mut self, cx: &mut Context) { + if self.state.read(cx).selected_chat.is_none() && self.chat_id.is_some() { + self.retain_on_deselect = false; + self.sync(cx); + self.retain_on_deselect = true; + } + } + /// Rebuild rows from app state; splice minimal ranges into the list. fn sync(&mut self, cx: &mut Context) { + if self.retain_on_deselect + && self.doc_override.is_none() + && self.state.read(cx).selected_chat.is_none() + && self.chat_id.is_some() + { + return; + } let (selected, replay) = { let s = self.state.read(cx); match &self.doc_override { @@ -4140,7 +4188,8 @@ impl Transcript { if raw >= 1.0 { self.user_collapse_scroll = None; self.last_scroll_distance = self.distance_from_bottom(); - self.show_jump_button = self.last_scroll_distance > SCROLL_BUTTON_THRESHOLD_PX; + self.show_jump_button = + jump_visibility(self.show_jump_button, self.last_scroll_distance); } cx.notify(); } @@ -6911,7 +6960,10 @@ impl Render for Transcript { // frame while an anchor is live (not just on kicks) so viewport // resizes and streaming growth re-derive the reservation; the step // only notifies on change, so a settled hold schedules no next frame. - if (self.own_turn.is_some() || self.own_turn_kick) && !self.own_turn_scheduled { + if !self.route_exit_pending(cx) + && (self.own_turn.is_some() || self.own_turn_kick) + && !self.own_turn_scheduled + { self.own_turn_scheduled = true; let entity = cx.weak_entity(); window.on_next_frame(move |_, cx| { @@ -6926,7 +6978,8 @@ impl Render for Transcript { // Spring driver: one on_next_frame callback at a time; each tick // notifies, which re-enters render and schedules the next frame until // the spring parks. Reduced motion never schedules (sync snaps). - if self.pinned + if !self.route_exit_pending(cx) + && self.pinned && !motion::reduced_motion(cx) && !self.spring_scheduled && self.spring_should_run() @@ -6964,7 +7017,7 @@ impl Render for Transcript { } let distance = this.distance_from_bottom(); this.last_scroll_distance = distance; - this.show_jump_button = distance > SCROLL_BUTTON_THRESHOLD_PX + this.show_jump_button = jump_visibility(this.show_jump_button, distance) && !this.pinned && !this.own_turn.as_ref().is_some_and(|turn| turn.held); if token.layout_settled(this.viewport_layout_revision) { @@ -7067,6 +7120,44 @@ impl Render for Transcript { #[cfg(test)] mod tests { use super::*; + + #[test] + fn jump_button_stays_available_when_scrolling_down_until_near_bottom() { + let mut shown = false; + for distance in [500.0, 330.0, 319.0, 200.0, 100.0] { + shown = jump_visibility(shown, distance); + assert!(shown, "button vanished with {distance}px remaining"); + } + assert!(!jump_visibility(shown, AT_BOTTOM_PX)); + assert!(!jump_visibility(false, 319.0)); + assert!(jump_visibility(false, 321.0)); + } + + #[gpui::test] + fn departing_transcript_is_retained_only_until_hidden(cx: &mut gpui::TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + crate::settings::init(crate::settings::UiSettings::default(), dir.path(), cx); + let state = cx.new(|_| AppState::new()); + let transcript = cx.new(|cx| Transcript::new(state.clone(), cx)); + transcript.update(cx, |transcript, cx| { + transcript.retain_for_route_exit(); + transcript.chat_id = Some("departing".into()); + transcript.last_source = None; + transcript.rows = vec![viewport_row("row", "message")]; + transcript.list.reset(1); + transcript.sync(cx); + assert_eq!(transcript.rows.len(), 1); + assert!(transcript.route_exit_pending(cx)); + transcript.finish_route_exit(cx); + assert!(transcript.rows.is_empty()); + assert!(transcript.chat_id.is_none()); + assert!(!transcript.route_exit_pending(cx)); + }); + }); + } use zeron_doc::MessagePart; #[test]