From cea64ba20136d286c00444f9c18fe4a756d896d3 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Fri, 21 Aug 2026 21:18:56 +0200 Subject: [PATCH 01/40] feat(ui): center new thread composer --- crates/ui/src/motion.rs | 13 ++++++++++++ crates/ui/src/shell.rs | 45 ++++++++++++++++++++++++++++++++--------- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/crates/ui/src/motion.rs b/crates/ui/src/motion.rs index 768347f1e..fb2c5b9cf 100644 --- a/crates/ui/src/motion.rs +++ b/crates/ui/src/motion.rs @@ -391,6 +391,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 diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 802842961..4a06a6b51 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -6753,10 +6753,9 @@ impl Shell { let has_appshots = !self.composer.read(cx).staged_appshots().is_empty(); let no_project = self.state.read(cx).no_project; - // 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). + // 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 outlet: AnyElement = if has_selection { self.transcript .clone() @@ -6810,11 +6809,37 @@ 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() + // New-thread canvas: the mark, target selectors, composer, and + // checkout row form one vertically-centered composition. The + // composer lives here only while the canvas is blank; established + // sessions keep it in the bottom chrome stack below. + let pickers = self.composer.read(cx).pickers().clone(); + let selectors = pickers.update(cx, |p, cx| p.render_target_selectors(cx)); + div() + .size_full() + .flex() + .flex_col() + .items_center() + .justify_center() + .child(motion::settle_down( + "new-thread-composition", + div() + .w_full() + .flex() + .flex_col() + .items_center() + .child( + icon(icons::ZERON_LOGO) + .w(px(41.9)) + .h(px(48.0)) + // 0.09 read as barely-there on the glass + // backdrop (user report). + .text_color(theme.text.opacity(0.2)), + ) + .child(div().mt(px(16.0)).child(selectors)) + .child(div().w_full().mt(px(24.0)).child(self.composer.clone())), + )) + .into_any_element() }; let status = self.render_status_strip(cx); @@ -6957,7 +6982,7 @@ impl Shell { .inset_0(), ) .child(status) - .when(has_spaces || has_appshots, |el| { + .when((has_spaces || has_appshots) && has_selection, |el| { el.child(self.composer.clone()) }) .child(self.render_terminal_container(cx)) From b4ccbf7a90b2cfba81b3618878c4b56c5668d1bf Mon Sep 17 00:00:00 2001 From: gaelcado Date: Fri, 21 Aug 2026 21:34:33 +0200 Subject: [PATCH 02/40] refine(ui): smooth new thread launch handoff --- crates/ui/src/composer.rs | 94 +++++++++++++++---- crates/ui/src/motion.rs | 7 ++ crates/ui/src/shell.rs | 184 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 262 insertions(+), 23 deletions(-) diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 4b27db34d..787280326 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -298,9 +298,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 500ms launch 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 +318,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 launch 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_launch(from: f32, start_ms: f32) -> Self { + Self { + from, + start_ms, + spec: motion::NEW_THREAD_LAUNCH, + } + } + + /// 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 +464,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 @@ -3812,7 +3829,12 @@ pub enum ComposerEvent { /// 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. - Sent { chat_id: String, message_id: String }, + Sent { + chat_id: String, + message_id: String, + /// True only for the first prompt sent from the blank-thread canvas. + from_new_thread: bool, + }, /// A locally-authored queue row was accepted. It is not a transcript send /// yet: the transcript remembers the stable id and promotes it to an /// own-turn anchor only when the host materializes the matching bubble. @@ -3999,6 +4021,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 @@ -4205,6 +4231,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(), @@ -5745,6 +5772,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(); + 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 +5799,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 && !motion::reduced_motion(cx) && self.last_rendered_height > 0.0 { + // The blank canvas is visibly expanded even when the stored + // mode is compact. Commit that compact target now and morph + // from the actually-rendered expanded height on the same + // 500ms timeline as the shell's positional handoff. + self.expanded_mode = false; + let now_ms = self.morph_clock.elapsed().as_secs_f32() * 1000.0 + / motion::speed_scale(); + self.flip_morph = Some(FlipMorph::new_thread_launch( + 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 +6196,7 @@ impl Composer { status: None, continuation_of: None, }; + self.launching_new_chat = is_new; // 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| { @@ -6176,6 +6224,7 @@ impl Composer { cx.emit(ComposerEvent::Sent { chat_id: chat_id.clone(), message_id: message_id.clone(), + from_new_thread: is_new, }); } cx.notify(); @@ -8859,6 +8908,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 +8928,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 +8939,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 +8968,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 +9035,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 +9047,16 @@ mod tests { assert!(mid > 0.0 && mid < 1.0); } + #[test] + fn new_thread_launch_uses_the_coordinated_timeline() { + let m = FlipMorph::new_thread_launch(124.0, 0.0); + assert_eq!(m.spec, motion::NEW_THREAD_LAUNCH); + 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, 500.0), 49.0); + } + #[test] fn staged_comments_alone_are_content() { assert!(!composer_has_content(" ", 0, 0)); diff --git a/crates/ui/src/motion.rs b/crates/ui/src/motion.rs index fb2c5b9cf..08b3c79bd 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); +/// First-send handoff: the centered new-thread composer travels to its +/// in-session anchor while the transcript takes over. A full 500ms +/// ease-in-out matches the transcript's own-send scroll glide, so the two +/// motions read as one transition instead of competing snaps. +pub const NEW_THREAD_LAUNCH: MotionSpec = MotionSpec::new(500, EASE_IN_OUT); /// 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); @@ -857,6 +862,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_LAUNCH.duration_ms, 500); + assert_eq!(NEW_THREAD_LAUNCH.curve, EASE_IN_OUT); 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/shell.rs b/crates/ui/src/shell.rs index 4a06a6b51..849c7ffd9 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -791,6 +791,33 @@ impl WidthTween { } } +/// One coordinated blank-thread → session handoff. The source composer bounds +/// come from the last painted blank canvas; the destination is the ordinary +/// bottom composer anchor. Position, composer height, outgoing header, and +/// transcript reveal all share [`motion::NEW_THREAD_LAUNCH`]. +#[derive(Debug, Clone, Copy)] +struct NewThreadLaunch { + source_bottom: f32, + source_height: f32, + started: std::time::Instant, +} + +fn new_thread_transcript_opacity(progress: f32) -> f32 { + ((progress - 0.12) / 0.88).clamp(0.0, 1.0) +} + +fn new_thread_header_opacity(progress: f32) -> f32 { + (1.0 - progress / 0.55).clamp(0.0, 1.0) +} + +fn new_thread_composer_offset( + source_bottom: f32, + destination_bottom: f32, + progress: f32, +) -> f32 { + (source_bottom - destination_bottom) * (1.0 - progress.clamp(0.0, 1.0)) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SplashPhase { Visible, @@ -1176,6 +1203,11 @@ pub struct Shell { /// the transcript's bottom clearance, and the jump pill's anchor (the /// same one-frame lag every fade here rides). bottom_stack: std::rc::Rc>, + /// Last painted bounds of the centered new-thread composer. The first-send + /// transition uses its bottom edge as the FLIP source anchor. + new_thread_composer_bottom: std::rc::Rc>, + new_thread_composer_height: std::rc::Rc>, + new_thread_launch: Option, /// 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). @@ -1358,6 +1390,7 @@ 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. @@ -1436,14 +1469,18 @@ impl Shell { // reply's space below it (notes-app parity). let composer_events = cx.subscribe(&composer, { let transcript = transcript.clone(); - move |_this: &mut Shell, _, event: &ComposerEvent, cx| match event { + move |this: &mut Shell, _, event: &ComposerEvent, cx| match event { ComposerEvent::Sent { chat_id, message_id, + from_new_thread, } => { transcript.update(cx, |t, cx| { t.on_own_send(chat_id.clone(), message_id.clone(), cx) }); + if *from_new_thread { + this.begin_new_thread_launch(cx); + } } ComposerEvent::Queued { chat_id, @@ -1562,6 +1599,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)), + new_thread_composer_bottom: std::rc::Rc::new(std::cell::Cell::new(0.0)), + new_thread_composer_height: std::rc::Rc::new(std::cell::Cell::new(0.0)), + new_thread_launch: None, archived_open: true, archived_shown: 0, archived_hover: None, @@ -1656,6 +1696,7 @@ impl Shell { main_takeover_tween: None, right_pane_expanded: false, viewport_width: 1280.0, + viewport_height: 880.0, terminal_tween: None, fullscreen: None, titlebar_tween: None, @@ -4309,6 +4350,41 @@ impl Shell { // ---- render pieces ---- + fn begin_new_thread_launch(&mut self, cx: &mut Context) { + let source_bottom = self.new_thread_composer_bottom.get(); + let source_height = self.new_thread_composer_height.get(); + if motion::reduced_motion(cx) || source_bottom <= 0.0 || source_height <= 0.0 { + self.new_thread_launch = None; + return; + } + self.new_thread_launch = Some(NewThreadLaunch { + source_bottom, + source_height, + started: std::time::Instant::now(), + }); + cx.notify(); + } + + /// Current coordinated first-send frame. Manual evaluation avoids a + /// remount replay when the composer moves between its two parents. + fn new_thread_launch_frame(&mut self) -> Option<(NewThreadLaunch, f32)> { + let launch = self.new_thread_launch?; + if self.reduced_motion { + self.new_thread_launch = None; + return None; + } + let total = motion::NEW_THREAD_LAUNCH + .total() + .mul_f32(motion::speed_scale()); + let raw = launch.started.elapsed().as_secs_f32() / total.as_secs_f32(); + if raw >= 1.0 { + self.new_thread_launch = None; + return None; + } + self.motion_active.set(true); + Some((launch, motion::NEW_THREAD_LAUNCH.progress(raw))) + } + fn tween_elapsed(&self, started: std::time::Instant) -> Duration { self.render_time .unwrap_or_else(std::time::Instant::now) @@ -6752,15 +6828,68 @@ 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 launch_frame = self.new_thread_launch_frame(); + let term_h = self.eval_tween(self.terminal_tween, self.terminal_target(cx)); + let launch_composer_offset = launch_frame.map(|(launch, progress)| { + let destination_bottom = self.viewport_height - term_h; + new_thread_composer_offset(launch.source_bottom, destination_bottom, progress) + }); // 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 outlet: AnyElement = if has_selection { - self.transcript - .clone() - .cached(gpui::StyleRefinement::default().size_full()) - .into_any_element() + if let Some((launch, progress)) = launch_frame { + let pickers = self.composer.read(cx).pickers().clone(); + let selectors = pickers.update(cx, |p, cx| p.render_target_selectors(cx)); + div() + .relative() + .size_full() + .child( + div() + .size_full() + .opacity(new_thread_transcript_opacity(progress)) + .child(self.transcript.clone()), + ) + // Let the source header dissolve while the composer leaves + // it behind. The composer itself is rendered only once — + // in the destination stack — and FLIP-offset to its old + // bottom edge below. + .child( + div() + .absolute() + .inset_0() + .flex() + .flex_col() + .items_center() + .justify_center() + .opacity(new_thread_header_opacity(progress)) + .top(px(-8.0 * progress)) + .child( + div() + .w_full() + .flex() + .flex_col() + .items_center() + .child( + icon(icons::ZERON_LOGO) + .w(px(41.9)) + .h(px(48.0)) + .text_color(theme.text.opacity(0.2)), + ) + .child(div().mt(px(16.0)).child(selectors)) + .child( + div() + .w_full() + .mt(px(24.0)) + .h(px(launch.source_height)), + ), + ), + ) + .into_any_element() + } else { + self.transcript.clone().cached(gpui::StyleRefinement::default().size_full()).into_any_element() + } } else if !has_spaces && !no_project { // Onboarding (first boot / after the destructive wipe): no folders // to work in yet — one clear affordance. @@ -6837,7 +6966,26 @@ impl Shell { .text_color(theme.text.opacity(0.2)), ) .child(div().mt(px(16.0)).child(selectors)) - .child(div().w_full().mt(px(24.0)).child(self.composer.clone())), + .child({ + let bottom = self.new_thread_composer_bottom.clone(); + let height = self.new_thread_composer_height.clone(); + div() + .w_full() + .mt(px(24.0)) + .relative() + .child( + gpui::canvas( + move |bounds, _, _| { + bottom.set(f32::from(bounds.bottom())); + height.set(f32::from(bounds.size.height)); + }, + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ) + .child(self.composer.clone()) + }), )) .into_any_element() }; @@ -6932,7 +7080,6 @@ 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 @@ -6983,7 +7130,12 @@ impl Shell { ) .child(status) .when((has_spaces || has_appshots) && has_selection, |el| { - el.child(self.composer.clone()) + el.child( + div() + .relative() + .top(px(launch_composer_offset.unwrap_or(0.0))) + .child(self.composer.clone()), + ) }) .child(self.render_terminal_container(cx)) }) @@ -9056,6 +9208,7 @@ 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; @@ -9309,6 +9462,21 @@ mod tests { } } + #[test] + fn new_thread_handoff_is_continuous_and_staged() { + // The bottom-anchored destination starts exactly at the centered + // source's bottom edge, then lands without overshoot. + assert_eq!(new_thread_composer_offset(520.0, 840.0, 0.0), -320.0); + assert_eq!(new_thread_composer_offset(520.0, 840.0, 0.5), -160.0); + assert_eq!(new_thread_composer_offset(520.0, 840.0, 1.0), 0.0); + // The source header leaves first; the transcript arrives just after + // motion begins and is fully opaque at rest. + assert_eq!(new_thread_header_opacity(0.0), 1.0); + assert_eq!(new_thread_header_opacity(1.0), 0.0); + assert_eq!(new_thread_transcript_opacity(0.0), 0.0); + assert_eq!(new_thread_transcript_opacity(1.0), 1.0); + } + #[test] fn right_pane_ceiling_preserves_the_chat_floor() { assert_eq!(right_pane_max_width(1200.0, 256.0), 644.0); From 39780dded8ff92cad983366678d30250cbc43ed5 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Fri, 21 Aug 2026 21:43:35 +0200 Subject: [PATCH 03/40] refine(ui): quicken new thread handoff --- crates/ui/src/composer.rs | 6 +++--- crates/ui/src/motion.rs | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 787280326..7b55de4b5 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -299,7 +299,7 @@ pub fn comment_strip_height(count: usize) -> f32 { /// 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 blank- -/// thread handoff swaps in the coordinated 500ms launch spec. Both use the +/// thread handoff swaps in the coordinated 360ms launch 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. /// @@ -5805,7 +5805,7 @@ impl Composer { // The blank canvas is visibly expanded even when the stored // mode is compact. Commit that compact target now and morph // from the actually-rendered expanded height on the same - // 500ms timeline as the shell's positional handoff. + // 360ms timeline as the shell's positional handoff. self.expanded_mode = false; let now_ms = self.morph_clock.elapsed().as_secs_f32() * 1000.0 / motion::speed_scale(); @@ -9054,7 +9054,7 @@ mod tests { 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, 500.0), 49.0); + assert_eq!(m.height(49.0, 360.0), 49.0); } #[test] diff --git a/crates/ui/src/motion.rs b/crates/ui/src/motion.rs index 08b3c79bd..e138e6310 100644 --- a/crates/ui/src/motion.rs +++ b/crates/ui/src/motion.rs @@ -360,10 +360,10 @@ 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); /// First-send handoff: the centered new-thread composer travels to its -/// in-session anchor while the transcript takes over. A full 500ms -/// ease-in-out matches the transcript's own-send scroll glide, so the two -/// motions read as one transition instead of competing snaps. -pub const NEW_THREAD_LAUNCH: MotionSpec = MotionSpec::new(500, EASE_IN_OUT); +/// in-session anchor while the transcript takes over. A brisk 360ms +/// ease-in-out keeps the pieces reading as one transition while making the +/// sent message feel immediately acknowledged. +pub const NEW_THREAD_LAUNCH: MotionSpec = MotionSpec::new(360, EASE_IN_OUT); /// 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); @@ -862,7 +862,7 @@ 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_LAUNCH.duration_ms, 500); + assert_eq!(NEW_THREAD_LAUNCH.duration_ms, 360); assert_eq!(NEW_THREAD_LAUNCH.curve, EASE_IN_OUT); assert_eq!(CHEVRON.duration_ms, 200); assert_eq!(ZERON_PULSE.duration_ms, 2400); From 39b14615bf25bac7521c5b32d0ce5a584e1ddfee Mon Sep 17 00:00:00 2001 From: gaelcado Date: Fri, 21 Aug 2026 21:54:21 +0200 Subject: [PATCH 04/40] refine(ui): enlarge new thread comet hero --- crates/ui/assets/icons/zeron-logo-faded.svg | 9 ++++++ crates/ui/src/icons.rs | 3 ++ crates/ui/src/shell.rs | 36 ++++++++++++++------- 3 files changed, 36 insertions(+), 12 deletions(-) create mode 100644 crates/ui/assets/icons/zeron-logo-faded.svg diff --git a/crates/ui/assets/icons/zeron-logo-faded.svg b/crates/ui/assets/icons/zeron-logo-faded.svg new file mode 100644 index 000000000..a34711392 --- /dev/null +++ b/crates/ui/assets/icons/zeron-logo-faded.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/ui/src/icons.rs b/crates/ui/src/icons.rs index ea0144b36..6b55d0014 100644 --- a/crates/ui/src/icons.rs +++ b/crates/ui/src/icons.rs @@ -188,6 +188,9 @@ icon_assets![ (STAR, "star"), (STAR_BOLD, "star-bold"), (ZERON_LOGO, "zeron-logo"), + // Hero-scale logo with row-level opacity baked into the vector so its + // lower pixels dissolve over glass without painting a fake background. + (ZERON_LOGO_FADED, "zeron-logo-faded"), // Harness brand marks (icons.tsx). (CLAUDE_MARK, "claude-mark"), (OPENAI_MARK, "openai-mark"), diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 849c7ffd9..23c9d4229 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -687,6 +687,12 @@ 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 hero geometry. The comet is the full-bleed visual layer; the +/// selectors and composer are the control layer floating over its faded tail. +const NEW_THREAD_COMET_WIDTH: f32 = 209.5; +const NEW_THREAD_COMET_HEIGHT: f32 = 240.0; +const NEW_THREAD_CONTROLS_PULL_UP: f32 = 88.0; + /// Drag marker for the sidebar resize handle. struct SidebarResize; /// Drag marker for the right-pane resize handle. @@ -6872,12 +6878,16 @@ impl Shell { .flex_col() .items_center() .child( - icon(icons::ZERON_LOGO) - .w(px(41.9)) - .h(px(48.0)) - .text_color(theme.text.opacity(0.2)), + icon(icons::ZERON_LOGO_FADED) + .w(px(NEW_THREAD_COMET_WIDTH)) + .h(px(NEW_THREAD_COMET_HEIGHT)) + .text_color(theme.text.opacity(0.18)), + ) + .child( + div() + .mt(px(-NEW_THREAD_CONTROLS_PULL_UP)) + .child(selectors), ) - .child(div().mt(px(16.0)).child(selectors)) .child( div() .w_full() @@ -6958,14 +6968,16 @@ impl Shell { .flex_col() .items_center() .child( - icon(icons::ZERON_LOGO) - .w(px(41.9)) - .h(px(48.0)) - // 0.09 read as barely-there on the glass - // backdrop (user report). - .text_color(theme.text.opacity(0.2)), + icon(icons::ZERON_LOGO_FADED) + .w(px(NEW_THREAD_COMET_WIDTH)) + .h(px(NEW_THREAD_COMET_HEIGHT)) + .text_color(theme.text.opacity(0.18)), + ) + .child( + div() + .mt(px(-NEW_THREAD_CONTROLS_PULL_UP)) + .child(selectors), ) - .child(div().mt(px(16.0)).child(selectors)) .child({ let bottom = self.new_thread_composer_bottom.clone(); let height = self.new_thread_composer_height.clone(); From f696ac7f835dd5316eaac4515c41a7a4b71b61b0 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Fri, 21 Aug 2026 22:01:34 +0200 Subject: [PATCH 05/40] refine(ui): separate new thread hero layers --- crates/ui/src/composer.rs | 2 +- crates/ui/src/pickers.rs | 13 ++++--- crates/ui/src/shell.rs | 76 +++++++++++++++++++++++++-------------- 3 files changed, 57 insertions(+), 34 deletions(-) diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 7b55de4b5..b0f035c08 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -69,7 +69,7 @@ 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 diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index 58d6d1547..85dcf1299 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -2408,10 +2408,10 @@ 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). + /// The new-session canvas's target row — device + project selector chips + /// form one context rail at the composer's leading edge (their popovers + /// anchor BELOW; the composer footer carries only checkout + ref now, and + /// sessions show their target in the titlebar instead). pub fn render_target_selectors(&mut self, cx: &mut Context) -> AnyElement { let theme = Theme::of(cx).clone(); let closing = self.open.closing_since(); @@ -2472,9 +2472,8 @@ impl Pickers { .flex() .flex_row() .items_center() - .gap(px(4.0)) - .px(px(10.0)) - .child(attach_overlay( + .gap(px(2.0)) + .child(attach_overlay_below( device_chip, &mut overlay, PickerKind::Device, diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 23c9d4229..092eda271 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -28,7 +28,9 @@ use zeron_proto::{AuthState, WorkspaceScope}; use zeron_rpc::methods; use crate::changes::{Changes, ChangesEvent}; -use crate::composer::{Composer, ComposerEvent, ComposerInput, ComposerInputEvent}; +use crate::composer::{ + COMPOSER_MAX_WIDTH, Composer, ComposerEvent, ComposerInput, ComposerInputEvent, +}; use crate::files::{FilesCloseDisposition, FilesEvent, FilesSurface, WorkspacePathDrag}; use crate::icons::{self, icon}; use crate::loaders; @@ -691,7 +693,10 @@ const SIDEBAR_GLASS_FADE_BAND: f32 = 24.0; /// selectors and composer are the control layer floating over its faded tail. const NEW_THREAD_COMET_WIDTH: f32 = 209.5; const NEW_THREAD_COMET_HEIGHT: f32 = 240.0; -const NEW_THREAD_CONTROLS_PULL_UP: f32 = 88.0; +const NEW_THREAD_COMET_X_CORRECTION: f32 = -8.0; +const NEW_THREAD_HERO_HEIGHT: f32 = 240.0; +const NEW_THREAD_SELECTOR_INSET: f32 = 24.0; +const NEW_THREAD_SELECTOR_BOTTOM: f32 = 14.0; /// Drag marker for the sidebar resize handle. struct SidebarResize; @@ -824,6 +829,47 @@ fn new_thread_composer_offset( (source_bottom - destination_bottom) * (1.0 - progress.clamp(0.0, 1.0)) } +/// The new-thread visual and context controls occupy one bounded hero region. +/// Only the comet layer clips at the composer's top edge; the selector rail +/// remains free to open its deferred popovers over the composer. +fn new_thread_hero(selectors: AnyElement, theme: &Theme) -> AnyElement { + div() + .w_full() + .max_w(px(COMPOSER_MAX_WIDTH)) + .h(px(NEW_THREAD_HERO_HEIGHT)) + .relative() + .child( + div() + .absolute() + .inset_0() + .overflow_hidden() + .flex() + .items_end() + .justify_center() + .child( + icon(icons::ZERON_LOGO_FADED) + .w(px(NEW_THREAD_COMET_WIDTH)) + .h(px(NEW_THREAD_COMET_HEIGHT)) + // The asymmetric comet reads optically right-heavy. + .ml(px(NEW_THREAD_COMET_X_CORRECTION)) + .text_color(theme.text.opacity(0.18)), + ), + ) + .child( + div() + .absolute() + .left(px(NEW_THREAD_SELECTOR_INSET)) + .bottom(px(NEW_THREAD_SELECTOR_BOTTOM)) + .p(px(2.0)) + .rounded(px(8.0)) + .border_1() + .border_color(theme.border.opacity(0.6)) + .bg(theme.surface_raised.opacity(0.55)) + .child(selectors), + ) + .into_any_element() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SplashPhase { Visible, @@ -6877,21 +6923,10 @@ impl Shell { .flex() .flex_col() .items_center() - .child( - icon(icons::ZERON_LOGO_FADED) - .w(px(NEW_THREAD_COMET_WIDTH)) - .h(px(NEW_THREAD_COMET_HEIGHT)) - .text_color(theme.text.opacity(0.18)), - ) - .child( - div() - .mt(px(-NEW_THREAD_CONTROLS_PULL_UP)) - .child(selectors), - ) + .child(new_thread_hero(selectors, theme)) .child( div() .w_full() - .mt(px(24.0)) .h(px(launch.source_height)), ), ), @@ -6967,23 +7002,12 @@ impl Shell { .flex() .flex_col() .items_center() - .child( - icon(icons::ZERON_LOGO_FADED) - .w(px(NEW_THREAD_COMET_WIDTH)) - .h(px(NEW_THREAD_COMET_HEIGHT)) - .text_color(theme.text.opacity(0.18)), - ) - .child( - div() - .mt(px(-NEW_THREAD_CONTROLS_PULL_UP)) - .child(selectors), - ) + .child(new_thread_hero(selectors, theme)) .child({ let bottom = self.new_thread_composer_bottom.clone(); let height = self.new_thread_composer_height.clone(); div() .w_full() - .mt(px(24.0)) .relative() .child( gpui::canvas( From b6d75726c4d6d2ba616bee27e77399411556c01f Mon Sep 17 00:00:00 2001 From: gaelcado Date: Fri, 21 Aug 2026 22:07:28 +0200 Subject: [PATCH 06/40] refine(ui): frame new thread composer context --- crates/ui/src/pickers.rs | 9 ++++----- crates/ui/src/shell.rs | 24 ++++++++++++++++-------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index 85dcf1299..740a865e7 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -2408,10 +2408,9 @@ impl Pickers { .child(div().min_w_0().truncate().child(label)) } - /// The new-session canvas's target row — device + project selector chips - /// form one context rail at the composer's leading edge (their popovers - /// anchor BELOW; the composer footer carries only checkout + ref now, and - /// sessions show their target in the titlebar instead). + /// The new-session canvas's target row — device leads and project trails, + /// mirroring the checkout/ref frame below the composer. Their popovers + /// anchor BELOW; sessions show their target in the titlebar instead. pub fn render_target_selectors(&mut self, cx: &mut Context) -> AnyElement { let theme = Theme::of(cx).clone(); let closing = self.open.closing_since(); @@ -2472,7 +2471,7 @@ impl Pickers { .flex() .flex_row() .items_center() - .gap(px(2.0)) + .justify_between() .child(attach_overlay_below( device_chip, &mut overlay, diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 092eda271..a5956867a 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -691,10 +691,11 @@ const SIDEBAR_GLASS_FADE_BAND: f32 = 24.0; /// New-thread hero geometry. The comet is the full-bleed visual layer; the /// selectors and composer are the control layer floating over its faded tail. -const NEW_THREAD_COMET_WIDTH: f32 = 209.5; -const NEW_THREAD_COMET_HEIGHT: f32 = 240.0; +const NEW_THREAD_COMET_WIDTH: f32 = 244.3; +const NEW_THREAD_COMET_HEIGHT: f32 = 280.0; const NEW_THREAD_COMET_X_CORRECTION: f32 = -8.0; -const NEW_THREAD_HERO_HEIGHT: f32 = 240.0; +const NEW_THREAD_COMET_CLIP_OVERSHOOT: f32 = 8.0; +const NEW_THREAD_HERO_HEIGHT: f32 = 272.0; const NEW_THREAD_SELECTOR_INSET: f32 = 24.0; const NEW_THREAD_SELECTOR_BOTTOM: f32 = 14.0; @@ -852,6 +853,11 @@ fn new_thread_hero(selectors: AnyElement, theme: &Theme) -> AnyElement { .h(px(NEW_THREAD_COMET_HEIGHT)) // The asymmetric comet reads optically right-heavy. .ml(px(NEW_THREAD_COMET_X_CORRECTION)) + // The layer ends at the composer; only the artwork + // overshoots and is clipped, keeping its last visible + // row flush with that boundary instead of floating. + .relative() + .top(px(NEW_THREAD_COMET_CLIP_OVERSHOOT)) .text_color(theme.text.opacity(0.18)), ), ) @@ -859,12 +865,8 @@ fn new_thread_hero(selectors: AnyElement, theme: &Theme) -> AnyElement { div() .absolute() .left(px(NEW_THREAD_SELECTOR_INSET)) + .right(px(NEW_THREAD_SELECTOR_INSET)) .bottom(px(NEW_THREAD_SELECTOR_BOTTOM)) - .p(px(2.0)) - .rounded(px(8.0)) - .border_1() - .border_color(theme.border.opacity(0.6)) - .bg(theme.surface_raised.opacity(0.55)) .child(selectors), ) .into_any_element() @@ -9500,6 +9502,12 @@ mod tests { #[test] fn new_thread_handoff_is_continuous_and_staged() { + // The hero clips at the composer boundary while the larger artwork + // extends just beyond it, so the fade visually reaches that edge. + assert_eq!( + NEW_THREAD_COMET_HEIGHT - NEW_THREAD_HERO_HEIGHT, + NEW_THREAD_COMET_CLIP_OVERSHOOT + ); // The bottom-anchored destination starts exactly at the centered // source's bottom edge, then lands without overshoot. assert_eq!(new_thread_composer_offset(520.0, 840.0, 0.0), -320.0); From 887ab69e84d75abacce4b55d5cab341352b5d7a0 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Fri, 21 Aug 2026 22:22:19 +0200 Subject: [PATCH 07/40] refine(ui): rebalance new thread comet scale --- crates/ui/src/shell.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index a5956867a..93a485d24 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -691,11 +691,11 @@ const SIDEBAR_GLASS_FADE_BAND: f32 = 24.0; /// New-thread hero geometry. The comet is the full-bleed visual layer; the /// selectors and composer are the control layer floating over its faded tail. -const NEW_THREAD_COMET_WIDTH: f32 = 244.3; -const NEW_THREAD_COMET_HEIGHT: f32 = 280.0; +const NEW_THREAD_COMET_WIDTH: f32 = 226.8; +const NEW_THREAD_COMET_HEIGHT: f32 = 260.0; const NEW_THREAD_COMET_X_CORRECTION: f32 = -8.0; -const NEW_THREAD_COMET_CLIP_OVERSHOOT: f32 = 8.0; -const NEW_THREAD_HERO_HEIGHT: f32 = 272.0; +const NEW_THREAD_COMET_CLIP_OVERSHOOT: f32 = 16.0; +const NEW_THREAD_HERO_HEIGHT: f32 = 244.0; const NEW_THREAD_SELECTOR_INSET: f32 = 24.0; const NEW_THREAD_SELECTOR_BOTTOM: f32 = 14.0; From ad9bbe1eba02c387865a0456794f6c80b2cab405 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Fri, 21 Aug 2026 22:56:19 +0200 Subject: [PATCH 08/40] refine(ui): soften new thread comet --- crates/ui/assets/icons/zeron-logo-faded.svg | 12 ++++++------ crates/ui/src/shell.rs | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/ui/assets/icons/zeron-logo-faded.svg b/crates/ui/assets/icons/zeron-logo-faded.svg index a34711392..0dc380474 100644 --- a/crates/ui/assets/icons/zeron-logo-faded.svg +++ b/crates/ui/assets/icons/zeron-logo-faded.svg @@ -1,9 +1,9 @@ - - - - - - + + + + + + diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 93a485d24..98160d8e5 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -691,11 +691,11 @@ const SIDEBAR_GLASS_FADE_BAND: f32 = 24.0; /// New-thread hero geometry. The comet is the full-bleed visual layer; the /// selectors and composer are the control layer floating over its faded tail. -const NEW_THREAD_COMET_WIDTH: f32 = 226.8; -const NEW_THREAD_COMET_HEIGHT: f32 = 260.0; +const NEW_THREAD_COMET_WIDTH: f32 = 209.5; +const NEW_THREAD_COMET_HEIGHT: f32 = 240.0; const NEW_THREAD_COMET_X_CORRECTION: f32 = -8.0; const NEW_THREAD_COMET_CLIP_OVERSHOOT: f32 = 16.0; -const NEW_THREAD_HERO_HEIGHT: f32 = 244.0; +const NEW_THREAD_HERO_HEIGHT: f32 = 224.0; const NEW_THREAD_SELECTOR_INSET: f32 = 24.0; const NEW_THREAD_SELECTOR_BOTTOM: f32 = 14.0; From cbac78e5396755e0f3125552a7a060041752f35d Mon Sep 17 00:00:00 2001 From: gaelcado Date: Fri, 21 Aug 2026 23:00:25 +0200 Subject: [PATCH 09/40] refine(ui): optically center new thread composition --- crates/ui/src/shell.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 98160d8e5..fd0b110a2 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -691,13 +691,16 @@ const SIDEBAR_GLASS_FADE_BAND: f32 = 24.0; /// New-thread hero geometry. The comet is the full-bleed visual layer; the /// selectors and composer are the control layer floating over its faded tail. -const NEW_THREAD_COMET_WIDTH: f32 = 209.5; -const NEW_THREAD_COMET_HEIGHT: f32 = 240.0; +const NEW_THREAD_COMET_WIDTH: f32 = 174.5; +const NEW_THREAD_COMET_HEIGHT: f32 = 200.0; const NEW_THREAD_COMET_X_CORRECTION: f32 = -8.0; const NEW_THREAD_COMET_CLIP_OVERSHOOT: f32 = 16.0; -const NEW_THREAD_HERO_HEIGHT: f32 = 224.0; +const NEW_THREAD_HERO_HEIGHT: f32 = 184.0; const NEW_THREAD_SELECTOR_INSET: f32 = 24.0; const NEW_THREAD_SELECTOR_BOTTOM: f32 = 14.0; +/// The composer carries more visual mass than the fading mark, so mathematical +/// centering reads low. Lift the entire composition to its optical center. +const NEW_THREAD_COMPOSITION_Y_CORRECTION: f32 = -20.0; /// Drag marker for the sidebar resize handle. struct SidebarResize; @@ -6922,6 +6925,8 @@ impl Shell { .child( div() .w_full() + .relative() + .top(px(NEW_THREAD_COMPOSITION_Y_CORRECTION)) .flex() .flex_col() .items_center() @@ -7001,6 +7006,8 @@ impl Shell { "new-thread-composition", div() .w_full() + .relative() + .top(px(NEW_THREAD_COMPOSITION_Y_CORRECTION)) .flex() .flex_col() .items_center() From 028a4f357672c89c1729af9ab5eeadd0dd990051 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Fri, 21 Aug 2026 23:09:45 +0200 Subject: [PATCH 10/40] refine(ui): rebalance new thread hero controls --- crates/ui/src/pickers.rs | 53 +++++++++++++++++++++++++++++++++------- crates/ui/src/shell.rs | 8 +++--- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index 740a865e7..7aecd3f32 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -2408,9 +2408,9 @@ impl Pickers { .child(div().min_w_0().truncate().child(label)) } - /// The new-session canvas's target row — device leads and project trails, - /// mirroring the checkout/ref frame below the composer. Their popovers - /// anchor BELOW; sessions show their target in the titlebar instead. + /// The new-session canvas's target row — device and project form one + /// compact cluster at the leading edge. Their popovers open upward, away + /// from the composer; sessions show their target in the titlebar instead. pub fn render_target_selectors(&mut self, cx: &mut Context) -> AnyElement { let theme = Theme::of(cx).clone(); let closing = self.open.closing_since(); @@ -2471,8 +2471,8 @@ impl Pickers { .flex() .flex_row() .items_center() - .justify_between() - .child(attach_overlay_below( + .gap(px(4.0)) + .child(attach_overlay( device_chip, &mut overlay, PickerKind::Device, @@ -2627,13 +2627,14 @@ impl Pickers { cx, ); // Checkout on the left edge, ref on the right — the row's - // justify_between splits them (user request). + // justify_between splits them. These controls sit below the composer, + // so their menus open downward, away from it. let left = div() .flex() .flex_row() .items_center() .min_w_0() - .child(attach_overlay( + .child(attach_overlay_below( kind_chip, &mut overlay, PickerKind::Checkout, @@ -2645,7 +2646,7 @@ impl Pickers { .flex_row() .items_center() .min_w_0() - .child(attach_overlay_end( + .child(attach_overlay_below_end( ref_chip, &mut overlay, PickerKind::Branch, @@ -4009,8 +4010,42 @@ fn attach_overlay( chip } +/// [`attach_overlay`] opening DOWNWARD from controls below the composer. +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_overlay_below`] with the menu RIGHT-ALIGNED to the trigger. +fn attach_overlay_below_end( + 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 + .relative() + .child(popover::anchored_menu_below_end(id, element, closing)); + } + chip +} + /// [`attach_overlay`] with the menu RIGHT-ALIGNED to the trigger (t3code -/// `align="end"` — right-edge triggers like the ref picker open leftward). +/// `align="end"` — right-edge controls like the model picker open leftward). fn attach_overlay_end( chip: gpui::Stateful, overlay: &mut Option<(PickerKind, AnyElement)>, diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index fd0b110a2..569183af1 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -691,11 +691,11 @@ const SIDEBAR_GLASS_FADE_BAND: f32 = 24.0; /// New-thread hero geometry. The comet is the full-bleed visual layer; the /// selectors and composer are the control layer floating over its faded tail. -const NEW_THREAD_COMET_WIDTH: f32 = 174.5; -const NEW_THREAD_COMET_HEIGHT: f32 = 200.0; -const NEW_THREAD_COMET_X_CORRECTION: f32 = -8.0; +const NEW_THREAD_COMET_WIDTH: f32 = 139.5; +const NEW_THREAD_COMET_HEIGHT: f32 = 160.0; +const NEW_THREAD_COMET_X_CORRECTION: f32 = 72.0; const NEW_THREAD_COMET_CLIP_OVERSHOOT: f32 = 16.0; -const NEW_THREAD_HERO_HEIGHT: f32 = 184.0; +const NEW_THREAD_HERO_HEIGHT: f32 = 144.0; const NEW_THREAD_SELECTOR_INSET: f32 = 24.0; const NEW_THREAD_SELECTOR_BOTTOM: f32 = 14.0; /// The composer carries more visual mass than the fading mark, so mathematical From 4cf7dd06e1b66e61e33b8a98b7bb7ba9c0452a61 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Fri, 21 Aug 2026 23:14:36 +0200 Subject: [PATCH 11/40] refine(ui): balance new thread glyph region --- crates/ui/src/pickers.rs | 2 +- crates/ui/src/shell.rs | 70 ++++++++++++++++++++++------------------ 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index 7aecd3f32..10da9ccb3 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -2467,7 +2467,7 @@ impl Pickers { // left. The row sits just above the composer pill, so the menus open // UPWARD. div() - .w_full() + .flex_none() .flex() .flex_row() .items_center() diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 569183af1..be8f3285d 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -691,9 +691,8 @@ const SIDEBAR_GLASS_FADE_BAND: f32 = 24.0; /// New-thread hero geometry. The comet is the full-bleed visual layer; the /// selectors and composer are the control layer floating over its faded tail. -const NEW_THREAD_COMET_WIDTH: f32 = 139.5; -const NEW_THREAD_COMET_HEIGHT: f32 = 160.0; -const NEW_THREAD_COMET_X_CORRECTION: f32 = 72.0; +const NEW_THREAD_COMET_WIDTH: f32 = 115.0; +const NEW_THREAD_COMET_HEIGHT: f32 = 132.0; const NEW_THREAD_COMET_CLIP_OVERSHOOT: f32 = 16.0; const NEW_THREAD_HERO_HEIGHT: f32 = 144.0; const NEW_THREAD_SELECTOR_INSET: f32 = 24.0; @@ -834,8 +833,10 @@ fn new_thread_composer_offset( } /// The new-thread visual and context controls occupy one bounded hero region. -/// Only the comet layer clips at the composer's top edge; the selector rail -/// remains free to open its deferred popovers over the composer. +/// The selector cluster takes only its intrinsic width; the comet is centered +/// in the space from the cluster's trailing edge to the composer's trailing +/// edge. Only the comet's half clips at the composer boundary, leaving the +/// selector half free to open its deferred popovers. fn new_thread_hero(selectors: AnyElement, theme: &Theme) -> AnyElement { div() .w_full() @@ -846,32 +847,38 @@ fn new_thread_hero(selectors: AnyElement, theme: &Theme) -> AnyElement { div() .absolute() .inset_0() - .overflow_hidden() .flex() + .flex_row() .items_end() - .justify_center() .child( - icon(icons::ZERON_LOGO_FADED) - .w(px(NEW_THREAD_COMET_WIDTH)) - .h(px(NEW_THREAD_COMET_HEIGHT)) - // The asymmetric comet reads optically right-heavy. - .ml(px(NEW_THREAD_COMET_X_CORRECTION)) - // The layer ends at the composer; only the artwork - // overshoots and is clipped, keeping its last visible - // row flush with that boundary instead of floating. - .relative() - .top(px(NEW_THREAD_COMET_CLIP_OVERSHOOT)) - .text_color(theme.text.opacity(0.18)), + div() + .ml(px(NEW_THREAD_SELECTOR_INSET)) + .mb(px(NEW_THREAD_SELECTOR_BOTTOM)) + .flex_none() + .child(selectors), + ) + .child( + div() + .h_full() + .min_w_0() + .flex_1() + .overflow_hidden() + .flex() + .items_end() + .justify_center() + .child( + icon(icons::ZERON_LOGO_FADED) + .w(px(NEW_THREAD_COMET_WIDTH)) + .h(px(NEW_THREAD_COMET_HEIGHT)) + // The layer ends at the composer; only the + // artwork overshoots and is clipped, keeping + // its last visible row flush with that edge. + .relative() + .top(px(NEW_THREAD_COMET_CLIP_OVERSHOOT)) + .text_color(theme.text.opacity(0.18)), + ), ), ) - .child( - div() - .absolute() - .left(px(NEW_THREAD_SELECTOR_INSET)) - .right(px(NEW_THREAD_SELECTOR_INSET)) - .bottom(px(NEW_THREAD_SELECTOR_BOTTOM)) - .child(selectors), - ) .into_any_element() } @@ -9509,12 +9516,11 @@ mod tests { #[test] fn new_thread_handoff_is_continuous_and_staged() { - // The hero clips at the composer boundary while the larger artwork - // extends just beyond it, so the fade visually reaches that edge. - assert_eq!( - NEW_THREAD_COMET_HEIGHT - NEW_THREAD_HERO_HEIGHT, - NEW_THREAD_COMET_CLIP_OVERSHOOT - ); + // The artwork remains tall enough to survive its explicit bottom + // overshoot, so the fade visibly reaches the composer boundary even + // when the glyph is smaller than the selector-bearing hero region. + assert!(NEW_THREAD_COMET_CLIP_OVERSHOOT > 0.0); + assert!(NEW_THREAD_COMET_HEIGHT > NEW_THREAD_COMET_CLIP_OVERSHOOT); // The bottom-anchored destination starts exactly at the centered // source's bottom edge, then lands without overshoot. assert_eq!(new_thread_composer_offset(520.0, 840.0, 0.0), -320.0); From 9d5fc08a4f5517af5fe60bcbd4fafbf00287e0b0 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Fri, 21 Aug 2026 23:22:00 +0200 Subject: [PATCH 12/40] fix(ui): scale new thread glyph fade --- crates/ui/src/composer.rs | 12 +++++------- crates/ui/src/shell.rs | 25 ++++++++++++------------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index b0f035c08..ba1d0a9ad 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -5772,9 +5772,8 @@ 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 new_thread_launch = + self.launching_new_chat && 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() { @@ -5807,8 +5806,8 @@ impl Composer { // from the actually-rendered expanded height on the same // 360ms timeline as the shell's positional handoff. self.expanded_mode = false; - let now_ms = self.morph_clock.elapsed().as_secs_f32() * 1000.0 - / motion::speed_scale(); + let now_ms = + self.morph_clock.elapsed().as_secs_f32() * 1000.0 / motion::speed_scale(); self.flip_morph = Some(FlipMorph::new_thread_launch( self.last_rendered_height, now_ms, @@ -5817,8 +5816,7 @@ impl Composer { } 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.route_snap_until = Some(Instant::now() + Duration::from_millis(ROUTE_SNAP_MS)); } self.input.update(cx, |input, cx| input.set_text(draft, cx)); } diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index be8f3285d..72ec6cbb5 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -693,7 +693,9 @@ const SIDEBAR_GLASS_FADE_BAND: f32 = 24.0; /// selectors and composer are the control layer floating over its faded tail. const NEW_THREAD_COMET_WIDTH: f32 = 115.0; const NEW_THREAD_COMET_HEIGHT: f32 = 132.0; -const NEW_THREAD_COMET_CLIP_OVERSHOOT: f32 = 16.0; +/// Keep the clip inside the SVG's final 1%-opacity row at every glyph size. +/// A fixed pixel offset cut that row off after the artwork was reduced. +const NEW_THREAD_COMET_CLIP_OVERSHOOT: f32 = NEW_THREAD_COMET_HEIGHT * 0.10; const NEW_THREAD_HERO_HEIGHT: f32 = 144.0; const NEW_THREAD_SELECTOR_INSET: f32 = 24.0; const NEW_THREAD_SELECTOR_BOTTOM: f32 = 14.0; @@ -824,11 +826,7 @@ fn new_thread_header_opacity(progress: f32) -> f32 { (1.0 - progress / 0.55).clamp(0.0, 1.0) } -fn new_thread_composer_offset( - source_bottom: f32, - destination_bottom: f32, - progress: f32, -) -> f32 { +fn new_thread_composer_offset(source_bottom: f32, destination_bottom: f32, progress: f32) -> f32 { (source_bottom - destination_bottom) * (1.0 - progress.clamp(0.0, 1.0)) } @@ -6938,16 +6936,15 @@ impl Shell { .flex_col() .items_center() .child(new_thread_hero(selectors, theme)) - .child( - div() - .w_full() - .h(px(launch.source_height)), - ), + .child(div().w_full().h(px(launch.source_height))), ), ) .into_any_element() } else { - self.transcript.clone().cached(gpui::StyleRefinement::default().size_full()).into_any_element() + self.transcript + .clone() + .cached(gpui::StyleRefinement::default().size_full()) + .into_any_element() } } else if !has_spaces && !no_project { // Onboarding (first boot / after the destructive wipe): no folders @@ -9519,7 +9516,9 @@ mod tests { // The artwork remains tall enough to survive its explicit bottom // overshoot, so the fade visibly reaches the composer boundary even // when the glyph is smaller than the selector-bearing hero region. - assert!(NEW_THREAD_COMET_CLIP_OVERSHOOT > 0.0); + assert!( + (NEW_THREAD_COMET_CLIP_OVERSHOOT / NEW_THREAD_COMET_HEIGHT - 0.10).abs() < f32::EPSILON + ); assert!(NEW_THREAD_COMET_HEIGHT > NEW_THREAD_COMET_CLIP_OVERSHOOT); // The bottom-anchored destination starts exactly at the centered // source's bottom edge, then lands without overshoot. From c4bd57cd0c84b7e9419d1a3fbb4c2c0a7a3186ca Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sat, 12 Sep 2026 23:51:40 +0200 Subject: [PATCH 13/40] feat(ui): add customizable new thread backdrop --- crates/ui/assets/icons/zeron-logo-faded.svg | 9 - crates/ui/src/composer.rs | 234 +++++++--- crates/ui/src/icons.rs | 3 - crates/ui/src/motion.rs | 14 +- crates/ui/src/pickers.rs | 171 +++++-- crates/ui/src/settings.rs | 143 ++++++ crates/ui/src/settings/appearance.rs | 153 ++++++- crates/ui/src/shell.rs | 471 ++++++++++++-------- 8 files changed, 883 insertions(+), 315 deletions(-) delete mode 100644 crates/ui/assets/icons/zeron-logo-faded.svg diff --git a/crates/ui/assets/icons/zeron-logo-faded.svg b/crates/ui/assets/icons/zeron-logo-faded.svg deleted file mode 100644 index 0dc380474..000000000 --- a/crates/ui/assets/icons/zeron-logo-faded.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index ba1d0a9ad..b2b572d69 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -75,6 +75,16 @@ 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; +/// New-session controls live in two content-sized tabs emerging from opposite +/// composer corners. A 6px-radius control row sits inside 6px padding, so the +/// containing tab uses a concentric 12px radius. +const NEW_THREAD_TAB_CONTROL_HEIGHT: f32 = 24.0; +const NEW_THREAD_TAB_PADDING: f32 = 6.0; +const NEW_THREAD_TAB_RADIUS: f32 = crate::pickers::FOOTER_CHIP_RADIUS + NEW_THREAD_TAB_PADDING; +const NEW_THREAD_TAB_OVERLAP: f32 = QUEUE_COMPOSER_OVERLAP; +const NEW_THREAD_TAB_VISIBLE_HEIGHT: f32 = + NEW_THREAD_TAB_CONTROL_HEIGHT + 2.0 * NEW_THREAD_TAB_PADDING; +const NEW_THREAD_TAB_HEIGHT: f32 = NEW_THREAD_TAB_OVERLAP + NEW_THREAD_TAB_VISIBLE_HEIGHT; /// 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. @@ -299,7 +309,7 @@ pub fn comment_strip_height(count: usize) -> f32 { /// 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 blank- -/// thread handoff swaps in the coordinated 360ms launch spec. Both use the +/// 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. /// @@ -319,7 +329,7 @@ pub struct FlipMorph { /// 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 launch timeline. + /// handoff uses the shell's longer coordinated route timeline. pub spec: motion::MotionSpec, } @@ -332,11 +342,11 @@ impl FlipMorph { } } - fn new_thread_launch(from: f32, start_ms: f32) -> Self { + fn new_thread_transition(from: f32, start_ms: f32) -> Self { Self { from, start_ms, - spec: motion::NEW_THREAD_LAUNCH, + spec: motion::NEW_THREAD_TRANSITION, } } @@ -3826,15 +3836,14 @@ 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. - Sent { - chat_id: String, - message_id: String, - /// True only for the first prompt sent from the blank-thread canvas. - from_new_thread: bool, - }, + Sent { chat_id: String, message_id: String }, /// A locally-authored queue row was accepted. It is not a transcript send /// yet: the transcript remembers the stable id and promotes it to an /// own-turn anchor only when the host materializes the matching bubble. @@ -3980,9 +3989,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, @@ -5774,6 +5782,7 @@ impl Composer { 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() { @@ -5800,15 +5809,16 @@ impl Composer { // window snaps (see ROUTE_SNAP_MS). self.height_morph = None; self.last_target_height = 0.0; - if new_thread_launch && !motion::reduced_motion(cx) && self.last_rendered_height > 0.0 { - // The blank canvas is visibly expanded even when the stored - // mode is compact. Commit that compact target now and morph - // from the actually-rendered expanded height on the same - // 360ms timeline as the shell's positional handoff. - self.expanded_mode = false; + 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_launch( + self.flip_morph = Some(FlipMorph::new_thread_transition( self.last_rendered_height, now_ms, )); @@ -6195,6 +6205,9 @@ impl Composer { 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| { @@ -6222,7 +6235,6 @@ impl Composer { cx.emit(ComposerEvent::Sent { chat_id: chat_id.clone(), message_id: message_id.clone(), - from_new_thread: is_new, }); } cx.notify(); @@ -7449,14 +7461,21 @@ impl Render for Composer { }; 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)); + self.height_morph = 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 @@ -7705,44 +7724,108 @@ 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_chat.then(|| { + self.pickers.update(cx, |pickers, cx| { + pickers.render_new_thread_target_selectors(cx) + }) + }); + let new_thread_git_selectors = new_chat + .then(|| { + self.pickers.update(cx, |pickers, cx| { + pickers.render_new_thread_git_selectors(cx) + }) + }) + .flatten(); // 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)), - ); + // This entity is deliberately not wrapped in its own entrance fade. + // The shell reparents it between the blank canvas and transcript; a + // keyed opacity animation would remount and flash independently of + // the coordinated shared-element transition. + let pill_surface = div() + .relative() + .child(crate::frost::frosted(COMPOSER_RADIUS, 16.0, 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)); + let composer_stack = div() + .relative() + .when_some(new_thread_target_selectors, |stack, selectors| { + stack.pt(px(NEW_THREAD_TAB_VISIBLE_HEIGHT)).child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .h(px(NEW_THREAD_TAB_HEIGHT)) + .px(px(QUEUE_SIDE_INSET)) + .flex() + .justify_end() + .child( + div() + .min_w_0() + .max_w_full() + .h_full() + .occlude() + .rounded_t(px(NEW_THREAD_TAB_RADIUS)) + .bg(theme.input_glass_bg()) + .border_1() + .border_color(theme.border) + .when(!theme.is_frost(), |el| el.shadow_lg()) + .pt(px(NEW_THREAD_TAB_PADDING)) + .px(px(NEW_THREAD_TAB_PADDING)) + .pb(px(NEW_THREAD_TAB_OVERLAP + NEW_THREAD_TAB_PADDING)) + .child(selectors), + ), + ) + }) + .when(new_thread_git_selectors.is_some(), |stack| { + stack.pb(px(NEW_THREAD_TAB_VISIBLE_HEIGHT)) + }) + .when_some(new_thread_git_selectors, |stack, selectors| { + stack.child( + div() + .absolute() + .bottom_0() + .left_0() + .right_0() + .h(px(NEW_THREAD_TAB_HEIGHT)) + .px(px(QUEUE_SIDE_INSET)) + .flex() + .child( + div() + .min_w_0() + .max_w_full() + .h_full() + .occlude() + .rounded_b(px(NEW_THREAD_TAB_RADIUS)) + .bg(theme.input_glass_bg()) + .border_1() + .border_color(theme.border) + .when(!theme.is_frost(), |el| el.shadow_lg()) + .pt(px(NEW_THREAD_TAB_OVERLAP + NEW_THREAD_TAB_PADDING)) + .px(px(NEW_THREAD_TAB_PADDING)) + .pb(px(NEW_THREAD_TAB_PADDING)) + .child(selectors), + ), + ) + }) + // Paint the composer after both tabs so their overlaps sit behind + // its clean silhouette in opaque and frosted themes. + .child(pill_surface); + let container = container.child(composer_stack); // 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 container = if !new_chat { + let footer = self + .pickers + .update(cx, |pickers, cx| pickers.render_footer(cx)); + { let usage = self.state.read(cx).context_usage; container.child( div() @@ -7754,12 +7837,10 @@ impl Render for Composer { crate::context_usage::render(usage, self.state.clone(), &theme), )), ) - } else { - match footer { - Some(footer) => container.child(footer), - None => container, - } - }; + } + } 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) { @@ -9046,13 +9127,28 @@ mod tests { } #[test] - fn new_thread_launch_uses_the_coordinated_timeline() { - let m = FlipMorph::new_thread_launch(124.0, 0.0); - assert_eq!(m.spec, motion::NEW_THREAD_LAUNCH); + 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, 360.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_tabs_are_compact_balanced_and_concentric() { + assert_eq!(NEW_THREAD_TAB_PADDING, 6.0); + assert_eq!(NEW_THREAD_TAB_CONTROL_HEIGHT, 24.0); + assert_eq!(NEW_THREAD_TAB_VISIBLE_HEIGHT, 36.0); + assert_eq!(NEW_THREAD_TAB_HEIGHT, NEW_THREAD_TAB_OVERLAP + 36.0); + assert_eq!( + NEW_THREAD_TAB_RADIUS, + crate::pickers::FOOTER_CHIP_RADIUS + NEW_THREAD_TAB_PADDING + ); } #[test] diff --git a/crates/ui/src/icons.rs b/crates/ui/src/icons.rs index 6b55d0014..ea0144b36 100644 --- a/crates/ui/src/icons.rs +++ b/crates/ui/src/icons.rs @@ -188,9 +188,6 @@ icon_assets![ (STAR, "star"), (STAR_BOLD, "star-bold"), (ZERON_LOGO, "zeron-logo"), - // Hero-scale logo with row-level opacity baked into the vector so its - // lower pixels dissolve over glass without painting a fake background. - (ZERON_LOGO_FADED, "zeron-logo-faded"), // Harness brand marks (icons.tsx). (CLAUDE_MARK, "claude-mark"), (OPENAI_MARK, "openai-mark"), diff --git a/crates/ui/src/motion.rs b/crates/ui/src/motion.rs index e138e6310..ac6b041c2 100644 --- a/crates/ui/src/motion.rs +++ b/crates/ui/src/motion.rs @@ -359,11 +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); -/// First-send handoff: the centered new-thread composer travels to its -/// in-session anchor while the transcript takes over. A brisk 360ms -/// ease-in-out keeps the pieces reading as one transition while making the -/// sent message feel immediately acknowledged. -pub const NEW_THREAD_LAUNCH: MotionSpec = MotionSpec::new(360, EASE_IN_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); @@ -862,8 +862,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_LAUNCH.duration_ms, 360); - assert_eq!(NEW_THREAD_LAUNCH.curve, EASE_IN_OUT); + 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/pickers.rs b/crates/ui/src/pickers.rs index 10da9ccb3..2a7c8e320 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -31,6 +31,13 @@ use zeron_rpc::methods; /// pagination plumbing). const MAX_REF_ROWS: usize = 300; +/// Icons, chevron, padding, and the 24px pointer-target floor still fit when +/// responsive new-thread selectors have yielded all label width. +const NEW_THREAD_SELECTOR_MIN_WIDTH: f32 = 52.0; +/// Inner radius of the compact selector chips. New-thread tab surfaces derive +/// their outer radius from this value plus their inset. +pub(crate) const FOOTER_CHIP_RADIUS: f32 = 6.0; + use crate::composer::{ComposerInput, ComposerInputEvent}; use crate::motion; use crate::popover::{self, Loadable, MenuKey}; @@ -2346,7 +2353,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 +2378,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,10 +2417,9 @@ impl Pickers { .child(div().min_w_0().truncate().child(label)) } - /// The new-session canvas's target row — device and project form one - /// compact cluster at the leading edge. Their popovers open upward, away - /// from the composer; 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 share the + /// trailing tab which emerges above the composer. + 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() { @@ -2453,21 +2461,25 @@ impl Pickers { &theme, cx, ) + .h(px(24.0)) + .min_w(px(NEW_THREAD_SELECTOR_MIN_WIDTH)) + .flex_shrink(1.0) .when(offline, |el| el.text_color(theme.warning.opacity(0.8))); - let project_chip = self.footer_chip( - PickerKind::Space, - "picker-project", - crate::icons::FOLDER, - project_label, - &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. + let project_chip = self + .footer_chip( + PickerKind::Space, + "picker-project", + crate::icons::FOLDER, + project_label, + &theme, + cx, + ) + .h(px(24.0)) + .min_w(px(NEW_THREAD_SELECTOR_MIN_WIDTH)) + .flex_shrink(1.0); div() - .flex_none() + .min_w_0() + .max_w_full() .flex() .flex_row() .items_center() @@ -2479,7 +2491,7 @@ impl Pickers { "device-popover", closing, )) - .child(attach_overlay( + .child(attach_overlay_end( project_chip, &mut overlay, PickerKind::Space, @@ -2489,10 +2501,91 @@ impl Pickers { .into_any_element() } + /// New-session Git controls. Checkout mode and branch share the leading + /// tab which emerges below the composer. Non-Git projects omit it. + 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, + ) + .h(px(24.0)) + .min_w(px(NEW_THREAD_SELECTOR_MIN_WIDTH)) + .flex_shrink(1.0); + let branch_chip = self + .footer_chip( + PickerKind::Branch, + "picker-branch", + crate::icons::GIT_BRANCH, + self.ref_label(), + &theme, + cx, + ) + .h(px(24.0)) + .min_w(px(NEW_THREAD_SELECTOR_MIN_WIDTH)) + .flex_shrink(1.0); + Some( + div() + .min_w_0() + .max_w_full() + .flex() + .flex_row() + .items_center() + .gap(px(4.0)) + .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 + /// selector tabs; 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 @@ -2600,8 +2693,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 new-thread selector tab. _ => None, }; @@ -2627,14 +2719,13 @@ impl Pickers { cx, ); // Checkout on the left edge, ref on the right — the row's - // justify_between splits them. These controls sit below the composer, - // so their menus open downward, away from it. + // justify_between splits them. let left = div() .flex() .flex_row() .items_center() .min_w_0() - .child(attach_overlay_below( + .child(attach_overlay( kind_chip, &mut overlay, PickerKind::Checkout, @@ -2646,7 +2737,7 @@ impl Pickers { .flex_row() .items_center() .min_w_0() - .child(attach_overlay_below_end( + .child(attach_overlay_end( ref_chip, &mut overlay, PickerKind::Branch, @@ -3994,7 +4085,7 @@ fn offered_harnesses_impl(list: &[HarnessDescriptor], allow_mock: bool) -> Vec, overlay: &mut Option<(PickerKind, AnyElement)>, @@ -4010,7 +4101,7 @@ fn attach_overlay( chip } -/// [`attach_overlay`] opening DOWNWARD from controls below the composer. +/// Attach the (single) open popover below a selector trigger. fn attach_overlay_below( chip: gpui::Stateful, overlay: &mut Option<(PickerKind, AnyElement)>, @@ -4026,25 +4117,7 @@ fn attach_overlay_below( chip } -/// [`attach_overlay_below`] with the menu RIGHT-ALIGNED to the trigger. -fn attach_overlay_below_end( - 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 - .relative() - .child(popover::anchored_menu_below_end(id, element, closing)); - } - chip -} - -/// [`attach_overlay`] with the menu RIGHT-ALIGNED to the trigger (t3code +/// 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, diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index 1c6e774e6..5acf2dae7 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -57,6 +57,16 @@ 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, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -224,6 +234,102 @@ 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)?; + 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(()) +} + +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 +560,9 @@ 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, /// 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 +619,7 @@ impl Default for UiSettings { files_show_all: false, accent: zeron_theme::AccentSelection::default(), surface: zeron_theme::SurfacePreference::default(), + new_thread_composer_background: None, legacy_accent_color: None, } } @@ -1124,6 +1234,7 @@ 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.sidebar_width, 300.0); assert!(!loaded.sound_enabled); for sound in [ @@ -1213,6 +1324,34 @@ 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()); + } + #[test] fn obsolete_steering_preference_does_not_reset_other_settings() { let loaded: UiSettings = serde_json::from_str( @@ -1301,6 +1440,10 @@ 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(), + }), legacy_accent_color: None, }; settings.save(dir.path()).unwrap(); diff --git a/crates/ui/src/settings/appearance.rs b/crates/ui/src/settings/appearance.rs index 546d1d09c..62d69696b 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; @@ -1913,6 +1950,7 @@ impl Render for AppearancePage { let current_themes = appearance::themes(cx); let current_accent = appearance::accent(cx); let current_surface = appearance::surface(cx); + let current_background = crate::settings::current(cx).new_thread_composer_background; let cards = AppearanceMode::ALL .into_iter() .map(|mode| { @@ -2050,6 +2088,117 @@ 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 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 72ec6cbb5..9d9299fa5 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -18,8 +18,8 @@ use chrono::Utc; use gpui::{ Action, AnyElement, App, ClipboardItem, Context, Empty, Entity, FocusHandle, Focusable as _, IntoElement, KeyBinding, Keystroke, ModifiersChangedEvent, MouseButton, MouseDownEvent, - MouseUpEvent, Pixels, Point, Render, SharedString, Subscription, Task, Window, - WindowControlArea, actions, div, prelude::*, px, + MouseUpEvent, ObjectFit, Pixels, Point, Render, SharedString, StyledImage as _, Subscription, + Task, Window, WindowControlArea, actions, div, img, prelude::*, px, }; use gpui_tokio::Tokio; @@ -28,9 +28,7 @@ use zeron_proto::{AuthState, WorkspaceScope}; use zeron_rpc::methods; use crate::changes::{Changes, ChangesEvent}; -use crate::composer::{ - COMPOSER_MAX_WIDTH, Composer, ComposerEvent, ComposerInput, ComposerInputEvent, -}; +use crate::composer::{Composer, ComposerEvent, ComposerInput, ComposerInputEvent}; use crate::files::{FilesCloseDisposition, FilesEvent, FilesSurface, WorkspacePathDrag}; use crate::icons::{self, icon}; use crate::loaders; @@ -689,19 +687,17 @@ 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 hero geometry. The comet is the full-bleed visual layer; the -/// selectors and composer are the control layer floating over its faded tail. -const NEW_THREAD_COMET_WIDTH: f32 = 115.0; -const NEW_THREAD_COMET_HEIGHT: f32 = 132.0; -/// Keep the clip inside the SVG's final 1%-opacity row at every glyph size. -/// A fixed pixel offset cut that row off after the artwork was reduced. -const NEW_THREAD_COMET_CLIP_OVERSHOOT: f32 = NEW_THREAD_COMET_HEIGHT * 0.10; -const NEW_THREAD_HERO_HEIGHT: f32 = 144.0; -const NEW_THREAD_SELECTOR_INSET: f32 = 24.0; -const NEW_THREAD_SELECTOR_BOTTOM: f32 = 14.0; -/// The composer carries more visual mass than the fading mark, so mathematical -/// centering reads low. Lift the entire composition to its optical center. -const NEW_THREAD_COMPOSITION_Y_CORRECTION: f32 = -20.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; +const NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO: f32 = 0.62; +const NEW_THREAD_BACKGROUND_SIDE_FADE: f32 = 72.0; +const NEW_THREAD_BACKGROUND_SIDE_FADE_RATIO: f32 = 0.18; +/// The reference composition sits just above the canvas midpoint. +const NEW_THREAD_COMPOSITION_Y_CORRECTION: f32 = -16.0; /// Drag marker for the sidebar resize handle. struct SidebarResize; @@ -807,75 +803,108 @@ impl WidthTween { } } -/// One coordinated blank-thread → session handoff. The source composer bounds -/// come from the last painted blank canvas; the destination is the ordinary -/// bottom composer anchor. Position, composer height, outgoing header, and -/// transcript reveal all share [`motion::NEW_THREAD_LAUNCH`]. +/// One reversible new-thread ↔ session handoff. The composer's measured +/// bottom edge is the shared-element anchor in both directions; canvas, +/// transcript, and height staging all share [`motion::NEW_THREAD_TRANSITION`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NewThreadTransitionDirection { + IntoThread, + IntoNewThread, +} + #[derive(Debug, Clone, Copy)] -struct NewThreadLaunch { - source_bottom: f32, - source_height: f32, +struct NewThreadTransition { + direction: NewThreadTransitionDirection, + origin_bottom: f32, + destination_bottom: f32, started: std::time::Instant, } fn new_thread_transcript_opacity(progress: f32) -> f32 { - ((progress - 0.12) / 0.88).clamp(0.0, 1.0) + ((progress - 0.14) / 0.72).clamp(0.0, 1.0) } -fn new_thread_header_opacity(progress: f32) -> f32 { - (1.0 - progress / 0.55).clamp(0.0, 1.0) +fn new_thread_canvas_opacity(direction: NewThreadTransitionDirection, progress: f32) -> f32 { + match direction { + NewThreadTransitionDirection::IntoThread => (1.0 - progress / 0.68).clamp(0.0, 1.0), + NewThreadTransitionDirection::IntoNewThread => ((progress - 0.06) / 0.78).clamp(0.0, 1.0), + } } -fn new_thread_composer_offset(source_bottom: f32, destination_bottom: f32, progress: f32) -> f32 { - (source_bottom - destination_bottom) * (1.0 - progress.clamp(0.0, 1.0)) +fn new_thread_composer_offset(origin_bottom: f32, destination_bottom: f32, progress: f32) -> f32 { + (origin_bottom - destination_bottom) * (1.0 - progress.clamp(0.0, 1.0)) } -/// The new-thread visual and context controls occupy one bounded hero region. -/// The selector cluster takes only its intrinsic width; the comet is centered -/// in the space from the cluster's trailing edge to the composer's trailing -/// edge. Only the comet's half clips at the composer boundary, leaving the -/// selector half free to open its deferred popovers. -fn new_thread_hero(selectors: AnyElement, theme: &Theme) -> AnyElement { +fn new_thread_transcript_settle(progress: f32) -> f32 { + 8.0 * (1.0 - new_thread_transcript_opacity(progress)) +} + +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_side_fade(viewport_width: f32) -> f32 { + (viewport_width.max(0.0) * NEW_THREAD_BACKGROUND_SIDE_FADE_RATIO) + .min(NEW_THREAD_BACKGROUND_SIDE_FADE) +} + +fn new_thread_background( + background: Option<&settings::NewThreadComposerBackground>, + theme: &Theme, + viewport_width: f32, + viewport_height: f32, + transition_opacity: f32, +) -> AnyElement { + let Some(background) = background else { + return Empty.into_any_element(); + }; + let path = PathBuf::from(&background.path); + if !path.is_file() { + return Empty.into_any_element(); + } + let hero_height = new_thread_background_height(viewport_height); + let side_fade = new_thread_background_side_fade(viewport_width); div() - .w_full() - .max_w(px(COMPOSER_MAX_WIDTH)) - .h(px(NEW_THREAD_HERO_HEIGHT)) - .relative() + .absolute() + .top_0() + .left_0() + .right_0() + .h(px(hero_height)) + .overflow_hidden() + .opacity(transition_opacity.clamp(0.0, 1.0)) + // Fade the image primitive itself instead of painting a theme-colored + // gradient above it. That creates a real alpha mask, so the tail + // resolves into the exact canvas beneath it on both opaque and glass + // themes without a horizontal color seam. .child( - div() - .absolute() - .inset_0() - .flex() - .flex_row() - .items_end() - .child( - div() - .ml(px(NEW_THREAD_SELECTOR_INSET)) - .mb(px(NEW_THREAD_SELECTOR_BOTTOM)) - .flex_none() - .child(selectors), - ) - .child( - div() - .h_full() - .min_w_0() - .flex_1() - .overflow_hidden() - .flex() - .items_end() - .justify_center() - .child( - icon(icons::ZERON_LOGO_FADED) - .w(px(NEW_THREAD_COMET_WIDTH)) - .h(px(NEW_THREAD_COMET_HEIGHT)) - // The layer ends at the composer; only the - // artwork overshoots and is clipped, keeping - // its last visible row flush with that edge. - .relative() - .top(px(NEW_THREAD_COMET_CLIP_OVERSHOOT)) - .text_color(theme.text.opacity(0.18)), - ), + crate::edge_fade::edge_faded( + side_fade, + false, + true, + // Give the mask a definite relayout box. A percentage-sized + // image as the custom element's direct child could briefly + // resolve to zero during live window resize. + div().relative().w_full().h(px(hero_height)).child( + img(path) + .absolute() + .inset_0() + .size_full() + .object_fit(ObjectFit::Cover) + .opacity(new_thread_background_opacity(theme.is_frost())), ), + ) + .band_bottom(hero_height * NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO) + .fade_left(true) + .fade_right(true), ) .into_any_element() } @@ -1269,7 +1298,7 @@ pub struct Shell { /// transition uses its bottom edge as the FLIP source anchor. new_thread_composer_bottom: std::rc::Rc>, new_thread_composer_height: std::rc::Rc>, - new_thread_launch: Option, + new_thread_transition: Option, /// 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). @@ -1532,17 +1561,16 @@ impl Shell { let composer_events = cx.subscribe(&composer, { let transcript = transcript.clone(); move |this: &mut Shell, _, event: &ComposerEvent, cx| match event { + ComposerEvent::NewThreadTransitionStarted => { + this.begin_new_thread_launch(cx); + } ComposerEvent::Sent { chat_id, message_id, - from_new_thread, } => { transcript.update(cx, |t, cx| { t.on_own_send(chat_id.clone(), message_id.clone(), cx) }); - if *from_new_thread { - this.begin_new_thread_launch(cx); - } } ComposerEvent::Queued { chat_id, @@ -1663,7 +1691,7 @@ impl Shell { bottom_stack: std::rc::Rc::new(std::cell::Cell::new(120.0)), new_thread_composer_bottom: std::rc::Rc::new(std::cell::Cell::new(0.0)), new_thread_composer_height: std::rc::Rc::new(std::cell::Cell::new(0.0)), - new_thread_launch: None, + new_thread_transition: None, archived_open: true, archived_shown: 0, archived_hover: None, @@ -2096,6 +2124,14 @@ impl Shell { } if selected != self.active_chat { self.suspend_file_images(cx); + if !self.active_chat.is_empty() + && selected.is_empty() + && matches!(self.route, Route::Chat) + { + // Capture the established composer's bottom anchor before + // `active_chat` changes the panel key and terminal geometry. + self.begin_new_thread_return(cx); + } self.active_chat = selected; // Route history: a chat switch is a navigation. The very first // selection off the untouched boot canvas REPLACES that entry — @@ -3306,6 +3342,8 @@ 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.settings.new_thread_composer_background = + settings::current(cx).new_thread_composer_background; 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); @@ -4413,38 +4451,68 @@ impl Shell { // ---- render pieces ---- fn begin_new_thread_launch(&mut self, cx: &mut Context) { - let source_bottom = self.new_thread_composer_bottom.get(); + let origin_bottom = self.new_thread_composer_bottom.get(); let source_height = self.new_thread_composer_height.get(); - if motion::reduced_motion(cx) || source_bottom <= 0.0 || source_height <= 0.0 { - self.new_thread_launch = None; + let destination_bottom = + self.viewport_height - self.eval_tween(self.terminal_tween, self.terminal_target(cx)); + if motion::reduced_motion(cx) || origin_bottom <= 0.0 || source_height <= 0.0 { + self.new_thread_transition = None; + return; + } + self.new_thread_transition = Some(NewThreadTransition { + direction: NewThreadTransitionDirection::IntoThread, + origin_bottom, + destination_bottom, + started: std::time::Instant::now(), + }); + cx.notify(); + } + + fn begin_new_thread_return(&mut self, cx: &mut Context) { + let terminal_height = self.eval_tween(self.terminal_tween, self.terminal_target(cx)); + let origin_bottom = self.viewport_height - terminal_height; + let measured_height = self.new_thread_composer_height.get(); + let destination_bottom = if self.new_thread_composer_bottom.get() > 0.0 { + self.new_thread_composer_bottom.get() + } else { + // Boot may land directly in a session before the blank canvas has + // ever painted. Use its centered geometry as a safe first-return + // target; the canvas measure replaces this for later transitions. + self.viewport_height * 0.5 + + measured_height.max(crate::composer::COMPOSER_MIN_HEIGHT) * 0.5 + + NEW_THREAD_COMPOSITION_Y_CORRECTION + }; + if motion::reduced_motion(cx) || origin_bottom <= 0.0 || destination_bottom <= 0.0 { + self.new_thread_transition = None; return; } - self.new_thread_launch = Some(NewThreadLaunch { - source_bottom, - source_height, + self.new_thread_transition = Some(NewThreadTransition { + direction: NewThreadTransitionDirection::IntoNewThread, + origin_bottom, + destination_bottom, started: std::time::Instant::now(), }); cx.notify(); } - /// Current coordinated first-send frame. Manual evaluation avoids a - /// remount replay when the composer moves between its two parents. - fn new_thread_launch_frame(&mut self) -> Option<(NewThreadLaunch, f32)> { - let launch = self.new_thread_launch?; + /// Current coordinated route frame. Manual evaluation avoids a remount + /// replay when the composer moves between its two parents. + fn new_thread_transition_frame(&mut self) -> Option<(NewThreadTransition, f32)> { + let transition = self.new_thread_transition?; if self.reduced_motion { - self.new_thread_launch = None; + self.new_thread_transition = None; return None; } - let total = motion::NEW_THREAD_LAUNCH + let total = motion::NEW_THREAD_TRANSITION .total() .mul_f32(motion::speed_scale()); - let raw = launch.started.elapsed().as_secs_f32() / total.as_secs_f32(); + let raw = transition.started.elapsed().as_secs_f32() / total.as_secs_f32(); if raw >= 1.0 { - self.new_thread_launch = None; + self.new_thread_transition = None; return None; } self.motion_active.set(true); - Some((launch, motion::NEW_THREAD_LAUNCH.progress(raw))) + Some((transition, motion::NEW_THREAD_TRANSITION.progress(raw))) } fn tween_elapsed(&self, started: std::time::Instant) -> Duration { @@ -6864,7 +6932,12 @@ impl Shell { ) } - fn render_main(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + fn render_main( + &mut self, + window: &mut Window, + main_content_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); @@ -6890,55 +6963,64 @@ 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 launch_frame = self.new_thread_launch_frame(); + let new_thread_background_setting = settings::current(cx).new_thread_composer_background; + let transition_frame = self.new_thread_transition_frame(); + let new_thread_background_layer = if !has_selection { + Some(new_thread_background( + new_thread_background_setting.as_ref(), + theme, + main_content_width, + self.viewport_height, + transition_frame + .filter(|(transition, _)| { + transition.direction == NewThreadTransitionDirection::IntoNewThread + }) + .map_or(1.0, |(transition, progress)| { + new_thread_canvas_opacity(transition.direction, progress) + }), + )) + } else { + transition_frame + .filter(|(transition, _)| { + transition.direction == NewThreadTransitionDirection::IntoThread + }) + .map(|(transition, progress)| { + new_thread_background( + new_thread_background_setting.as_ref(), + theme, + main_content_width, + self.viewport_height, + new_thread_canvas_opacity(transition.direction, progress), + ) + }) + }; let term_h = self.eval_tween(self.terminal_tween, self.terminal_target(cx)); - let launch_composer_offset = launch_frame.map(|(launch, progress)| { - let destination_bottom = self.viewport_height - term_h; - new_thread_composer_offset(launch.source_bottom, destination_bottom, progress) + let transition_composer_offset = transition_frame.map(|(transition, progress)| { + new_thread_composer_offset( + transition.origin_bottom, + transition.destination_bottom, + progress, + ) }); // 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 outlet: AnyElement = if has_selection { - if let Some((launch, progress)) = launch_frame { - let pickers = self.composer.read(cx).pickers().clone(); - let selectors = pickers.update(cx, |p, cx| p.render_target_selectors(cx)); + if let Some((_, progress)) = transition_frame.filter(|(transition, _)| { + transition.direction == NewThreadTransitionDirection::IntoThread + }) { div() .relative() .size_full() .child( div() + .relative() + .top(px(new_thread_transcript_settle(progress))) .size_full() .opacity(new_thread_transcript_opacity(progress)) .child(self.transcript.clone()), ) - // Let the source header dissolve while the composer leaves - // it behind. The composer itself is rendered only once — - // in the destination stack — and FLIP-offset to its old - // bottom edge below. - .child( - div() - .absolute() - .inset_0() - .flex() - .flex_col() - .items_center() - .justify_center() - .opacity(new_thread_header_opacity(progress)) - .top(px(-8.0 * progress)) - .child( - div() - .w_full() - .relative() - .top(px(NEW_THREAD_COMPOSITION_Y_CORRECTION)) - .flex() - .flex_col() - .items_center() - .child(new_thread_hero(selectors, theme)) - .child(div().w_full().h(px(launch.source_height))), - ), - ) .into_any_element() } else { self.transcript @@ -6994,48 +7076,58 @@ impl Shell { )) .into_any_element() } else { - // New-thread canvas: the mark, target selectors, composer, and - // checkout row form one vertically-centered composition. The - // composer lives here only while the canvas is blank; established - // sessions keep it in the bottom chrome stack below. - let pickers = self.composer.read(cx).pickers().clone(); - let selectors = pickers.update(cx, |p, cx| p.render_target_selectors(cx)); - div() - .size_full() + // New-thread canvas: optional artwork fills the panel behind one + // vertically-centered controls composition. The composer lives + // here only while the canvas is blank; established sessions keep + // it in the bottom chrome stack below. + let composition = div() + .w_full() + .relative() + .top(px(NEW_THREAD_COMPOSITION_Y_CORRECTION + + transition_frame + .filter(|(transition, _)| { + transition.direction == NewThreadTransitionDirection::IntoNewThread + }) + .map_or(0.0, |_| { + transition_composer_offset.unwrap_or(0.0) + }))) .flex() .flex_col() .items_center() - .justify_center() - .child(motion::settle_down( - "new-thread-composition", + .child({ + let bottom = self.new_thread_composer_bottom.clone(); + let height = self.new_thread_composer_height.clone(); div() .w_full() .relative() - .top(px(NEW_THREAD_COMPOSITION_Y_CORRECTION)) - .flex() - .flex_col() - .items_center() - .child(new_thread_hero(selectors, theme)) - .child({ - let bottom = self.new_thread_composer_bottom.clone(); - let height = self.new_thread_composer_height.clone(); - div() - .w_full() - .relative() - .child( - gpui::canvas( - move |bounds, _, _| { - bottom.set(f32::from(bounds.bottom())); - height.set(f32::from(bounds.size.height)); - }, - |_, _, _, _| {}, - ) - .absolute() - .inset_0(), - ) - .child(self.composer.clone()) - }), - )) + .child( + gpui::canvas( + move |bounds, _, _| { + bottom.set(f32::from(bounds.bottom())); + height.set(f32::from(bounds.size.height)); + }, + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ) + .child(self.composer.clone()) + }); + let composition: AnyElement = if transition_frame.is_some_and(|(transition, _)| { + transition.direction == NewThreadTransitionDirection::IntoNewThread + }) { + composition.into_any_element() + } else { + motion::settle_down("new-thread-composition", composition).into_any_element() + }; + div() + .size_full() + .relative() + .flex() + .flex_col() + .items_center() + .justify_center() + .child(composition) .into_any_element() }; @@ -7108,6 +7200,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 @@ -7182,7 +7278,7 @@ impl Shell { el.child( div() .relative() - .top(px(launch_composer_offset.unwrap_or(0.0))) + .top(px(transition_composer_offset.unwrap_or(0.0))) .child(self.composer.clone()), ) }) @@ -8982,6 +9078,8 @@ 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.settings.new_thread_composer_background = + settings::current(cx).new_thread_composer_background; 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 @@ -9287,7 +9385,7 @@ impl Render for Shell { |shell, _| shell.settings.sidebar_width = SIDEBAR_DEFAULT, cx, ); - let main = self.render_main(window, cx); + let main = self.render_main(window, main_content_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 @@ -9513,24 +9611,45 @@ mod tests { #[test] fn new_thread_handoff_is_continuous_and_staged() { - // The artwork remains tall enough to survive its explicit bottom - // overshoot, so the fade visibly reaches the composer boundary even - // when the glyph is smaller than the selector-bearing hero region. - assert!( - (NEW_THREAD_COMET_CLIP_OVERSHOOT / NEW_THREAD_COMET_HEIGHT - 0.10).abs() < f32::EPSILON + assert_eq!(new_thread_background_opacity(false), 1.0); + assert_eq!( + new_thread_background_opacity(true), + NEW_THREAD_BACKGROUND_FROSTED_OPACITY ); - assert!(NEW_THREAD_COMET_HEIGHT > NEW_THREAD_COMET_CLIP_OVERSHOOT); + 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); + assert!((new_thread_background_side_fade(160.0) - 28.8).abs() < 0.001); + assert_eq!(new_thread_background_side_fade(1_000.0), 72.0); + assert!(new_thread_background_side_fade(160.0) * 2.0 < 160.0); // The bottom-anchored destination starts exactly at the centered // source's bottom edge, then lands without overshoot. assert_eq!(new_thread_composer_offset(520.0, 840.0, 0.0), -320.0); assert_eq!(new_thread_composer_offset(520.0, 840.0, 0.5), -160.0); assert_eq!(new_thread_composer_offset(520.0, 840.0, 1.0), 0.0); - // The source header leaves first; the transcript arrives just after - // motion begins and is fully opaque at rest. - assert_eq!(new_thread_header_opacity(0.0), 1.0); - assert_eq!(new_thread_header_opacity(1.0), 0.0); + // The canvas leaves early on send and returns on the reverse path; + // the transcript arrives just after motion begins and settles upward. + assert_eq!( + new_thread_canvas_opacity(NewThreadTransitionDirection::IntoThread, 0.0), + 1.0 + ); + assert_eq!( + new_thread_canvas_opacity(NewThreadTransitionDirection::IntoThread, 1.0), + 0.0 + ); + assert_eq!( + new_thread_canvas_opacity(NewThreadTransitionDirection::IntoNewThread, 0.0), + 0.0 + ); + assert_eq!( + new_thread_canvas_opacity(NewThreadTransitionDirection::IntoNewThread, 1.0), + 1.0 + ); assert_eq!(new_thread_transcript_opacity(0.0), 0.0); assert_eq!(new_thread_transcript_opacity(1.0), 1.0); + assert_eq!(new_thread_transcript_settle(0.0), 8.0); + assert_eq!(new_thread_transcript_settle(1.0), 0.0); } #[test] From 586076ecead230c61c0e7e54f960cdd802fb3a61 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 00:00:26 +0200 Subject: [PATCH 14/40] fix(ui): harden composer route transitions --- crates/ui/src/composer.rs | 159 ++++++++++++++++++++++++++------------ crates/ui/src/pickers.rs | 8 +- crates/ui/src/shell.rs | 98 +++++++++++++++++------ 3 files changed, 188 insertions(+), 77 deletions(-) diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index b2b572d69..f06dabfcc 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -85,6 +85,16 @@ const NEW_THREAD_TAB_OVERLAP: f32 = QUEUE_COMPOSER_OVERLAP; const NEW_THREAD_TAB_VISIBLE_HEIGHT: f32 = NEW_THREAD_TAB_CONTROL_HEIGHT + 2.0 * NEW_THREAD_TAB_PADDING; const NEW_THREAD_TAB_HEIGHT: f32 = NEW_THREAD_TAB_OVERLAP + NEW_THREAD_TAB_VISIBLE_HEIGHT; +const SESSION_FOOTER_HEIGHT: f32 = 20.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. @@ -7464,6 +7474,19 @@ impl Render for Composer { 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 = 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) = + route_chrome_opacities(new_thread_chrome); self.height_morph = if coordinated_route_morph.is_some() { coordinated_route_morph } else { @@ -7724,18 +7747,23 @@ impl Render for Composer { ), ) }; - let new_thread_target_selectors = new_chat.then(|| { + 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_chat + 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_tab = 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`. @@ -7754,46 +7782,54 @@ impl Render for Composer { .children(self.render_slash_popup(&theme, cx)); let composer_stack = div() .relative() - .when_some(new_thread_target_selectors, |stack, selectors| { - stack.pt(px(NEW_THREAD_TAB_VISIBLE_HEIGHT)).child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .h(px(NEW_THREAD_TAB_HEIGHT)) - .px(px(QUEUE_SIDE_INSET)) - .flex() - .justify_end() - .child( - div() - .min_w_0() - .max_w_full() - .h_full() - .occlude() - .rounded_t(px(NEW_THREAD_TAB_RADIUS)) - .bg(theme.input_glass_bg()) - .border_1() - .border_color(theme.border) - .when(!theme.is_frost(), |el| el.shadow_lg()) - .pt(px(NEW_THREAD_TAB_PADDING)) - .px(px(NEW_THREAD_TAB_PADDING)) - .pb(px(NEW_THREAD_TAB_OVERLAP + NEW_THREAD_TAB_PADDING)) - .child(selectors), - ), - ) + .when(new_thread_chrome > 0.0, |stack| { + stack + .pt(px(NEW_THREAD_TAB_VISIBLE_HEIGHT * new_thread_chrome)) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .h(px(NEW_THREAD_TAB_HEIGHT + - NEW_THREAD_TAB_VISIBLE_HEIGHT + * (1.0 - new_thread_chrome))) + .px(px(QUEUE_SIDE_INSET)) + .flex() + .justify_end() + .child( + div() + .min_w_0() + .max_w_full() + .h_full() + .occlude() + .rounded_t(px(NEW_THREAD_TAB_RADIUS)) + .bg(theme.input_glass_bg()) + .border_1() + .border_color(theme.border) + .when(!theme.is_frost(), |el| el.shadow_lg()) + .overflow_hidden() + .opacity(new_thread_chrome_opacity) + .pt(px(NEW_THREAD_TAB_PADDING)) + .px(px(NEW_THREAD_TAB_PADDING)) + .pb(px(NEW_THREAD_TAB_OVERLAP + NEW_THREAD_TAB_PADDING)) + .children(new_thread_target_selectors), + ), + ) }) - .when(new_thread_git_selectors.is_some(), |stack| { - stack.pb(px(NEW_THREAD_TAB_VISIBLE_HEIGHT)) + .when(has_new_thread_git_tab && new_thread_chrome > 0.0, |stack| { + stack.pb(px(NEW_THREAD_TAB_VISIBLE_HEIGHT * new_thread_chrome)) }) - .when_some(new_thread_git_selectors, |stack, selectors| { + .when(has_new_thread_git_tab && new_thread_chrome > 0.0, |stack| { stack.child( div() .absolute() .bottom_0() .left_0() .right_0() - .h(px(NEW_THREAD_TAB_HEIGHT)) + .h(px(NEW_THREAD_TAB_HEIGHT + - NEW_THREAD_TAB_VISIBLE_HEIGHT + * (1.0 - new_thread_chrome))) .px(px(QUEUE_SIDE_INSET)) .flex() .child( @@ -7807,10 +7843,12 @@ impl Render for Composer { .border_1() .border_color(theme.border) .when(!theme.is_frost(), |el| el.shadow_lg()) + .overflow_hidden() + .opacity(new_thread_chrome_opacity) .pt(px(NEW_THREAD_TAB_OVERLAP + NEW_THREAD_TAB_PADDING)) .px(px(NEW_THREAD_TAB_PADDING)) .pb(px(NEW_THREAD_TAB_PADDING)) - .child(selectors), + .children(new_thread_git_selectors), ), ) }) @@ -7821,23 +7859,35 @@ impl Render for Composer { // 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 container = if !new_chat { + let container = if session_chrome_opacity > 0.0 { let footer = self .pickers .update(cx, |pickers, cx| pickers.render_footer(cx)); - { - 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), - )), - ) - } + let usage = self.state.read(cx).context_usage; + let session_chrome = 1.0 - new_thread_chrome; + container.child( + div() + // A flex-column gap is inserted before this child. Cancel + // it at t=0, then hand it back while the 20px row reveals; + // the proportional -8px bottom bleed preserves the old + // steady-state 8px/8px optical padding. + .h(px(SESSION_FOOTER_HEIGHT * session_chrome)) + .mt(px(-Theme::SPACE_SM * (1.0 - session_chrome))) + .mb(px(-Theme::SPACE_SM * session_chrome)) + .overflow_hidden() + .opacity(session_chrome_opacity) + .child( + div() + .w_full() + .h(px(SESSION_FOOTER_HEIGHT)) + .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), + )), + ), + ) } else { container }; @@ -9151,6 +9201,17 @@ mod tests { ); } + #[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/pickers.rs b/crates/ui/src/pickers.rs index 2a7c8e320..d00215431 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -2605,9 +2605,10 @@ 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 @@ -2620,7 +2621,6 @@ impl Pickers { .justify_between() .gap(px(8.0)) .px(px(10.0)) - .mb(px(-8.0)) }; if let Some(chat) = &session { diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 9d9299fa5..2a57396ae 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -803,6 +803,23 @@ impl WidthTween { } } +fn new_thread_transition_progress(transition: NewThreadTransition) -> f32 { + let total = motion::NEW_THREAD_TRANSITION + .total() + .mul_f32(motion::speed_scale()); + let raw = transition.started.elapsed().as_secs_f32() / total.as_secs_f32(); + motion::NEW_THREAD_TRANSITION.progress(raw.clamp(0.0, 1.0)) +} + +fn new_thread_transition_bottom(transition: NewThreadTransition) -> f32 { + transition.destination_bottom + + new_thread_composer_offset( + transition.origin_bottom, + transition.destination_bottom, + new_thread_transition_progress(transition), + ) +} + /// One reversible new-thread ↔ session handoff. The composer's measured /// bottom edge is the shared-element anchor in both directions; canvas, /// transcript, and height staging all share [`motion::NEW_THREAD_TRANSITION`]. @@ -835,6 +852,10 @@ fn new_thread_composer_offset(origin_bottom: f32, destination_bottom: f32, progr (origin_bottom - destination_bottom) * (1.0 - progress.clamp(0.0, 1.0)) } +fn canonical_new_thread_composer_bottom(painted_bottom: f32, transition_offset: f32) -> f32 { + painted_bottom - transition_offset +} + fn new_thread_transcript_settle(progress: f32) -> f32 { 8.0 * (1.0 - new_thread_transcript_opacity(progress)) } @@ -4451,7 +4472,15 @@ impl Shell { // ---- render pieces ---- fn begin_new_thread_launch(&mut self, cx: &mut Context) { - let origin_bottom = self.new_thread_composer_bottom.get(); + if self.new_thread_transition.is_some_and(|transition| { + transition.direction == NewThreadTransitionDirection::IntoThread + }) { + return; + } + let origin_bottom = self + .new_thread_transition + .map(new_thread_transition_bottom) + .unwrap_or_else(|| self.new_thread_composer_bottom.get()); let source_height = self.new_thread_composer_height.get(); let destination_bottom = self.viewport_height - self.eval_tween(self.terminal_tween, self.terminal_target(cx)); @@ -4470,7 +4499,15 @@ impl Shell { fn begin_new_thread_return(&mut self, cx: &mut Context) { let terminal_height = self.eval_tween(self.terminal_tween, self.terminal_target(cx)); - let origin_bottom = self.viewport_height - terminal_height; + if self.new_thread_transition.is_some_and(|transition| { + transition.direction == NewThreadTransitionDirection::IntoNewThread + }) { + return; + } + let origin_bottom = self + .new_thread_transition + .map(new_thread_transition_bottom) + .unwrap_or(self.viewport_height - terminal_height); let measured_height = self.new_thread_composer_height.get(); let destination_bottom = if self.new_thread_composer_bottom.get() > 0.0 { self.new_thread_composer_bottom.get() @@ -6965,6 +7002,7 @@ impl Shell { let no_project = self.state.read(cx).no_project; let new_thread_background_setting = settings::current(cx).new_thread_composer_background; let transition_frame = self.new_thread_transition_frame(); + let term_h = self.eval_tween(self.terminal_tween, self.terminal_target(cx)); let new_thread_background_layer = if !has_selection { Some(new_thread_background( new_thread_background_setting.as_ref(), @@ -6994,13 +7032,19 @@ impl Shell { ) }) }; - let term_h = self.eval_tween(self.terminal_tween, self.terminal_target(cx)); let transition_composer_offset = transition_frame.map(|(transition, progress)| { - new_thread_composer_offset( - transition.origin_bottom, - transition.destination_bottom, - progress, - ) + let destination_bottom = match transition.direction { + NewThreadTransitionDirection::IntoThread => self.viewport_height - term_h, + NewThreadTransitionDirection::IntoNewThread => { + let measured = self.new_thread_composer_bottom.get(); + if measured > 0.0 { + measured + } else { + transition.destination_bottom + } + } + }; + new_thread_composer_offset(transition.origin_bottom, destination_bottom, progress) }); // Content outlet: selected chat → transcript; nothing selected → the @@ -7080,17 +7124,15 @@ impl Shell { // vertically-centered controls composition. The composer lives // here only while the canvas is blank; established sessions keep // it in the bottom chrome stack below. + let transition_dy = transition_frame + .filter(|(transition, _)| { + transition.direction == NewThreadTransitionDirection::IntoNewThread + }) + .map_or(0.0, |_| transition_composer_offset.unwrap_or(0.0)); let composition = div() .w_full() .relative() - .top(px(NEW_THREAD_COMPOSITION_Y_CORRECTION - + transition_frame - .filter(|(transition, _)| { - transition.direction == NewThreadTransitionDirection::IntoNewThread - }) - .map_or(0.0, |_| { - transition_composer_offset.unwrap_or(0.0) - }))) + .top(px(NEW_THREAD_COMPOSITION_Y_CORRECTION + transition_dy)) .flex() .flex_col() .items_center() @@ -7103,7 +7145,14 @@ impl Shell { .child( gpui::canvas( move |bounds, _, _| { - bottom.set(f32::from(bounds.bottom())); + // Record the resting blank-canvas anchor, + // not this frame's animated translation. + // Otherwise every reverse frame moves its + // own destination and produces a wobble. + bottom.set(canonical_new_thread_composer_bottom( + f32::from(bounds.bottom()), + transition_dy, + )); height.set(f32::from(bounds.size.height)); }, |_, _, _, _| {}, @@ -7113,13 +7162,10 @@ impl Shell { ) .child(self.composer.clone()) }); - let composition: AnyElement = if transition_frame.is_some_and(|(transition, _)| { - transition.direction == NewThreadTransitionDirection::IntoNewThread - }) { - composition.into_any_element() - } else { - motion::settle_down("new-thread-composition", composition).into_any_element() - }; + // This is a persistent route surface, not an entrance. A keyed + // one-shot here replayed after reparents and competed with the + // shared-element transition, producing the final-frame flicker. + let composition: AnyElement = composition.into_any_element(); div() .size_full() .relative() @@ -9628,6 +9674,10 @@ mod tests { assert_eq!(new_thread_composer_offset(520.0, 840.0, 0.0), -320.0); assert_eq!(new_thread_composer_offset(520.0, 840.0, 0.5), -160.0); assert_eq!(new_thread_composer_offset(520.0, 840.0, 1.0), 0.0); + // Measuring while the reverse transition is translated must recover + // the same resting blank-canvas anchor on every frame. + assert_eq!(canonical_new_thread_composer_bottom(520.0, -320.0), 840.0); + assert_eq!(canonical_new_thread_composer_bottom(680.0, -160.0), 840.0); // The canvas leaves early on send and returns on the reverse path; // the transcript arrives just after motion begins and settles upward. assert_eq!( From ef466fa171fe04c0a01a2003a3f634cecf23a854 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 00:15:47 +0200 Subject: [PATCH 15/40] fix(ui): restore floating selectors and settle transcript layout --- crates/ui/src/composer.rs | 199 ++++++++++++++------------------------ crates/ui/src/pickers.rs | 88 +++++++---------- crates/ui/src/shell.rs | 68 +++++++++++-- 3 files changed, 166 insertions(+), 189 deletions(-) diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index f06dabfcc..f4a996e36 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -75,16 +75,9 @@ 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; -/// New-session controls live in two content-sized tabs emerging from opposite -/// composer corners. A 6px-radius control row sits inside 6px padding, so the -/// containing tab uses a concentric 12px radius. -const NEW_THREAD_TAB_CONTROL_HEIGHT: f32 = 24.0; -const NEW_THREAD_TAB_PADDING: f32 = 6.0; -const NEW_THREAD_TAB_RADIUS: f32 = crate::pickers::FOOTER_CHIP_RADIUS + NEW_THREAD_TAB_PADDING; -const NEW_THREAD_TAB_OVERLAP: f32 = QUEUE_COMPOSER_OVERLAP; -const NEW_THREAD_TAB_VISIBLE_HEIGHT: f32 = - NEW_THREAD_TAB_CONTROL_HEIGHT + 2.0 * NEW_THREAD_TAB_PADDING; -const NEW_THREAD_TAB_HEIGHT: f32 = NEW_THREAD_TAB_OVERLAP + NEW_THREAD_TAB_VISIBLE_HEIGHT; +/// 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; const SESSION_FOOTER_HEIGHT: f32 = 20.0; /// Route chrome dissolves around the middle of the shared-element move. The @@ -7759,7 +7752,7 @@ impl Render for Composer { }) }) .flatten(); - let has_new_thread_git_tab = self + let has_new_thread_git_selectors = self .state .read(cx) .selected_space_row() @@ -7780,113 +7773,77 @@ impl Render for Composer { // the file-mention and slash tokens are mutually exclusive. .children(self.render_file_mention_popup(&theme, cx)) .children(self.render_slash_popup(&theme, cx)); - let composer_stack = div() - .relative() - .when(new_thread_chrome > 0.0, |stack| { - stack - .pt(px(NEW_THREAD_TAB_VISIBLE_HEIGHT * new_thread_chrome)) - .child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .h(px(NEW_THREAD_TAB_HEIGHT - - NEW_THREAD_TAB_VISIBLE_HEIGHT - * (1.0 - new_thread_chrome))) - .px(px(QUEUE_SIDE_INSET)) - .flex() - .justify_end() - .child( - div() - .min_w_0() - .max_w_full() - .h_full() - .occlude() - .rounded_t(px(NEW_THREAD_TAB_RADIUS)) - .bg(theme.input_glass_bg()) - .border_1() - .border_color(theme.border) - .when(!theme.is_frost(), |el| el.shadow_lg()) - .overflow_hidden() - .opacity(new_thread_chrome_opacity) - .pt(px(NEW_THREAD_TAB_PADDING)) - .px(px(NEW_THREAD_TAB_PADDING)) - .pb(px(NEW_THREAD_TAB_OVERLAP + NEW_THREAD_TAB_PADDING)) - .children(new_thread_target_selectors), - ), - ) - }) - .when(has_new_thread_git_tab && new_thread_chrome > 0.0, |stack| { - stack.pb(px(NEW_THREAD_TAB_VISIBLE_HEIGHT * new_thread_chrome)) - }) - .when(has_new_thread_git_tab && new_thread_chrome > 0.0, |stack| { - stack.child( - div() - .absolute() - .bottom_0() - .left_0() - .right_0() - .h(px(NEW_THREAD_TAB_HEIGHT - - NEW_THREAD_TAB_VISIBLE_HEIGHT - * (1.0 - new_thread_chrome))) - .px(px(QUEUE_SIDE_INSET)) - .flex() - .child( - div() - .min_w_0() - .max_w_full() - .h_full() - .occlude() - .rounded_b(px(NEW_THREAD_TAB_RADIUS)) - .bg(theme.input_glass_bg()) - .border_1() - .border_color(theme.border) - .when(!theme.is_frost(), |el| el.shadow_lg()) - .overflow_hidden() - .opacity(new_thread_chrome_opacity) - .pt(px(NEW_THREAD_TAB_OVERLAP + NEW_THREAD_TAB_PADDING)) - .px(px(NEW_THREAD_TAB_PADDING)) - .pb(px(NEW_THREAD_TAB_PADDING)) - .children(new_thread_git_selectors), - ), - ) - }) - // Paint the composer after both tabs so their overlaps sit behind - // its clean silhouette in opaque and frosted themes. - .child(pill_surface); - let container = container.child(composer_stack); - // 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 container = if session_chrome_opacity > 0.0 { - let footer = self - .pickers - .update(cx, |pickers, cx| pickers.render_footer(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 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 { + 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; - let session_chrome = 1.0 - new_thread_chrome; container.child( div() - // A flex-column gap is inserted before this child. Cancel - // it at t=0, then hand it back while the 20px row reveals; - // the proportional -8px bottom bleed preserves the old - // steady-state 8px/8px optical padding. - .h(px(SESSION_FOOTER_HEIGHT * session_chrome)) - .mt(px(-Theme::SPACE_SM * (1.0 - session_chrome))) - .mb(px(-Theme::SPACE_SM * session_chrome)) - .overflow_hidden() - .opacity(session_chrome_opacity) - .child( - div() - .w_full() - .h(px(SESSION_FOOTER_HEIGHT)) - .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), - )), - ), + .w_full() + .h(px(NEW_THREAD_SELECTOR_ROW_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_start() + .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().pr(px(10.0)).mb(px(-8.0)).child( + crate::context_usage::render(usage, self.state.clone(), &theme), + )), + ) + }), ) } else { container @@ -9190,15 +9147,9 @@ mod tests { } #[test] - fn new_thread_tabs_are_compact_balanced_and_concentric() { - assert_eq!(NEW_THREAD_TAB_PADDING, 6.0); - assert_eq!(NEW_THREAD_TAB_CONTROL_HEIGHT, 24.0); - assert_eq!(NEW_THREAD_TAB_VISIBLE_HEIGHT, 36.0); - assert_eq!(NEW_THREAD_TAB_HEIGHT, NEW_THREAD_TAB_OVERLAP + 36.0); - assert_eq!( - NEW_THREAD_TAB_RADIUS, - crate::pickers::FOOTER_CHIP_RADIUS + NEW_THREAD_TAB_PADDING - ); + fn new_thread_selectors_restore_the_compact_floating_row() { + assert_eq!(NEW_THREAD_SELECTOR_ROW_HEIGHT, SESSION_FOOTER_HEIGHT); + assert_eq!(NEW_THREAD_SELECTOR_ROW_HEIGHT, 20.0); } #[test] diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index d00215431..aa7a76d2e 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -31,12 +31,7 @@ use zeron_rpc::methods; /// pagination plumbing). const MAX_REF_ROWS: usize = 300; -/// Icons, chevron, padding, and the 24px pointer-target floor still fit when -/// responsive new-thread selectors have yielded all label width. -const NEW_THREAD_SELECTOR_MIN_WIDTH: f32 = 52.0; -/// Inner radius of the compact selector chips. New-thread tab surfaces derive -/// their outer radius from this value plus their inset. -pub(crate) const FOOTER_CHIP_RADIUS: f32 = 6.0; +const FOOTER_CHIP_RADIUS: f32 = 6.0; use crate::composer::{ComposerInput, ComposerInputEvent}; use crate::motion; @@ -2417,8 +2412,8 @@ impl Pickers { .child(div().min_w_0().truncate().child(label)) } - /// New-session destination controls. Machine and project share the - /// trailing tab which emerges above the composer. + /// 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(); @@ -2461,25 +2456,17 @@ impl Pickers { &theme, cx, ) - .h(px(24.0)) - .min_w(px(NEW_THREAD_SELECTOR_MIN_WIDTH)) - .flex_shrink(1.0) .when(offline, |el| el.text_color(theme.warning.opacity(0.8))); - let project_chip = self - .footer_chip( - PickerKind::Space, - "picker-project", - crate::icons::FOLDER, - project_label, - &theme, - cx, - ) - .h(px(24.0)) - .min_w(px(NEW_THREAD_SELECTOR_MIN_WIDTH)) - .flex_shrink(1.0); + let project_chip = self.footer_chip( + PickerKind::Space, + "picker-project", + crate::icons::FOLDER, + project_label, + &theme, + cx, + ); div() - .min_w_0() - .max_w_full() + .flex_none() .flex() .flex_row() .items_center() @@ -2501,8 +2488,8 @@ impl Pickers { .into_any_element() } - /// New-session Git controls. Checkout mode and branch share the leading - /// tab which emerges below the composer. Non-Git projects omit it. + /// 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, @@ -2533,34 +2520,25 @@ impl Pickers { (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, - ) - .h(px(24.0)) - .min_w(px(NEW_THREAD_SELECTOR_MIN_WIDTH)) - .flex_shrink(1.0); - let branch_chip = self - .footer_chip( - PickerKind::Branch, - "picker-branch", - crate::icons::GIT_BRANCH, - self.ref_label(), - &theme, - cx, - ) - .h(px(24.0)) - .min_w(px(NEW_THREAD_SELECTOR_MIN_WIDTH)) - .flex_shrink(1.0); + 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( div() - .min_w_0() - .max_w_full() + .flex_none() .flex() .flex_row() .items_center() @@ -2585,7 +2563,7 @@ impl Pickers { /// The composer footer row: checkout-kind + ref, LEFT-aligned, only when /// the picked (or session's) project has git. New sessions use the floating - /// selector tabs; sessions name their target in the titlebar. + /// 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 @@ -2693,7 +2671,7 @@ impl Pickers { let content = self.render_checkout_popover(cx); Some((PickerKind::Checkout, self.popover_frame(224.0, content, cx))) } - // Space/Device popovers mount in the new-thread selector tab. + // Space/Device popovers mount in the floating row above the pill. _ => None, }; diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 2a57396ae..bc59e7b06 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -856,6 +856,13 @@ fn canonical_new_thread_composer_bottom(painted_bottom: f32, transition_offset: painted_bottom - transition_offset } +fn bottom_stack_measurement_matches( + measured_has_composer: bool, + expected_has_composer: bool, +) -> bool { + measured_has_composer == expected_has_composer +} + fn new_thread_transcript_settle(progress: f32) -> f32 { 8.0 * (1.0 - new_thread_transcript_opacity(progress)) } @@ -1310,11 +1317,13 @@ 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>, /// Last painted bounds of the centered new-thread composer. The first-send /// transition uses its bottom edge as the FLIP source anchor. new_thread_composer_bottom: std::rc::Rc>, @@ -1710,6 +1719,7 @@ 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)), new_thread_composer_bottom: std::rc::Rc::new(std::cell::Cell::new(0.0)), new_thread_composer_height: std::rc::Rc::new(std::cell::Cell::new(0.0)), new_thread_transition: None, @@ -6999,6 +7009,10 @@ impl Shell { let has_selection = self.state.read(cx).selected_chat.is_some(); let has_spaces = !self.state.read(cx).spaces.is_empty(); let has_appshots = !self.composer.read(cx).staged_appshots().is_empty(); + let transcript_geometry_ready = bottom_stack_measurement_matches( + self.bottom_stack_has_composer.get(), + (has_spaces || has_appshots) && has_selection, + ); let no_project = self.state.read(cx).no_project; let new_thread_background_setting = settings::current(cx).new_thread_composer_background; let transition_frame = self.new_thread_transition_frame(); @@ -7062,14 +7076,23 @@ impl Shell { .relative() .top(px(new_thread_transcript_settle(progress))) .size_full() - .opacity(new_thread_transcript_opacity(progress)) + .opacity(if transcript_geometry_ready { + new_thread_transcript_opacity(progress) + } else { + 0.0 + }) .child(self.transcript.clone()), ) .into_any_element() } else { - self.transcript - .clone() - .cached(gpui::StyleRefinement::default().size_full()) + div() + .size_full() + .opacity(if transcript_geometry_ready { 1.0 } else { 0.0 }) + .child( + self.transcript + .clone() + .cached(gpui::StyleRefinement::default().size_full()), + ) .into_any_element() } } else if !has_spaces && !no_project { @@ -7306,6 +7329,8 @@ 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 && has_selection; div() .flex_none() .relative() @@ -7313,7 +7338,16 @@ impl Shell { .flex_col() .child( gpui::canvas( - move |bounds, _, _| measured.set(f32::from(bounds.size.height)), + move |bounds, window, _| { + let next_height = f32::from(bounds.size.height); + 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() @@ -9419,9 +9453,19 @@ impl Render for Shell { // `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.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 { + t.set_bottom_clearance(stack_h, cx); + } }); let sidebar = self.render_sidebar(cx); @@ -9657,6 +9701,10 @@ mod tests { #[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), From b84a09345d4ac5b4b82fbd20608fa3cb5bafc17e Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 00:29:06 +0200 Subject: [PATCH 16/40] feat(ui): add new thread background treatments --- crates/ui/src/lib.rs | 1 + .../ui/src/new_thread_background_effects.rs | 308 ++++++++++++++++++ crates/ui/src/settings.rs | 58 ++++ crates/ui/src/settings/appearance.rs | 86 ++++- crates/ui/src/shell.rs | 34 +- 5 files changed, 477 insertions(+), 10 deletions(-) create mode 100644 crates/ui/src/new_thread_background_effects.rs diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 30726385b..8247d1757 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -35,6 +35,7 @@ pub mod links; pub mod loaders; pub mod markdown; pub mod motion; +mod new_thread_background_effects; pub mod notify; pub mod pickers; pub mod popover; 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..c91b24dfe --- /dev/null +++ b/crates/ui/src/new_thread_background_effects.rs @@ -0,0 +1,308 @@ +//! Non-destructive treatments for the optional new-thread hero artwork. + +use std::path::{Path, PathBuf}; + +use gpui::{ + AnyElement, BorderStyle, Empty, IntoElement, Pixels, SharedString, TextRun, div, prelude::*, px, +}; + +use crate::settings::NewThreadBackgroundEffect; +use crate::theme::{Appearance, Theme}; + +#[derive(Debug)] +struct BackgroundLuminance { + width: u32, + height: u32, + pixels: Box<[u8]>, +} + +impl BackgroundLuminance { + fn sample_cover(&self, bounds: gpui::Size, x: f32, y: f32) -> u8 { + 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; + self.pixels[(source_y * self.width + source_x) as usize] + } +} + +fn needs_luminance(effect: NewThreadBackgroundEffect) -> bool { + matches!( + effect, + NewThreadBackgroundEffect::Dither + | NewThreadBackgroundEffect::Ascii + | NewThreadBackgroundEffect::Halftone + ) +} + +fn background_luminance(path: &Path) -> Option> { + type Cache = Vec<(PathBuf, std::sync::Arc)>; + static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); + let cache = CACHE.get_or_init(|| std::sync::Mutex::new(Vec::new())); + if let Some(sample) = cache + .lock() + .ok()? + .iter() + .find_map(|(cached, sample)| (cached == path).then(|| sample.clone())) + { + return Some(sample); + } + + // The hero never exceeds 440px high. Keeping a 2048px luminance proxy + // preserves more than enough detail while bounding retained memory. + let decoded = image::ImageReader::open(path).ok()?.decode().ok()?; + let gray = decoded.thumbnail(2048, 2048).to_luma8(); + let sample = std::sync::Arc::new(BackgroundLuminance { + width: gray.width(), + height: gray.height(), + pixels: gray.into_raw().into_boxed_slice(), + }); + let mut cache = cache.lock().ok()?; + cache.push((path.to_path_buf(), sample.clone())); + if cache.len() > 4 { + cache.remove(0); + } + Some(sample) +} + +/// Returns the image opacity and optional texture layer as one resolved +/// treatment. Unsupported raster decoding falls back to the original image. +pub(super) fn treatment( + requested: NewThreadBackgroundEffect, + theme: &Theme, + path: &Path, + base_opacity: f32, +) -> (f32, AnyElement) { + let luminance = needs_luminance(requested) + .then(|| background_luminance(path)) + .flatten(); + let effect = if needs_luminance(requested) && luminance.is_none() { + NewThreadBackgroundEffect::None + } else { + requested + }; + let image_opacity = base_opacity + * match effect { + NewThreadBackgroundEffect::None => 1.0, + NewThreadBackgroundEffect::Dither => 0.94, + NewThreadBackgroundEffect::Ascii => 0.78, + NewThreadBackgroundEffect::Halftone => 0.90, + NewThreadBackgroundEffect::Scanlines => 0.96, + }; + if effect == NewThreadBackgroundEffect::None { + return (image_opacity, Empty.into_any_element()); + } + + let color = theme.text.opacity(match effect { + NewThreadBackgroundEffect::Dither => 0.13, + NewThreadBackgroundEffect::Ascii => 0.26, + NewThreadBackgroundEffect::Halftone => 0.15, + NewThreadBackgroundEffect::Scanlines => 0.11, + NewThreadBackgroundEffect::None => 0.0, + }); + let light = matches!(theme.appearance, Appearance::Light); + let ascii_font = theme.font_mono.clone(); + let prepaint_luminance = luminance.clone(); + let texture = gpui::canvas( + move |bounds, window, _| { + if effect != NewThreadBackgroundEffect::Ascii { + return Vec::new(); + } + let Some(luminance) = prepaint_luminance.as_ref() else { + return Vec::new(); + }; + let columns = (f32::from(bounds.size.width) / 7.0).ceil() as usize + 1; + let rows = (f32::from(bounds.size.height) / 9.0).ceil() as usize; + let ramp = b" .:-=+*#%@"; + (0..rows) + .map(|row| { + let mut text = String::with_capacity(columns); + for column in 0..columns { + let luma = luminance.sample_cover( + bounds.size, + column as f32 * 7.0, + row as f32 * 9.0, + ); + let ink = if light { 255 - luma } else { luma }; + let index = ink as usize * (ramp.len() - 1) / 255; + text.push(ramp[index] as char); + } + let text: SharedString = text.into(); + let run = TextRun { + len: text.len(), + font: gpui::font(ascii_font.clone()), + color, + background_color: None, + underline: None, + strikethrough: None, + }; + window.text_system().shape_line(text, px(7.0), &[run], None) + }) + .collect::>() + }, + move |bounds, ascii_lines, window, cx| match effect { + NewThreadBackgroundEffect::None => {} + NewThreadBackgroundEffect::Dither => { + const BAYER: [[u8; 4]; 4] = + [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]; + let step = 7.0; + let columns = (f32::from(bounds.size.width) / step).ceil() as usize; + let rows = (f32::from(bounds.size.height) / step).ceil() as usize; + for row in 0..rows { + for column in 0..columns { + let threshold = BAYER[row % 4][column % 4]; + let Some(luminance) = luminance.as_ref() else { + continue; + }; + let luma = luminance.sample_cover( + bounds.size, + column as f32 * step, + row as f32 * step, + ); + let ink = if light { 255 - luma } else { luma }; + if ink / 16 <= threshold { + continue; + } + let dot = if threshold < 3 { 1.8 } else { 1.0 }; + paint_dot( + window, + bounds, + column as f32 * step, + row as f32 * step, + dot, + color, + ); + } + } + } + NewThreadBackgroundEffect::Ascii => { + let line_height = px(9.0); + for (row, line) in ascii_lines.iter().enumerate() { + let _ = line.paint( + gpui::point(bounds.left(), bounds.top() + line_height * row as f32), + line_height, + gpui::TextAlign::Left, + Some(bounds.size.width), + window, + cx, + ); + } + } + NewThreadBackgroundEffect::Halftone => { + let step = 12.0; + let columns = (f32::from(bounds.size.width) / step).ceil() as usize; + let rows = (f32::from(bounds.size.height) / step).ceil() as usize; + for row in 0..rows { + for column in 0..columns { + let Some(luminance) = luminance.as_ref() else { + continue; + }; + let luma = luminance.sample_cover( + bounds.size, + column as f32 * step, + row as f32 * step, + ); + let ink = if light { 255 - luma } else { luma }; + let dot = 0.8 + ink as f32 / 255.0 * 4.2; + paint_dot( + window, + bounds, + column as f32 * step, + row as f32 * step, + dot, + color, + ); + } + } + } + NewThreadBackgroundEffect::Scanlines => { + let rows = (f32::from(bounds.size.height) / 5.0).ceil() as usize; + for row in 0..rows { + window.paint_quad(gpui::quad( + gpui::Bounds::new( + gpui::point(bounds.left(), bounds.top() + px(row as f32 * 5.0)), + gpui::size(bounds.size.width, px(1.0)), + ), + px(0.0), + color, + px(0.0), + gpui::transparent_black(), + BorderStyle::default(), + )); + } + } + }, + ) + .absolute() + .inset_0(); + + let layer = div() + .absolute() + .inset_0() + .when( + matches!( + effect, + NewThreadBackgroundEffect::Dither + | NewThreadBackgroundEffect::Ascii + | NewThreadBackgroundEffect::Halftone + ), + |layer| layer.bg(theme.bg.opacity(0.18)), + ) + .child(texture) + .into_any_element(); + (image_opacity, layer) +} + +fn paint_dot( + window: &mut gpui::Window, + bounds: gpui::Bounds, + x: f32, + y: f32, + diameter: f32, + color: gpui::Hsla, +) { + window.paint_quad(gpui::quad( + gpui::Bounds::new( + gpui::point(bounds.left() + px(x), bounds.top() + px(y)), + gpui::size(px(diameter), px(diameter)), + ), + px(diameter / 2.0), + color, + px(0.0), + gpui::transparent_black(), + BorderStyle::default(), + )); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cover_sampling_crops_the_long_axis_from_the_center() { + let sample = BackgroundLuminance { + width: 4, + height: 2, + pixels: vec![0, 1, 2, 3, 10, 11, 12, 13].into_boxed_slice(), + }; + let square = gpui::size(px(100.0), px(100.0)); + assert_eq!(sample.sample_cover(square, 0.0, 0.0), 1); + assert_eq!(sample.sample_cover(square, 99.0, 99.0), 12); + } + + #[test] + fn adaptive_effects_are_the_only_ones_that_need_pixels() { + assert!(!needs_luminance(NewThreadBackgroundEffect::None)); + assert!(needs_luminance(NewThreadBackgroundEffect::Dither)); + assert!(needs_luminance(NewThreadBackgroundEffect::Ascii)); + assert!(needs_luminance(NewThreadBackgroundEffect::Halftone)); + assert!(!needs_luminance(NewThreadBackgroundEffect::Scanlines)); + } +} diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index 5acf2dae7..d94779621 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -68,6 +68,47 @@ pub struct NewThreadComposerBackground { 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 => "Adds a fine ordered-dot texture.", + Self::Ascii => "Layers a quiet monospaced glyph field.", + Self::Halftone => "Adds a larger print-style dot screen.", + Self::Scanlines => "Adds subtle horizontal display lines.", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct GitHistoryColumns { @@ -315,6 +356,14 @@ pub fn remove_new_thread_composer_background(cx: &mut App) -> Result<(), String> 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, @@ -563,6 +612,8 @@ pub struct UiSettings { /// 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)] @@ -620,6 +671,7 @@ impl Default for UiSettings { 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, } } @@ -1235,6 +1287,10 @@ 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 [ @@ -1444,6 +1500,7 @@ mod tests { 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(); @@ -1451,6 +1508,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] diff --git a/crates/ui/src/settings/appearance.rs b/crates/ui/src/settings/appearance.rs index 62d69696b..48b87efff 100644 --- a/crates/ui/src/settings/appearance.rs +++ b/crates/ui/src/settings/appearance.rs @@ -581,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, @@ -1950,7 +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 current_background = crate::settings::current(cx).new_thread_composer_background; + 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| { @@ -2188,6 +2230,48 @@ impl Render for AppearancePage { ) .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() diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index bc59e7b06..6464190e1 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -887,6 +887,7 @@ fn new_thread_background_side_fade(viewport_width: f32) -> f32 { fn new_thread_background( background: Option<&settings::NewThreadComposerBackground>, + effect: settings::NewThreadBackgroundEffect, theme: &Theme, viewport_width: f32, viewport_height: f32, @@ -901,6 +902,12 @@ fn new_thread_background( } let hero_height = new_thread_background_height(viewport_height); let side_fade = new_thread_background_side_fade(viewport_width); + let (image_opacity, effect_layer) = crate::new_thread_background_effects::treatment( + effect, + theme, + &path, + new_thread_background_opacity(theme.is_frost()), + ); div() .absolute() .top_0() @@ -921,14 +928,19 @@ fn new_thread_background( // Give the mask a definite relayout box. A percentage-sized // image as the custom element's direct child could briefly // resolve to zero during live window resize. - div().relative().w_full().h(px(hero_height)).child( - img(path) - .absolute() - .inset_0() - .size_full() - .object_fit(ObjectFit::Cover) - .opacity(new_thread_background_opacity(theme.is_frost())), - ), + div() + .relative() + .w_full() + .h(px(hero_height)) + .child( + img(path) + .absolute() + .inset_0() + .size_full() + .object_fit(ObjectFit::Cover) + .opacity(image_opacity), + ) + .child(effect_layer), ) .band_bottom(hero_height * NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO) .fade_left(true) @@ -7014,12 +7026,15 @@ impl Shell { (has_spaces || has_appshots) && has_selection, ); let no_project = self.state.read(cx).no_project; - let new_thread_background_setting = settings::current(cx).new_thread_composer_background; + 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 transition_frame = self.new_thread_transition_frame(); let term_h = self.eval_tween(self.terminal_tween, self.terminal_target(cx)); let new_thread_background_layer = if !has_selection { Some(new_thread_background( new_thread_background_setting.as_ref(), + new_thread_background_effect, theme, main_content_width, self.viewport_height, @@ -7039,6 +7054,7 @@ impl Shell { .map(|(transition, progress)| { new_thread_background( new_thread_background_setting.as_ref(), + new_thread_background_effect, theme, main_content_width, self.viewport_height, From 3c7ec81226f5d177642b16fe32b708429cc41fab Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 00:53:50 +0200 Subject: [PATCH 17/40] feat(ui): choreograph persistent composer dock and masked dissolve --- crates/ui/src/composer.rs | 189 +++++-- crates/ui/src/composer_dock.rs | 513 ++++++++++++++++++ crates/ui/src/edge_fade.rs | 12 + crates/ui/src/lib.rs | 1 + .../ui/src/new_thread_background_effects.rs | 46 ++ crates/ui/src/shell.rs | 439 ++++----------- crates/ui/src/transcript.rs | 70 ++- 7 files changed, 891 insertions(+), 379 deletions(-) create mode 100644 crates/ui/src/composer_dock.rs diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index f4a996e36..e37038f69 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -4114,6 +4114,8 @@ 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, last_target_height: f32, height_morph: Option, /// Monotonic clock anchor for the morph timeline. @@ -4130,6 +4132,26 @@ 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 + } + /// The picker entity, for the shell's canvas target selectors. pub fn pickers(&self) -> &Entity { &self.pickers @@ -4280,6 +4302,8 @@ impl Composer { settle_task: None, flip_morph: None, last_rendered_height: 0.0, + dock_frame: None, + dock_clearance_correction: 0.0, last_target_height: 0.0, height_morph: None, morph_clock: Instant::now(), @@ -7252,14 +7276,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 @@ -7438,10 +7466,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 @@ -7457,7 +7491,9 @@ 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 @@ -7471,16 +7507,25 @@ impl Render for Composer { // 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 = 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 = 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()), ); - let (new_thread_chrome_opacity, session_chrome_opacity) = - route_chrome_opacities(new_thread_chrome); - self.height_morph = if coordinated_route_morph.is_some() { + 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( @@ -7512,14 +7557,18 @@ impl Render for Composer { window.request_animation_frame(); } self.last_rendered_height = pill_height; - let text_pt = morph_text_pad(morph_t); - let textarea_height = (pill_height - - strip_h - - appshot_strip_height(appshot_count) - - comment_strip_h - - PILL_BORDER_V - - ACTIONS_ROW_HEIGHT) - .max(0.0); + 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) - comment_strip_h - PILL_BORDER_V - ACTIONS_ROW_HEIGHT).max(0.0); self.input.update(cx, |input, cx| { let height = if expanded { (textarea_height - text_pt - 4.0).max(0.0) @@ -7604,7 +7653,7 @@ impl Render for Composer { } }), ) - .rounded(px(COMPOSER_RADIUS)) + .rounded(px(surface_radius)) .bg(pill_bg) .border_1() .border_color(theme.border) @@ -7625,7 +7674,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() @@ -7659,9 +7707,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() @@ -7762,13 +7814,12 @@ impl Render for Composer { // via `add_paths`. // Frosted: the pill backdrop-blurs the transcript scrolling under it // (the popover glass treatment; radius matches the pill's rounding). - // This entity is deliberately not wrapped in its own entrance fade. - // The shell reparents it between the blank canvas and transcript; a - // keyed opacity animation would remount and flash independently of - // the coordinated shared-element transition. + // 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() - .child(crate::frost::frosted(COMPOSER_RADIUS, 16.0, body)) + .id("composer-surface") + .child(crate::frost::frosted(surface_radius, 16.0, 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)) @@ -7776,7 +7827,23 @@ impl Render for Composer { // 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 new_thread_chrome > 0.0 { + 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() @@ -7798,7 +7865,7 @@ impl Render for Composer { // 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 { + let bottom_slot = if has_new_thread_git_selectors || self.dock_frame.is_some() { 1.0 } else { session_chrome @@ -7906,6 +7973,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); diff --git a/crates/ui/src/composer_dock.rs b/crates/ui/src/composer_dock.rs new file mode 100644 index 000000000..88b933eae --- /dev/null +++ b/crates/ui/src/composer_dock.rs @@ -0,0 +1,513 @@ +//! 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}; + +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), + } + } + } +} + +pub(crate) struct DockState { + 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)>, +} + +impl Default for DockState { + fn default() -> Self { + Self { + 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, + } + } +} + +impl DockState { + pub fn painted_top(&self) -> Option { + self.position.map(|(_, y)| y.value) + } + + 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 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; + 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 time = now.saturating_duration_since(started).as_secs_f32() / duration(docked); + if time >= 1.0 { + self.choreography = None; + } + from.advance(docked, time) + } else { + Visuals::settled(docked) + }; + 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 - 16.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 position = state.position.get_or_insert((Glide::new(x), Glide::new(y))); + 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::*}; + + #[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/edge_fade.rs b/crates/ui/src/edge_fade.rs index 8be7e8a49..f69b04074 100644 --- a/crates/ui/src/edge_fade.rs +++ b/crates/ui/src/edge_fade.rs @@ -21,6 +21,7 @@ pub fn edge_faded(band: f32, top: bool, bottom: bool, child: impl IntoElement) - band_bottom: None, inset_top: 0.0, outset_bottom: 0.0, + inset_x: 0.0, top, bottom, left: false, @@ -38,6 +39,7 @@ pub struct EdgeFaded { band_bottom: Option, inset_top: f32, outset_bottom: f32, + inset_x: f32, top: bool, bottom: bool, left: bool, @@ -116,6 +118,12 @@ impl EdgeFaded { self.outset_bottom = px; self } + + /// Inset the alpha mask without relaying out or cropping the artwork. + pub fn inset_x(mut self, px: f32) -> Self { + self.inset_x = px.max(0.0); + self + } } impl Element for EdgeFaded { @@ -187,6 +195,10 @@ 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)); + let inset_x = px(self.inset_x).min(bounds.size.width * 0.5); + bounds.origin.x += inset_x; + bounds.size.width -= inset_x * 2.0; EdgeFade { bounds, band: px(self.band), diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 8247d1757..ed2378bd0 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; diff --git a/crates/ui/src/new_thread_background_effects.rs b/crates/ui/src/new_thread_background_effects.rs index c91b24dfe..80d22e9dc 100644 --- a/crates/ui/src/new_thread_background_effects.rs +++ b/crates/ui/src/new_thread_background_effects.rs @@ -281,6 +281,52 @@ fn paint_dot( )); } +/// Deterministic dissolve grain. It requires no image decode on the first +/// navigation frame, and its cells evaporate instead of re-randomizing each +/// frame. The caller scopes this texture to the artwork's contracting mask. +pub(super) fn dissolve_grain(theme: &Theme, progress: f32) -> AnyElement { + let color = theme.text; + gpui::canvas( + |_, _, _| (), + move |bounds, _, window, _| { + const BAYER: [[u8; 4]; 4] = + [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]; + let width = f32::from(bounds.size.width); + let height = f32::from(bounds.size.height); + let step = 6.0; + let left = (width * 0.38 * progress / step).floor() as usize; + let right = ((width - width * 0.38 * progress) / step).ceil() as usize; + let top = (height * 0.82 * progress / step).floor() as usize; + let bottom = (height / step).ceil() as usize; + for row in top..bottom { + for column in left..right { + let threshold = (BAYER[row % 4][column % 4] as f32 + 1.0) / 17.0; + let alpha = 1.0 + - crate::composer_dock::stage( + progress, + threshold * 0.5, + 0.5 + threshold * 0.5, + ); + if alpha < 0.01 { + continue; + } + paint_dot( + window, + bounds, + column as f32 * step, + row as f32 * step, + 1.0 + alpha, + color.opacity(0.3 * alpha), + ); + } + } + }, + ) + .absolute() + .inset_0() + .into_any_element() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 6464190e1..ff2de5c63 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -696,8 +696,6 @@ const NEW_THREAD_BACKGROUND_MAX_HEIGHT: f32 = 440.0; const NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO: f32 = 0.62; const NEW_THREAD_BACKGROUND_SIDE_FADE: f32 = 72.0; const NEW_THREAD_BACKGROUND_SIDE_FADE_RATIO: f32 = 0.18; -/// The reference composition sits just above the canvas midpoint. -const NEW_THREAD_COMPOSITION_Y_CORRECTION: f32 = -16.0; /// Drag marker for the sidebar resize handle. struct SidebarResize; @@ -803,59 +801,6 @@ impl WidthTween { } } -fn new_thread_transition_progress(transition: NewThreadTransition) -> f32 { - let total = motion::NEW_THREAD_TRANSITION - .total() - .mul_f32(motion::speed_scale()); - let raw = transition.started.elapsed().as_secs_f32() / total.as_secs_f32(); - motion::NEW_THREAD_TRANSITION.progress(raw.clamp(0.0, 1.0)) -} - -fn new_thread_transition_bottom(transition: NewThreadTransition) -> f32 { - transition.destination_bottom - + new_thread_composer_offset( - transition.origin_bottom, - transition.destination_bottom, - new_thread_transition_progress(transition), - ) -} - -/// One reversible new-thread ↔ session handoff. The composer's measured -/// bottom edge is the shared-element anchor in both directions; canvas, -/// transcript, and height staging all share [`motion::NEW_THREAD_TRANSITION`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum NewThreadTransitionDirection { - IntoThread, - IntoNewThread, -} - -#[derive(Debug, Clone, Copy)] -struct NewThreadTransition { - direction: NewThreadTransitionDirection, - origin_bottom: f32, - destination_bottom: f32, - started: std::time::Instant, -} - -fn new_thread_transcript_opacity(progress: f32) -> f32 { - ((progress - 0.14) / 0.72).clamp(0.0, 1.0) -} - -fn new_thread_canvas_opacity(direction: NewThreadTransitionDirection, progress: f32) -> f32 { - match direction { - NewThreadTransitionDirection::IntoThread => (1.0 - progress / 0.68).clamp(0.0, 1.0), - NewThreadTransitionDirection::IntoNewThread => ((progress - 0.06) / 0.78).clamp(0.0, 1.0), - } -} - -fn new_thread_composer_offset(origin_bottom: f32, destination_bottom: f32, progress: f32) -> f32 { - (origin_bottom - destination_bottom) * (1.0 - progress.clamp(0.0, 1.0)) -} - -fn canonical_new_thread_composer_bottom(painted_bottom: f32, transition_offset: f32) -> f32 { - painted_bottom - transition_offset -} - fn bottom_stack_measurement_matches( measured_has_composer: bool, expected_has_composer: bool, @@ -863,10 +808,6 @@ fn bottom_stack_measurement_matches( measured_has_composer == expected_has_composer } -fn new_thread_transcript_settle(progress: f32) -> f32 { - 8.0 * (1.0 - new_thread_transcript_opacity(progress)) -} - fn new_thread_background_opacity(is_frost: bool) -> f32 { if is_frost { NEW_THREAD_BACKGROUND_FROSTED_OPACITY @@ -891,7 +832,8 @@ fn new_thread_background( theme: &Theme, viewport_width: f32, viewport_height: f32, - transition_opacity: f32, + dissolve: f32, + composer_top: f32, ) -> AnyElement { let Some(background) = background else { return Empty.into_any_element(); @@ -902,20 +844,32 @@ fn new_thread_background( } let hero_height = new_thread_background_height(viewport_height); let side_fade = new_thread_background_side_fade(viewport_width); + let dissolve = dissolve.clamp(0.0, 1.0); + let grain_strength = (std::f32::consts::PI * dissolve).sin().max(0.0); let (image_opacity, effect_layer) = crate::new_thread_background_effects::treatment( effect, theme, &path, new_thread_background_opacity(theme.is_frost()), ); + // Dither emerges only inside the shrinking alpha mask. Keep the original + // cover geometry fixed so the artwork never zooms or re-crops as it leaves. + let dissolve_layer = (grain_strength > 0.001).then(|| { + let grain = crate::new_thread_background_effects::dissolve_grain(theme, dissolve); + div() + .absolute() + .inset_0() + .opacity(grain_strength) + .child(grain) + }); div() .absolute() - .top_0() + .top(px((composer_top - hero_height * 0.88).max(0.0) * dissolve)) .left_0() .right_0() .h(px(hero_height)) .overflow_hidden() - .opacity(transition_opacity.clamp(0.0, 1.0)) + .opacity(1.0 - crate::composer_dock::stage(dissolve, 0.82, 1.0)) // Fade the image primitive itself instead of painting a theme-colored // gradient above it. That creates a real alpha mask, so the tail // resolves into the exact canvas beneath it on both opaque and glass @@ -923,7 +877,7 @@ fn new_thread_background( .child( crate::edge_fade::edge_faded( side_fade, - false, + dissolve > 0.0, true, // Give the mask a definite relayout box. A percentage-sized // image as the custom element's direct child could briefly @@ -938,11 +892,16 @@ fn new_thread_background( .inset_0() .size_full() .object_fit(ObjectFit::Cover) - .opacity(image_opacity), + .opacity(image_opacity * (1.0 - 0.65 * dissolve)), ) - .child(effect_layer), + .child(effect_layer) + .children(dissolve_layer), ) .band_bottom(hero_height * NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO) + .inset_top(hero_height * 0.82 * dissolve) + .band_top(hero_height * 0.16 * dissolve) + .outset_bottom(-hero_height * 0.06 * dissolve) + .inset_x(viewport_width * 0.38 * dissolve) .fade_left(true) .fade_right(true), ) @@ -1336,11 +1295,8 @@ pub struct Shell { /// 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>, - /// Last painted bounds of the centered new-thread composer. The first-send - /// transition uses its bottom edge as the FLIP source anchor. - new_thread_composer_bottom: std::rc::Rc>, - new_thread_composer_height: std::rc::Rc>, - new_thread_transition: Option, + /// Shared route clock and measured prepaint geometry for the persistent composer. + composer_dock: crate::composer_dock::SharedDock, /// 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). @@ -1585,6 +1541,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, _| { @@ -1602,9 +1559,10 @@ impl Shell { // reply's space below it (notes-app parity). let composer_events = cx.subscribe(&composer, { let transcript = transcript.clone(); - move |this: &mut Shell, _, event: &ComposerEvent, cx| match event { + move |_this: &mut Shell, _, event: &ComposerEvent, cx| match event { ComposerEvent::NewThreadTransitionStarted => { - this.begin_new_thread_launch(cx); + // Route observation drives the dock once selection commits. + cx.notify(); } ComposerEvent::Sent { chat_id, @@ -1732,9 +1690,7 @@ impl Shell { // 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)), - new_thread_composer_bottom: std::rc::Rc::new(std::cell::Cell::new(0.0)), - new_thread_composer_height: std::rc::Rc::new(std::cell::Cell::new(0.0)), - new_thread_transition: None, + composer_dock: Default::default(), archived_open: true, archived_shown: 0, archived_hover: None, @@ -2167,14 +2123,6 @@ impl Shell { } if selected != self.active_chat { self.suspend_file_images(cx); - if !self.active_chat.is_empty() - && selected.is_empty() - && matches!(self.route, Route::Chat) - { - // Capture the established composer's bottom anchor before - // `active_chat` changes the panel key and terminal geometry. - self.begin_new_thread_return(cx); - } self.active_chat = selected; // Route history: a chat switch is a navigation. The very first // selection off the untouched boot canvas REPLACES that entry — @@ -4493,87 +4441,6 @@ impl Shell { // ---- render pieces ---- - fn begin_new_thread_launch(&mut self, cx: &mut Context) { - if self.new_thread_transition.is_some_and(|transition| { - transition.direction == NewThreadTransitionDirection::IntoThread - }) { - return; - } - let origin_bottom = self - .new_thread_transition - .map(new_thread_transition_bottom) - .unwrap_or_else(|| self.new_thread_composer_bottom.get()); - let source_height = self.new_thread_composer_height.get(); - let destination_bottom = - self.viewport_height - self.eval_tween(self.terminal_tween, self.terminal_target(cx)); - if motion::reduced_motion(cx) || origin_bottom <= 0.0 || source_height <= 0.0 { - self.new_thread_transition = None; - return; - } - self.new_thread_transition = Some(NewThreadTransition { - direction: NewThreadTransitionDirection::IntoThread, - origin_bottom, - destination_bottom, - started: std::time::Instant::now(), - }); - cx.notify(); - } - - fn begin_new_thread_return(&mut self, cx: &mut Context) { - let terminal_height = self.eval_tween(self.terminal_tween, self.terminal_target(cx)); - if self.new_thread_transition.is_some_and(|transition| { - transition.direction == NewThreadTransitionDirection::IntoNewThread - }) { - return; - } - let origin_bottom = self - .new_thread_transition - .map(new_thread_transition_bottom) - .unwrap_or(self.viewport_height - terminal_height); - let measured_height = self.new_thread_composer_height.get(); - let destination_bottom = if self.new_thread_composer_bottom.get() > 0.0 { - self.new_thread_composer_bottom.get() - } else { - // Boot may land directly in a session before the blank canvas has - // ever painted. Use its centered geometry as a safe first-return - // target; the canvas measure replaces this for later transitions. - self.viewport_height * 0.5 - + measured_height.max(crate::composer::COMPOSER_MIN_HEIGHT) * 0.5 - + NEW_THREAD_COMPOSITION_Y_CORRECTION - }; - if motion::reduced_motion(cx) || origin_bottom <= 0.0 || destination_bottom <= 0.0 { - self.new_thread_transition = None; - return; - } - self.new_thread_transition = Some(NewThreadTransition { - direction: NewThreadTransitionDirection::IntoNewThread, - origin_bottom, - destination_bottom, - started: std::time::Instant::now(), - }); - cx.notify(); - } - - /// Current coordinated route frame. Manual evaluation avoids a remount - /// replay when the composer moves between its two parents. - fn new_thread_transition_frame(&mut self) -> Option<(NewThreadTransition, f32)> { - let transition = self.new_thread_transition?; - if self.reduced_motion { - self.new_thread_transition = None; - return None; - } - let total = motion::NEW_THREAD_TRANSITION - .total() - .mul_f32(motion::speed_scale()); - let raw = transition.started.elapsed().as_secs_f32() / total.as_secs_f32(); - if raw >= 1.0 { - self.new_thread_transition = None; - return None; - } - self.motion_active.set(true); - Some((transition, motion::NEW_THREAD_TRANSITION.progress(raw))) - } - fn tween_elapsed(&self, started: std::time::Instant) -> Duration { self.render_time .unwrap_or_else(std::time::Instant::now) @@ -7021,96 +6888,76 @@ impl Shell { let has_selection = self.state.read(cx).selected_chat.is_some(); 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 || has_appshots) && has_selection, + (has_spaces || no_project || has_appshots) && has_selection, ); - let no_project = self.state.read(cx).no_project; 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 transition_frame = self.new_thread_transition_frame(); + let frame_time = self.render_time.unwrap_or_else(std::time::Instant::now); + 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 = if !has_selection { - Some(new_thread_background( + let new_thread_background_layer = (!has_selection || dock_frame.active).then(|| { + new_thread_background( new_thread_background_setting.as_ref(), new_thread_background_effect, theme, main_content_width, self.viewport_height, - transition_frame - .filter(|(transition, _)| { - transition.direction == NewThreadTransitionDirection::IntoNewThread - }) - .map_or(1.0, |(transition, progress)| { - new_thread_canvas_opacity(transition.direction, progress) - }), - )) - } else { - transition_frame - .filter(|(transition, _)| { - transition.direction == NewThreadTransitionDirection::IntoThread - }) - .map(|(transition, progress)| { - new_thread_background( - new_thread_background_setting.as_ref(), - new_thread_background_effect, - theme, - main_content_width, - self.viewport_height, - new_thread_canvas_opacity(transition.direction, progress), - ) - }) - }; - let transition_composer_offset = transition_frame.map(|(transition, progress)| { - let destination_bottom = match transition.direction { - NewThreadTransitionDirection::IntoThread => self.viewport_height - term_h, - NewThreadTransitionDirection::IntoNewThread => { - let measured = self.new_thread_composer_bottom.get(); - if measured > 0.0 { - measured - } else { - transition.destination_bottom - } - } - }; - new_thread_composer_offset(transition.origin_bottom, destination_bottom, progress) + dock_frame.dissolve(), + self.composer_dock.borrow().painted_top().unwrap_or(0.0), + ) }); // 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 outlet: AnyElement = if has_selection { - if let Some((_, progress)) = transition_frame.filter(|(transition, _)| { - transition.direction == NewThreadTransitionDirection::IntoThread - }) { - div() - .relative() - .size_full() - .child( - div() - .relative() - .top(px(new_thread_transcript_settle(progress))) - .size_full() - .opacity(if transcript_geometry_ready { - new_thread_transcript_opacity(progress) - } else { - 0.0 - }) - .child(self.transcript.clone()), - ) - .into_any_element() - } else { - div() - .size_full() - .opacity(if transcript_geometry_ready { 1.0 } else { 0.0 }) - .child( - self.transcript - .clone() - .cached(gpui::StyleRefinement::default().size_full()), - ) - .into_any_element() - } + let departing_transcript = !has_selection && dock_frame.transcript() > 0.0; + if !has_selection && !departing_transcript { + self.transcript + .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() + .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 // to work in yet — one clear affordance. @@ -7159,61 +7006,7 @@ impl Shell { )) .into_any_element() } else { - // New-thread canvas: optional artwork fills the panel behind one - // vertically-centered controls composition. The composer lives - // here only while the canvas is blank; established sessions keep - // it in the bottom chrome stack below. - let transition_dy = transition_frame - .filter(|(transition, _)| { - transition.direction == NewThreadTransitionDirection::IntoNewThread - }) - .map_or(0.0, |_| transition_composer_offset.unwrap_or(0.0)); - let composition = div() - .w_full() - .relative() - .top(px(NEW_THREAD_COMPOSITION_Y_CORRECTION + transition_dy)) - .flex() - .flex_col() - .items_center() - .child({ - let bottom = self.new_thread_composer_bottom.clone(); - let height = self.new_thread_composer_height.clone(); - div() - .w_full() - .relative() - .child( - gpui::canvas( - move |bounds, _, _| { - // Record the resting blank-canvas anchor, - // not this frame's animated translation. - // Otherwise every reverse frame moves its - // own destination and produces a wobble. - bottom.set(canonical_new_thread_composer_bottom( - f32::from(bounds.bottom()), - transition_dy, - )); - height.set(f32::from(bounds.size.height)); - }, - |_, _, _, _| {}, - ) - .absolute() - .inset_0(), - ) - .child(self.composer.clone()) - }); - // This is a persistent route surface, not an entrance. A keyed - // one-shot here replayed after reparents and competed with the - // shared-element transition, producing the final-frame flicker. - let composition: AnyElement = composition.into_any_element(); - div() - .size_full() - .relative() - .flex() - .flex_col() - .items_center() - .justify_center() - .child(composition) - .into_any_element() + Empty.into_any_element() }; let status = self.render_status_strip(cx); @@ -7346,7 +7139,8 @@ impl Shell { .child({ let measured = self.bottom_stack.clone(); let measured_has_composer = self.bottom_stack_has_composer.clone(); - let contains_composer = has_spaces && has_selection; + let contains_composer = (has_spaces || no_project) && has_selection; + let composer = self.composer.clone(); div() .flex_none() .relative() @@ -7354,8 +7148,10 @@ impl Shell { .flex_col() .child( gpui::canvas( - move |bounds, window, _| { - let next_height = 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); @@ -7370,13 +7166,18 @@ impl Shell { .inset_0(), ) .child(status) - .when((has_spaces || has_appshots) && has_selection, |el| { - el.child( + .when(has_spaces || no_project || has_appshots, |el| { + el.child(crate::composer_dock::docked_composer( div() - .relative() - .top(px(transition_composer_offset.unwrap_or(0.0))) + .id("persistent-composer") + .w(px(composer_width)) + .mx_auto() .child(self.composer.clone()), - ) + self.composer_dock.clone(), + self.viewport_height, + self.reduced_motion, + frame_time, + )) }) .child(self.render_terminal_container(cx)) }) @@ -9461,9 +9262,6 @@ impl Render for Shell { 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) - }); // 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. @@ -9471,7 +9269,7 @@ impl Render for Shell { 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.selected_chat.is_some() + (!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(), @@ -9479,7 +9277,7 @@ impl Render for Shell { ); self.transcript.update(cx, |t, cx| { t.set_rail_enabled(rail::rail_visible(main_width), cx); - if bottom_stack_ready { + if bottom_stack_ready && expected_has_composer { t.set_bottom_clearance(stack_h, cx); } }); @@ -9733,37 +9531,6 @@ mod tests { assert!((new_thread_background_side_fade(160.0) - 28.8).abs() < 0.001); assert_eq!(new_thread_background_side_fade(1_000.0), 72.0); assert!(new_thread_background_side_fade(160.0) * 2.0 < 160.0); - // The bottom-anchored destination starts exactly at the centered - // source's bottom edge, then lands without overshoot. - assert_eq!(new_thread_composer_offset(520.0, 840.0, 0.0), -320.0); - assert_eq!(new_thread_composer_offset(520.0, 840.0, 0.5), -160.0); - assert_eq!(new_thread_composer_offset(520.0, 840.0, 1.0), 0.0); - // Measuring while the reverse transition is translated must recover - // the same resting blank-canvas anchor on every frame. - assert_eq!(canonical_new_thread_composer_bottom(520.0, -320.0), 840.0); - assert_eq!(canonical_new_thread_composer_bottom(680.0, -160.0), 840.0); - // The canvas leaves early on send and returns on the reverse path; - // the transcript arrives just after motion begins and settles upward. - assert_eq!( - new_thread_canvas_opacity(NewThreadTransitionDirection::IntoThread, 0.0), - 1.0 - ); - assert_eq!( - new_thread_canvas_opacity(NewThreadTransitionDirection::IntoThread, 1.0), - 0.0 - ); - assert_eq!( - new_thread_canvas_opacity(NewThreadTransitionDirection::IntoNewThread, 0.0), - 0.0 - ); - assert_eq!( - new_thread_canvas_opacity(NewThreadTransitionDirection::IntoNewThread, 1.0), - 1.0 - ); - assert_eq!(new_thread_transcript_opacity(0.0), 0.0); - assert_eq!(new_thread_transcript_opacity(1.0), 1.0); - assert_eq!(new_thread_transcript_settle(0.0), 8.0); - assert_eq!(new_thread_transcript_settle(1.0), 0.0); } #[test] diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index 87e7e2fb9..a819e8e92 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -2293,6 +2293,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 +2586,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, @@ -3259,6 +3263,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 +3563,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 +3620,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 { @@ -6911,7 +6947,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 +6965,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() @@ -7067,6 +7107,32 @@ impl Render for Transcript { #[cfg(test)] mod tests { use super::*; + + #[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] From ad2bbeccb1a6ca39827182d2b02a62a5f3141da4 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 01:12:55 +0200 Subject: [PATCH 18/40] fix(ui): remove resting hero side haze and refine bottom fade --- crates/ui/src/shell.rs | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index ff2de5c63..850b2b954 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -693,7 +693,10 @@ const SIDEBAR_GLASS_FADE_BAND: f32 = 24.0; 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; -const NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO: f32 = 0.62; +// Keep the upper artwork clear, with a quiet tail over the lower 56%. +// The hero meets the panel edges directly; side feathering belongs only to +// the contracting dock mask, never the resting composition. +const NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO: f32 = 0.56; const NEW_THREAD_BACKGROUND_SIDE_FADE: f32 = 72.0; const NEW_THREAD_BACKGROUND_SIDE_FADE_RATIO: f32 = 0.18; @@ -821,9 +824,10 @@ fn new_thread_background_height(viewport_height: f32) -> f32 { .min(NEW_THREAD_BACKGROUND_MAX_HEIGHT) } -fn new_thread_background_side_fade(viewport_width: f32) -> f32 { +fn new_thread_background_side_fade(viewport_width: f32, dissolve: f32) -> f32 { (viewport_width.max(0.0) * NEW_THREAD_BACKGROUND_SIDE_FADE_RATIO) .min(NEW_THREAD_BACKGROUND_SIDE_FADE) + * dissolve.clamp(0.0, 1.0) } fn new_thread_background( @@ -843,8 +847,8 @@ fn new_thread_background( return Empty.into_any_element(); } let hero_height = new_thread_background_height(viewport_height); - let side_fade = new_thread_background_side_fade(viewport_width); let dissolve = dissolve.clamp(0.0, 1.0); + let side_fade = new_thread_background_side_fade(viewport_width, dissolve); let grain_strength = (std::f32::consts::PI * dissolve).sin().max(0.0); let (image_opacity, effect_layer) = crate::new_thread_background_effects::treatment( effect, @@ -902,8 +906,8 @@ fn new_thread_background( .band_top(hero_height * 0.16 * dissolve) .outset_bottom(-hero_height * 0.06 * dissolve) .inset_x(viewport_width * 0.38 * dissolve) - .fade_left(true) - .fade_right(true), + .fade_left(dissolve > 0.0) + .fade_right(dissolve > 0.0), ) .into_any_element() } @@ -9528,9 +9532,14 @@ mod tests { 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); - assert!((new_thread_background_side_fade(160.0) - 28.8).abs() < 0.001); - assert_eq!(new_thread_background_side_fade(1_000.0), 72.0); - assert!(new_thread_background_side_fade(160.0) * 2.0 < 160.0); + // Full bleed at rest, including fullscreen; the feather grows from + // zero on departure rather than switching on a visible side band. + assert_eq!(new_thread_background_side_fade(160.0, 0.0), 0.0); + assert_eq!(new_thread_background_side_fade(2_560.0, 0.0), 0.0); + assert!((new_thread_background_side_fade(160.0, 1.0) - 28.8).abs() < 0.001); + assert_eq!(new_thread_background_side_fade(1_000.0, 0.5), 36.0); + assert_eq!(new_thread_background_side_fade(1_000.0, 1.0), 72.0); + assert!(new_thread_background_side_fade(160.0, 1.0) * 2.0 < 160.0); } #[test] From 7686ab96a04b484faf01a2e5384ca65511d26582 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 02:16:03 +0200 Subject: [PATCH 19/40] fix(ui): simplify hero motion and align background textures --- crates/ui/src/composer_dock.rs | 4 - crates/ui/src/edge_fade.rs | 11 -- .../ui/src/new_thread_background_effects.rs | 166 ++++++++++-------- crates/ui/src/shell.rs | 58 ++---- 4 files changed, 99 insertions(+), 140 deletions(-) diff --git a/crates/ui/src/composer_dock.rs b/crates/ui/src/composer_dock.rs index 88b933eae..7dbaa579e 100644 --- a/crates/ui/src/composer_dock.rs +++ b/crates/ui/src/composer_dock.rs @@ -158,10 +158,6 @@ impl Default for DockState { } impl DockState { - pub fn painted_top(&self) -> Option { - self.position.map(|(_, y)| y.value) - } - pub fn layout_width(&mut self, target: f32, reduced: bool, now: Instant) -> f32 { let dt = if self.route_changed { 0.0 diff --git a/crates/ui/src/edge_fade.rs b/crates/ui/src/edge_fade.rs index f69b04074..1329f0e1a 100644 --- a/crates/ui/src/edge_fade.rs +++ b/crates/ui/src/edge_fade.rs @@ -21,7 +21,6 @@ pub fn edge_faded(band: f32, top: bool, bottom: bool, child: impl IntoElement) - band_bottom: None, inset_top: 0.0, outset_bottom: 0.0, - inset_x: 0.0, top, bottom, left: false, @@ -39,7 +38,6 @@ pub struct EdgeFaded { band_bottom: Option, inset_top: f32, outset_bottom: f32, - inset_x: f32, top: bool, bottom: bool, left: bool, @@ -118,12 +116,6 @@ impl EdgeFaded { self.outset_bottom = px; self } - - /// Inset the alpha mask without relaying out or cropping the artwork. - pub fn inset_x(mut self, px: f32) -> Self { - self.inset_x = px.max(0.0); - self - } } impl Element for EdgeFaded { @@ -196,9 +188,6 @@ impl Element for EdgeFaded { bounds.size.height -= inset; bounds.size.height += px(self.outset_bottom); bounds.size.height = bounds.size.height.max(px(0.0)); - let inset_x = px(self.inset_x).min(bounds.size.width * 0.5); - bounds.origin.x += inset_x; - bounds.size.width -= inset_x * 2.0; EdgeFade { bounds, band: px(self.band), diff --git a/crates/ui/src/new_thread_background_effects.rs b/crates/ui/src/new_thread_background_effects.rs index 80d22e9dc..4650e2adc 100644 --- a/crates/ui/src/new_thread_background_effects.rs +++ b/crates/ui/src/new_thread_background_effects.rs @@ -9,6 +9,9 @@ use gpui::{ use crate::settings::NewThreadBackgroundEffect; use crate::theme::{Appearance, Theme}; +const ASCII_FONT_SIZE: f32 = 6.0; +const ASCII_LINE_HEIGHT: f32 = 8.0; + #[derive(Debug)] struct BackgroundLuminance { width: u32, @@ -91,20 +94,20 @@ pub(super) fn treatment( let image_opacity = base_opacity * match effect { NewThreadBackgroundEffect::None => 1.0, - NewThreadBackgroundEffect::Dither => 0.94, - NewThreadBackgroundEffect::Ascii => 0.78, - NewThreadBackgroundEffect::Halftone => 0.90, - NewThreadBackgroundEffect::Scanlines => 0.96, + NewThreadBackgroundEffect::Dither => 1.0, + NewThreadBackgroundEffect::Ascii => 0.96, + NewThreadBackgroundEffect::Halftone => 1.0, + NewThreadBackgroundEffect::Scanlines => 1.0, }; if effect == NewThreadBackgroundEffect::None { return (image_opacity, Empty.into_any_element()); } let color = theme.text.opacity(match effect { - NewThreadBackgroundEffect::Dither => 0.13, - NewThreadBackgroundEffect::Ascii => 0.26, - NewThreadBackgroundEffect::Halftone => 0.15, - NewThreadBackgroundEffect::Scanlines => 0.11, + NewThreadBackgroundEffect::Dither => 0.10, + NewThreadBackgroundEffect::Ascii => 0.22, + NewThreadBackgroundEffect::Halftone => 0.12, + NewThreadBackgroundEffect::Scanlines => 0.08, NewThreadBackgroundEffect::None => 0.0, }); let light = matches!(theme.appearance, Appearance::Light); @@ -118,8 +121,13 @@ pub(super) fn treatment( let Some(luminance) = prepaint_luminance.as_ref() else { return Vec::new(); }; - let columns = (f32::from(bounds.size.width) / 7.0).ceil() as usize + 1; - let rows = (f32::from(bounds.size.height) / 9.0).ceil() as usize; + let font = gpui::font(ascii_font.clone()); + // Font size is not glyph advance. Using it as cell width made + // the rendered ASCII field stop halfway across the artwork and + // compressed its source sampling into the wrong horizontal span. + let cell_width = ascii_cell_width(window, &font, color); + let columns = (f32::from(bounds.size.width) / cell_width).ceil() as usize + 1; + let rows = (f32::from(bounds.size.height) / ASCII_LINE_HEIGHT).ceil() as usize + 1; let ramp = b" .:-=+*#%@"; (0..rows) .map(|row| { @@ -127,8 +135,8 @@ pub(super) fn treatment( for column in 0..columns { let luma = luminance.sample_cover( bounds.size, - column as f32 * 7.0, - row as f32 * 9.0, + (column as f32 + 0.5) * cell_width, + (row as f32 + 0.5) * ASCII_LINE_HEIGHT, ); let ink = if light { 255 - luma } else { luma }; let index = ink as usize * (ramp.len() - 1) / 255; @@ -137,13 +145,15 @@ pub(super) fn treatment( let text: SharedString = text.into(); let run = TextRun { len: text.len(), - font: gpui::font(ascii_font.clone()), + font: font.clone(), color, background_color: None, underline: None, strikethrough: None, }; - window.text_system().shape_line(text, px(7.0), &[run], None) + window + .text_system() + .shape_line(text, px(ASCII_FONT_SIZE), &[run], None) }) .collect::>() }, @@ -152,7 +162,7 @@ pub(super) fn treatment( NewThreadBackgroundEffect::Dither => { const BAYER: [[u8; 4]; 4] = [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]; - let step = 7.0; + let step = 3.0; let columns = (f32::from(bounds.size.width) / step).ceil() as usize; let rows = (f32::from(bounds.size.height) / step).ceil() as usize; for row in 0..rows { @@ -170,7 +180,7 @@ pub(super) fn treatment( if ink / 16 <= threshold { continue; } - let dot = if threshold < 3 { 1.8 } else { 1.0 }; + let dot = if threshold < 3 { 1.0 } else { 0.7 }; paint_dot( window, bounds, @@ -183,7 +193,7 @@ pub(super) fn treatment( } } NewThreadBackgroundEffect::Ascii => { - let line_height = px(9.0); + let line_height = px(ASCII_LINE_HEIGHT); for (row, line) in ascii_lines.iter().enumerate() { let _ = line.paint( gpui::point(bounds.left(), bounds.top() + line_height * row as f32), @@ -196,7 +206,7 @@ pub(super) fn treatment( } } NewThreadBackgroundEffect::Halftone => { - let step = 12.0; + let step = 6.0; let columns = (f32::from(bounds.size.width) / step).ceil() as usize; let rows = (f32::from(bounds.size.height) / step).ceil() as usize; for row in 0..rows { @@ -210,7 +220,7 @@ pub(super) fn treatment( row as f32 * step, ); let ink = if light { 255 - luma } else { luma }; - let dot = 0.8 + ink as f32 / 255.0 * 4.2; + let dot = 0.5 + ink as f32 / 255.0 * 2.0; paint_dot( window, bounds, @@ -223,12 +233,12 @@ pub(super) fn treatment( } } NewThreadBackgroundEffect::Scanlines => { - let rows = (f32::from(bounds.size.height) / 5.0).ceil() as usize; + let rows = (f32::from(bounds.size.height) / 3.0).ceil() as usize; for row in 0..rows { window.paint_quad(gpui::quad( gpui::Bounds::new( - gpui::point(bounds.left(), bounds.top() + px(row as f32 * 5.0)), - gpui::size(bounds.size.width, px(1.0)), + gpui::point(bounds.left(), bounds.top() + px(row as f32 * 3.0)), + gpui::size(bounds.size.width, px(0.5)), ), px(0.0), color, @@ -246,20 +256,27 @@ pub(super) fn treatment( let layer = div() .absolute() .inset_0() - .when( - matches!( - effect, - NewThreadBackgroundEffect::Dither - | NewThreadBackgroundEffect::Ascii - | NewThreadBackgroundEffect::Halftone - ), - |layer| layer.bg(theme.bg.opacity(0.18)), - ) + .opacity(base_opacity) .child(texture) .into_any_element(); (image_opacity, layer) } +fn ascii_cell_width(window: &gpui::Window, font: &gpui::Font, color: gpui::Hsla) -> f32 { + let run = TextRun { + len: 1, + font: font.clone(), + color, + background_color: None, + underline: None, + strikethrough: None, + }; + let probe = window + .text_system() + .shape_line("M".into(), px(ASCII_FONT_SIZE), &[run], None); + f32::from(probe.width).max(1.0) +} + fn paint_dot( window: &mut gpui::Window, bounds: gpui::Bounds, @@ -281,56 +298,51 @@ fn paint_dot( )); } -/// Deterministic dissolve grain. It requires no image decode on the first -/// navigation frame, and its cells evaporate instead of re-randomizing each -/// frame. The caller scopes this texture to the artwork's contracting mask. -pub(super) fn dissolve_grain(theme: &Theme, progress: f32) -> AnyElement { - let color = theme.text; - gpui::canvas( - |_, _, _| (), - move |bounds, _, window, _| { - const BAYER: [[u8; 4]; 4] = - [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]; - let width = f32::from(bounds.size.width); - let height = f32::from(bounds.size.height); - let step = 6.0; - let left = (width * 0.38 * progress / step).floor() as usize; - let right = ((width - width * 0.38 * progress) / step).ceil() as usize; - let top = (height * 0.82 * progress / step).floor() as usize; - let bottom = (height / step).ceil() as usize; - for row in top..bottom { - for column in left..right { - let threshold = (BAYER[row % 4][column % 4] as f32 + 1.0) / 17.0; - let alpha = 1.0 - - crate::composer_dock::stage( - progress, - threshold * 0.5, - 0.5 + threshold * 0.5, - ); - if alpha < 0.01 { - continue; - } - paint_dot( - window, - bounds, - column as f32 * step, - row as f32 * step, - 1.0 + alpha, - color.opacity(0.3 * alpha), - ); - } - } - }, - ) - .absolute() - .inset_0() - .into_any_element() -} - #[cfg(test)] mod tests { use super::*; + #[gpui::test] + fn ascii_advance_covers_narrow_and_fullscreen_artwork(cx: &mut gpui::TestAppContext) { + struct Fixture; + impl gpui::Render for Fixture { + fn render( + &mut self, + _: &mut gpui::Window, + _: &mut gpui::Context, + ) -> impl IntoElement { + div() + } + } + let handle = cx.add_window(|_, _| Fixture); + cx.update_window(handle.into(), |_, window, _| { + let font = gpui::font("Menlo"); + let color = gpui::white(); + let advance = ascii_cell_width(window, &font, color); + for width in [320.0, 768.0, 2560.0] { + let columns = (width / advance).ceil() as usize + 1; + let run = TextRun { + len: columns, + font: font.clone(), + color, + background_color: None, + underline: None, + strikethrough: None, + }; + let line = window.text_system().shape_line( + "M".repeat(columns).into(), + px(ASCII_FONT_SIZE), + &[run], + None, + ); + let painted_width = f32::from(line.width); + assert!(painted_width >= width, "pattern stopped before {width}px"); + assert!(painted_width < width + 2.0 * advance + 0.1); + } + }) + .unwrap(); + } + #[test] fn cover_sampling_crops_the_long_axis_from_the_center() { let sample = BackgroundLuminance { diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 850b2b954..be88fc72d 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -694,11 +694,8 @@ 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; // Keep the upper artwork clear, with a quiet tail over the lower 56%. -// The hero meets the panel edges directly; side feathering belongs only to -// the contracting dock mask, never the resting composition. +// The artwork meets the panel edges directly in every animation frame. const NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO: f32 = 0.56; -const NEW_THREAD_BACKGROUND_SIDE_FADE: f32 = 72.0; -const NEW_THREAD_BACKGROUND_SIDE_FADE_RATIO: f32 = 0.18; /// Drag marker for the sidebar resize handle. struct SidebarResize; @@ -824,20 +821,12 @@ fn new_thread_background_height(viewport_height: f32) -> f32 { .min(NEW_THREAD_BACKGROUND_MAX_HEIGHT) } -fn new_thread_background_side_fade(viewport_width: f32, dissolve: f32) -> f32 { - (viewport_width.max(0.0) * NEW_THREAD_BACKGROUND_SIDE_FADE_RATIO) - .min(NEW_THREAD_BACKGROUND_SIDE_FADE) - * dissolve.clamp(0.0, 1.0) -} - fn new_thread_background( background: Option<&settings::NewThreadComposerBackground>, effect: settings::NewThreadBackgroundEffect, theme: &Theme, - viewport_width: f32, viewport_height: f32, dissolve: f32, - composer_top: f32, ) -> AnyElement { let Some(background) = background else { return Empty.into_any_element(); @@ -848,40 +837,30 @@ fn new_thread_background( } let hero_height = new_thread_background_height(viewport_height); let dissolve = dissolve.clamp(0.0, 1.0); - let side_fade = new_thread_background_side_fade(viewport_width, dissolve); - let grain_strength = (std::f32::consts::PI * dissolve).sin().max(0.0); + let (image_opacity, effect_layer) = crate::new_thread_background_effects::treatment( effect, theme, &path, new_thread_background_opacity(theme.is_frost()), ); - // Dither emerges only inside the shrinking alpha mask. Keep the original - // cover geometry fixed so the artwork never zooms or re-crops as it leaves. - let dissolve_layer = (grain_strength > 0.001).then(|| { - let grain = crate::new_thread_background_effects::dissolve_grain(theme, dissolve); - div() - .absolute() - .inset_0() - .opacity(grain_strength) - .child(grain) - }); + // Image and treatment share a fixed crop and fade together in place. div() .absolute() - .top(px((composer_top - hero_height * 0.88).max(0.0) * dissolve)) + .top_0() .left_0() .right_0() .h(px(hero_height)) .overflow_hidden() - .opacity(1.0 - crate::composer_dock::stage(dissolve, 0.82, 1.0)) + .opacity(1.0 - dissolve) // Fade the image primitive itself instead of painting a theme-colored // gradient above it. That creates a real alpha mask, so the tail // resolves into the exact canvas beneath it on both opaque and glass // themes without a horizontal color seam. .child( crate::edge_fade::edge_faded( - side_fade, - dissolve > 0.0, + 0.0, + false, true, // Give the mask a definite relayout box. A percentage-sized // image as the custom element's direct child could briefly @@ -896,18 +875,11 @@ fn new_thread_background( .inset_0() .size_full() .object_fit(ObjectFit::Cover) - .opacity(image_opacity * (1.0 - 0.65 * dissolve)), + .opacity(image_opacity), ) - .child(effect_layer) - .children(dissolve_layer), + .child(effect_layer), ) - .band_bottom(hero_height * NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO) - .inset_top(hero_height * 0.82 * dissolve) - .band_top(hero_height * 0.16 * dissolve) - .outset_bottom(-hero_height * 0.06 * dissolve) - .inset_x(viewport_width * 0.38 * dissolve) - .fade_left(dissolve > 0.0) - .fade_right(dissolve > 0.0), + .band_bottom(hero_height * NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO), ) .into_any_element() } @@ -6924,10 +6896,8 @@ impl Shell { new_thread_background_setting.as_ref(), new_thread_background_effect, theme, - main_content_width, self.viewport_height, dock_frame.dissolve(), - self.composer_dock.borrow().painted_top().unwrap_or(0.0), ) }); @@ -9532,14 +9502,6 @@ mod tests { 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); - // Full bleed at rest, including fullscreen; the feather grows from - // zero on departure rather than switching on a visible side band. - assert_eq!(new_thread_background_side_fade(160.0, 0.0), 0.0); - assert_eq!(new_thread_background_side_fade(2_560.0, 0.0), 0.0); - assert!((new_thread_background_side_fade(160.0, 1.0) - 28.8).abs() < 0.001); - assert_eq!(new_thread_background_side_fade(1_000.0, 0.5), 36.0); - assert_eq!(new_thread_background_side_fade(1_000.0, 1.0), 72.0); - assert!(new_thread_background_side_fade(160.0, 1.0) * 2.0 < 160.0); } #[test] From 3362b58fbe81ae80af412245379a87b68bb261b6 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 02:25:46 +0200 Subject: [PATCH 20/40] feat(ui): strengthen source-colored hero treatments --- .../ui/src/new_thread_background_effects.rs | 209 ++++++++++++------ crates/ui/src/settings.rs | 8 +- 2 files changed, 146 insertions(+), 71 deletions(-) diff --git a/crates/ui/src/new_thread_background_effects.rs b/crates/ui/src/new_thread_background_effects.rs index 4650e2adc..aea5e4349 100644 --- a/crates/ui/src/new_thread_background_effects.rs +++ b/crates/ui/src/new_thread_background_effects.rs @@ -7,7 +7,7 @@ use gpui::{ }; use crate::settings::NewThreadBackgroundEffect; -use crate::theme::{Appearance, Theme}; +use crate::theme::Theme; const ASCII_FONT_SIZE: f32 = 6.0; const ASCII_LINE_HEIGHT: f32 = 8.0; @@ -17,10 +17,46 @@ struct BackgroundLuminance { width: u32, height: u32, pixels: Box<[u8]>, + colors: Box<[[u8; 4]]>, + dither: std::sync::OnceLock>>, } impl BackgroundLuminance { + fn dither_image(&self) -> Option> { + self.dither + .get_or_init(|| { + const BAYER: [[u8; 4]; 4] = + [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]; + let pixels = image::RgbaImage::from_fn(self.width, self.height, |x, y| { + let [r, g, b, a] = self.colors[(y * self.width + x) as usize]; + let threshold = BAYER[y as usize % 4][x as usize % 4]; + image::Rgba([ + quantize(r, threshold), + quantize(g, threshold), + quantize(b, threshold), + a, + ]) + }); + let mut bytes = std::io::Cursor::new(Vec::new()); + pixels.write_to(&mut bytes, image::ImageFormat::Png).ok()?; + Some(std::sync::Arc::new(gpui::Image::from_bytes( + gpui::ImageFormat::Png, + bytes.into_inner(), + ))) + }) + .clone() + } + fn sample_cover(&self, bounds: gpui::Size, x: f32, y: f32) -> u8 { + self.pixels[self.cover_index(bounds, x, y)] + } + + fn color_cover(&self, bounds: gpui::Size, x: f32, y: f32) -> gpui::Hsla { + let [r, g, b, a] = self.colors[self.cover_index(bounds, x, y)]; + gpui::rgba(u32::from_be_bytes([r, g, b, a])).into() + } + + 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; @@ -32,7 +68,7 @@ impl BackgroundLuminance { .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; - self.pixels[(source_y * self.width + source_x) as usize] + (source_y * self.width + source_x) as usize } } @@ -58,14 +94,16 @@ fn background_luminance(path: &Path) -> Option 1.0, - NewThreadBackgroundEffect::Dither => 1.0, - NewThreadBackgroundEffect::Ascii => 0.96, - NewThreadBackgroundEffect::Halftone => 1.0, + NewThreadBackgroundEffect::Dither => 0.0, + NewThreadBackgroundEffect::Ascii => 0.0, + NewThreadBackgroundEffect::Halftone => 0.0, NewThreadBackgroundEffect::Scanlines => 1.0, }; if effect == NewThreadBackgroundEffect::None { return (image_opacity, Empty.into_any_element()); } + // Transform once, not hundreds of thousands of canvas quads on every + // animation frame. The processed raster follows the original cover crop. + if effect == NewThreadBackgroundEffect::Dither { + return match luminance.as_ref().and_then(|source| source.dither_image()) { + Some(image) => ( + 0.0, + gpui::img(image) + .absolute() + .inset_0() + .size_full() + .object_fit(gpui::ObjectFit::Cover) + .opacity(base_opacity) + .into_any_element(), + ), + None => (base_opacity, Empty.into_any_element()), + }; + } - let color = theme.text.opacity(match effect { - NewThreadBackgroundEffect::Dither => 0.10, - NewThreadBackgroundEffect::Ascii => 0.22, - NewThreadBackgroundEffect::Halftone => 0.12, - NewThreadBackgroundEffect::Scanlines => 0.08, - NewThreadBackgroundEffect::None => 0.0, - }); - let light = matches!(theme.appearance, Appearance::Light); + let color = gpui::white(); let ascii_font = theme.font_mono.clone(); let prepaint_luminance = luminance.clone(); let texture = gpui::canvas( @@ -132,66 +180,39 @@ pub(super) fn treatment( (0..rows) .map(|row| { let mut text = String::with_capacity(columns); + let mut runs = Vec::with_capacity(columns); for column in 0..columns { let luma = luminance.sample_cover( bounds.size, (column as f32 + 0.5) * cell_width, (row as f32 + 0.5) * ASCII_LINE_HEIGHT, ); - let ink = if light { 255 - luma } else { luma }; - let index = ink as usize * (ramp.len() - 1) / 255; + let index = + ((luma as f32 / 255.0).sqrt() * (ramp.len() - 1) as f32) as usize; text.push(ramp[index] as char); + runs.push(TextRun { + len: 1, + font: font.clone(), + color: luminance.color_cover( + bounds.size, + (column as f32 + 0.5) * cell_width, + (row as f32 + 0.5) * ASCII_LINE_HEIGHT, + ), + background_color: None, + underline: None, + strikethrough: None, + }); } let text: SharedString = text.into(); - let run = TextRun { - len: text.len(), - font: font.clone(), - color, - background_color: None, - underline: None, - strikethrough: None, - }; window .text_system() - .shape_line(text, px(ASCII_FONT_SIZE), &[run], None) + .shape_line(text, px(ASCII_FONT_SIZE), &runs, None) }) .collect::>() }, move |bounds, ascii_lines, window, cx| match effect { NewThreadBackgroundEffect::None => {} - NewThreadBackgroundEffect::Dither => { - const BAYER: [[u8; 4]; 4] = - [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]; - let step = 3.0; - let columns = (f32::from(bounds.size.width) / step).ceil() as usize; - let rows = (f32::from(bounds.size.height) / step).ceil() as usize; - for row in 0..rows { - for column in 0..columns { - let threshold = BAYER[row % 4][column % 4]; - let Some(luminance) = luminance.as_ref() else { - continue; - }; - let luma = luminance.sample_cover( - bounds.size, - column as f32 * step, - row as f32 * step, - ); - let ink = if light { 255 - luma } else { luma }; - if ink / 16 <= threshold { - continue; - } - let dot = if threshold < 3 { 1.0 } else { 0.7 }; - paint_dot( - window, - bounds, - column as f32 * step, - row as f32 * step, - dot, - color, - ); - } - } - } + NewThreadBackgroundEffect::Dither => {} NewThreadBackgroundEffect::Ascii => { let line_height = px(ASCII_LINE_HEIGHT); for (row, line) in ascii_lines.iter().enumerate() { @@ -206,7 +227,7 @@ pub(super) fn treatment( } } NewThreadBackgroundEffect::Halftone => { - let step = 6.0; + let step = 4.0; let columns = (f32::from(bounds.size.width) / step).ceil() as usize; let rows = (f32::from(bounds.size.height) / step).ceil() as usize; for row in 0..rows { @@ -219,13 +240,17 @@ pub(super) fn treatment( column as f32 * step, row as f32 * step, ); - let ink = if light { 255 - luma } else { luma }; - let dot = 0.5 + ink as f32 / 255.0 * 2.0; + let dot = step * (0.3 + 0.7 * (luma as f32 / 255.0).sqrt()); + let color = luminance.color_cover( + bounds.size, + (column as f32 + 0.5) * step, + (row as f32 + 0.5) * step, + ); paint_dot( window, bounds, - column as f32 * step, - row as f32 * step, + column as f32 * step + (step - dot) * 0.5, + row as f32 * step + (step - dot) * 0.5, dot, color, ); @@ -238,10 +263,10 @@ pub(super) fn treatment( window.paint_quad(gpui::quad( gpui::Bounds::new( gpui::point(bounds.left(), bounds.top() + px(row as f32 * 3.0)), - gpui::size(bounds.size.width, px(0.5)), + gpui::size(bounds.size.width, px(1.0)), ), px(0.0), - color, + gpui::black().opacity(0.48), px(0.0), gpui::transparent_black(), BorderStyle::default(), @@ -257,11 +282,27 @@ pub(super) fn treatment( .absolute() .inset_0() .opacity(base_opacity) + .when( + matches!( + effect, + NewThreadBackgroundEffect::Ascii | NewThreadBackgroundEffect::Halftone + ), + |layer| layer.bg(gpui::black()), + ) .child(texture) .into_any_element(); (image_opacity, layer) } +// Four levels per channel, with a centered ordered threshold: actual color +// quantization rather than a disconnected stipple over the original photograph. +fn quantize(value: u8, threshold: u8) -> u8 { + let level = (value as f32 / 85.0 + (threshold as f32 + 0.5) / 16.0 - 0.5) + .round() + .clamp(0.0, 3.0); + (level * 85.0) as u8 +} + fn ascii_cell_width(window: &gpui::Window, font: &gpui::Font, color: gpui::Hsla) -> f32 { let run = TextRun { len: 1, @@ -302,6 +343,38 @@ fn paint_dot( mod tests { use super::*; + #[test] + fn ordered_palette_preserves_endpoints_and_distributes_midtones() { + for threshold in 0..16 { + assert_eq!(quantize(0, threshold), 0); + assert_eq!(quantize(255, threshold), 255); + for value in 0..=255 { + assert_eq!(quantize(value, threshold) % 85, 0); + } + } + let levels: Vec<_> = (0..16).map(|threshold| quantize(128, threshold)).collect(); + assert!(levels.contains(&85)); + assert!(levels.contains(&170)); + let average = levels.iter().map(|&v| v as f32).sum::() / 16.0; + assert!((average - 128.0).abs() < 6.0); + } + + #[test] + fn processed_artwork_is_reused_across_frames() { + let sample = BackgroundLuminance { + width: 4, + height: 4, + pixels: vec![128; 16].into_boxed_slice(), + colors: vec![[180, 100, 220, 255]; 16].into_boxed_slice(), + dither: Default::default(), + }; + let first = sample.dither_image().unwrap(); + assert!(std::sync::Arc::ptr_eq( + &first, + &sample.dither_image().unwrap() + )); + } + #[gpui::test] fn ascii_advance_covers_narrow_and_fullscreen_artwork(cx: &mut gpui::TestAppContext) { struct Fixture; @@ -349,6 +422,8 @@ mod tests { width: 4, height: 2, pixels: vec![0, 1, 2, 3, 10, 11, 12, 13].into_boxed_slice(), + colors: vec![[0, 0, 0, 255]; 8].into_boxed_slice(), + dither: Default::default(), }; let square = gpui::size(px(100.0), px(100.0)); assert_eq!(sample.sample_cover(square, 0.0, 0.0), 1); diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index d94779621..243ef8638 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -101,10 +101,10 @@ impl NewThreadBackgroundEffect { pub const fn description(self) -> &'static str { match self { Self::None => "Shows the original artwork.", - Self::Dither => "Adds a fine ordered-dot texture.", - Self::Ascii => "Layers a quiet monospaced glyph field.", - Self::Halftone => "Adds a larger print-style dot screen.", - Self::Scanlines => "Adds subtle horizontal display lines.", + 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.", } } } From c9a4037fdfb5e95cfb5bc3fbf4a59e350744397c Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 02:31:31 +0200 Subject: [PATCH 21/40] fix(ui): preserve visible dither grain and soften ASCII --- .../ui/src/new_thread_background_effects.rs | 215 ++++++++++++------ 1 file changed, 141 insertions(+), 74 deletions(-) diff --git a/crates/ui/src/new_thread_background_effects.rs b/crates/ui/src/new_thread_background_effects.rs index aea5e4349..d087362d4 100644 --- a/crates/ui/src/new_thread_background_effects.rs +++ b/crates/ui/src/new_thread_background_effects.rs @@ -11,6 +11,9 @@ use crate::theme::Theme; const ASCII_FONT_SIZE: f32 = 6.0; const ASCII_LINE_HEIGHT: f32 = 8.0; +const ASCII_STRENGTH: f32 = 0.72; + +type DitherCache = Option<((u32, u32), std::sync::Arc)>; #[derive(Debug)] struct BackgroundLuminance { @@ -18,33 +21,50 @@ struct BackgroundLuminance { height: u32, pixels: Box<[u8]>, colors: Box<[[u8; 4]]>, - dither: std::sync::OnceLock>>, + dither: std::sync::Mutex, } impl BackgroundLuminance { - fn dither_image(&self) -> Option> { - self.dither - .get_or_init(|| { - const BAYER: [[u8; 4]; 4] = - [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]; - let pixels = image::RgbaImage::from_fn(self.width, self.height, |x, y| { - let [r, g, b, a] = self.colors[(y * self.width + x) as usize]; - let threshold = BAYER[y as usize % 4][x as usize % 4]; - image::Rgba([ - quantize(r, threshold), - quantize(g, threshold), - quantize(b, threshold), - a, - ]) - }); - let mut bytes = std::io::Cursor::new(Vec::new()); - pixels.write_to(&mut bytes, image::ImageFormat::Png).ok()?; - Some(std::sync::Arc::new(gpui::Image::from_bytes( - gpui::ImageFormat::Png, - bytes.into_inner(), - ))) - }) - .clone() + fn dither_image( + &self, + width: u32, + height: u32, + window: &mut gpui::Window, + cx: &mut gpui::App, + ) -> std::sync::Arc { + let mut cached = self + .dither + .lock() + .unwrap_or_else(|error| error.into_inner()); + if let Some((size, image)) = cached.as_ref() { + if *size == (width, height) { + return image.clone(); + } + } + let pixels = self.dither_pixels(width, height); + let image = std::sync::Arc::new(gpui::RenderImage::new([image::Frame::new(pixels)])); + if let Some((_, previous)) = cached.replace(((width, height), image.clone())) { + gpui::ImageSource::Render(previous).evict(Some(window), cx); + } + image + } + + 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)); + image::RgbaImage::from_fn(width, height, |x, y| { + // Two logical pixels per dot: screen-space, not source-space. + // Sampling once per cell keeps the stipple intact on detailed art. + let column = x / 2; + let row = y / 2; + let index = self.cover_index(bounds, (column * 2 + 1) as f32, (row * 2 + 1) as f32); + let [r, g, b, a] = dither_color( + self.colors[index], + BAYER[row as usize % 4][column as usize % 4], + ); + // RenderImage consumes BGRA, unlike image::Image's encoded decoder. + image::Rgba([b, g, r, a]) + }) } fn sample_cover(&self, bounds: gpui::Size, x: f32, y: f32) -> u8 { @@ -140,22 +160,31 @@ pub(super) fn treatment( if effect == NewThreadBackgroundEffect::None { return (image_opacity, Empty.into_any_element()); } - // Transform once, not hundreds of thousands of canvas quads on every - // animation frame. The processed raster follows the original cover crop. + // Cache one screen-sized raster. Resizes regenerate the crop and retire + // the previous GPU image; docking reuses it without resampling the dots. if effect == NewThreadBackgroundEffect::Dither { - return match luminance.as_ref().and_then(|source| source.dither_image()) { - Some(image) => ( - 0.0, - gpui::img(image) - .absolute() - .inset_0() - .size_full() - .object_fit(gpui::ObjectFit::Cover) - .opacity(base_opacity) - .into_any_element(), - ), - None => (base_opacity, Empty.into_any_element()), - }; + let source = luminance.expect("decoded dither source"); + let texture = gpui::canvas( + move |bounds, window, cx| { + let width = f32::from(bounds.size.width).ceil().clamp(1.0, 8192.0) as u32; + let height = f32::from(bounds.size.height).ceil().clamp(1.0, 440.0) as u32; + source.dither_image(width, height, window, cx) + }, + |bounds, image, window, _| { + let _ = window.paint_image(bounds, gpui::Corners::default(), image, 0, false); + }, + ) + .absolute() + .inset_0(); + return ( + 0.0, + div() + .absolute() + .inset_0() + .opacity(base_opacity) + .child(texture) + .into_any_element(), + ); } let color = gpui::white(); @@ -278,10 +307,12 @@ pub(super) fn treatment( .absolute() .inset_0(); - let layer = div() + let surface = div() .absolute() .inset_0() - .opacity(base_opacity) + .when(effect == NewThreadBackgroundEffect::Ascii, |surface| { + surface.opacity(ASCII_STRENGTH) + }) .when( matches!( effect, @@ -289,18 +320,39 @@ pub(super) fn treatment( ), |layer| layer.bg(gpui::black()), ) - .child(texture) + .child(texture); + // Mix the artwork and glyph treatment before applying glass transparency. + let layer = div() + .absolute() + .inset_0() + .opacity(base_opacity) + .when(effect == NewThreadBackgroundEffect::Ascii, |layer| { + layer.child( + gpui::img(path.to_path_buf()) + .absolute() + .inset_0() + .size_full() + .object_fit(gpui::ObjectFit::Cover), + ) + }) + .child(surface) .into_any_element(); (image_opacity, layer) } -// Four levels per channel, with a centered ordered threshold: actual color -// quantization rather than a disconnected stipple over the original photograph. -fn quantize(value: u8, threshold: u8) -> u8 { - let level = (value as f32 / 85.0 + (threshold as f32 + 0.5) / 16.0 - 0.5) - .round() - .clamp(0.0, 3.0); - (level * 85.0) as u8 +// Dither between a dark ink and a bright, hue-preserving source color. +// RGB-channel quantization mostly posterized the artwork and its fine Bayer +// pattern vanished when the source raster was downsampled. +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, + ] } fn ascii_cell_width(window: &gpui::Window, font: &gpui::Font, color: gpui::Hsla) -> f32 { @@ -343,36 +395,43 @@ fn paint_dot( mod tests { use super::*; + fn dither_fixture() -> BackgroundLuminance { + BackgroundLuminance { + width: 4, + height: 4, + pixels: vec![128; 16].into_boxed_slice(), + colors: vec![[128, 64, 32, 200]; 16].into_boxed_slice(), + dither: Default::default(), + } + } + #[test] - fn ordered_palette_preserves_endpoints_and_distributes_midtones() { + fn dither_has_visible_contrast_without_changing_hue_or_alpha() { + let dark = dither_color([128, 64, 32, 200], 15); + let bright = dither_color([128, 64, 32, 200], 0); + assert!(bright[0] - dark[0] > 200); + assert_eq!(bright, [255, 128, 64, 200]); + assert_eq!(dark[3], 200); for threshold in 0..16 { - assert_eq!(quantize(0, threshold), 0); - assert_eq!(quantize(255, threshold), 255); - for value in 0..=255 { - assert_eq!(quantize(value, threshold) % 85, 0); - } + assert_eq!(dither_color([0, 0, 0, 0], threshold), [0, 0, 0, 0]); + assert_eq!(dither_color([255, 255, 255, 255], threshold), [255; 4]); } - let levels: Vec<_> = (0..16).map(|threshold| quantize(128, threshold)).collect(); - assert!(levels.contains(&85)); - assert!(levels.contains(&170)); - let average = levels.iter().map(|&v| v as f32).sum::() / 16.0; - assert!((average - 128.0).abs() < 6.0); } #[test] - fn processed_artwork_is_reused_across_frames() { - let sample = BackgroundLuminance { - width: 4, - height: 4, - pixels: vec![128; 16].into_boxed_slice(), - colors: vec![[180, 100, 220, 255]; 16].into_boxed_slice(), - dither: Default::default(), - }; - let first = sample.dither_image().unwrap(); - assert!(std::sync::Arc::ptr_eq( - &first, - &sample.dither_image().unwrap() - )); + fn dither_cells_remain_two_pixels_across_window_sizes() { + let source = dither_fixture(); + for width in [320, 768, 2560] { + let pixels = source.dither_pixels(width, 8); + assert_eq!(pixels.dimensions(), (width, 8)); + for x in (0..width).step_by(2) { + assert_eq!(pixels.get_pixel(x, 0), pixels.get_pixel(x + 1, 0)); + assert_eq!(pixels.get_pixel(x, 0), pixels.get_pixel(x, 1)); + } + assert_ne!(pixels.get_pixel(0, 0), pixels.get_pixel(2, 0)); + // Direct GPU uploads are BGRA, including the original alpha. + assert_eq!(pixels.get_pixel(0, 0).0, [64, 128, 255, 200]); + } } #[gpui::test] @@ -388,7 +447,15 @@ mod tests { } } let handle = cx.add_window(|_, _| Fixture); - cx.update_window(handle.into(), |_, window, _| { + cx.update_window(handle.into(), |_, window, cx| { + let source = dither_fixture(); + let first = source.dither_image(320, 8, window, cx); + assert!(std::sync::Arc::ptr_eq( + &first, + &source.dither_image(320, 8, window, cx) + )); + let resized = source.dither_image(768, 8, window, cx); + assert!(!std::sync::Arc::ptr_eq(&first, &resized)); let font = gpui::font("Menlo"); let color = gpui::white(); let advance = ascii_cell_width(window, &font, color); From 8d86c0d1a395c041cbf11cc8a46d9be743513270 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 02:33:44 +0200 Subject: [PATCH 22/40] fix(ui): preserve background effects during panel resize saves --- crates/ui/src/shell.rs | 74 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index be88fc72d..677dc465e 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -3309,13 +3309,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.settings.new_thread_composer_background = - settings::current(cx).new_thread_composer_background; + 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); } @@ -8949,8 +8956,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.settings.new_thread_composer_background = - settings::current(cx).new_thread_composer_background; + 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 @@ -10548,6 +10554,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(); From b6ced5cbe81d654a4516874e8c779797eb6be0e9 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 02:45:38 +0200 Subject: [PATCH 23/40] perf(ui): coalesce background effect renders off the resize path --- .../ui/src/new_thread_background_effects.rs | 331 ++++++++++++------ 1 file changed, 227 insertions(+), 104 deletions(-) diff --git a/crates/ui/src/new_thread_background_effects.rs b/crates/ui/src/new_thread_background_effects.rs index d087362d4..0098f947d 100644 --- a/crates/ui/src/new_thread_background_effects.rs +++ b/crates/ui/src/new_thread_background_effects.rs @@ -13,7 +13,22 @@ const ASCII_FONT_SIZE: f32 = 6.0; const ASCII_LINE_HEIGHT: f32 = 8.0; const ASCII_STRENGTH: f32 = 0.72; -type DitherCache = Option<((u32, u32), std::sync::Arc)>; +type RasterKey = (u32, u32, NewThreadBackgroundEffect); + +#[derive(Debug, Default)] +struct RasterCache { + requested: Option, + running: bool, + ready: Option<(RasterKey, std::sync::Arc)>, + // A few recent sizes also prevent different windows from continuously + // invalidating each other's sole cached result when refreshed together. + older: Vec<(RasterKey, std::sync::Arc)>, +} + +type AsciiCache = Option<( + (u32, u32, gpui::Font), + std::sync::Arc>, +)>; #[derive(Debug)] struct BackgroundLuminance { @@ -21,50 +36,128 @@ struct BackgroundLuminance { height: u32, pixels: Box<[u8]>, colors: Box<[[u8; 4]]>, - dither: std::sync::Mutex, + dither: std::sync::Mutex, + ascii: std::sync::Mutex, } impl BackgroundLuminance { - fn dither_image( - &self, - width: u32, - height: u32, - window: &mut gpui::Window, + fn raster_image( + self: &std::sync::Arc, + key: RasterKey, cx: &mut gpui::App, - ) -> std::sync::Arc { - let mut cached = self - .dither - .lock() - .unwrap_or_else(|error| error.into_inner()); - if let Some((size, image)) = cached.as_ref() { - if *size == (width, height) { - return image.clone(); - } + ) -> Option> { + let mut cache = self.dither.lock().unwrap_or_else(|e| e.into_inner()); + cache.requested = Some(key); + if let Some((_, image)) = cache.older.iter().find(|(old, _)| *old == key) { + return Some(image.clone()); } - let pixels = self.dither_pixels(width, height); - let image = std::sync::Arc::new(gpui::RenderImage::new([image::Frame::new(pixels)])); - if let Some((_, previous)) = cached.replace(((width, height), image.clone())) { - gpui::ImageSource::Render(previous).evict(Some(window), cx); + let image = cache + .ready + .as_ref() + .filter(|(ready, _)| ready.2 == key.2) + .map(|(_, image)| image.clone()); + if cache.running || cache.ready.as_ref().is_some_and(|(ready, _)| *ready == key) { + return image; } + cache.running = true; + drop(cache); + let source = self.clone(); + cx.spawn(async move |cx| { + loop { + let key = source.dither.lock().unwrap().requested.unwrap(); + let worker = source.clone(); + let pixels = cx + .background_executor() + .spawn(async move { + match key.2 { + NewThreadBackgroundEffect::Halftone => { + worker.halftone_pixels(key.0, key.1) + } + _ => worker.dither_pixels(key.0, key.1), + } + }) + .await; + let next = std::sync::Arc::new(gpui::RenderImage::new([image::Frame::new(pixels)])); + let done = cx.update(|cx| { + let mut cache = source.dither.lock().unwrap(); + let current = cache.requested == Some(key); + // Never replace a visible result with an obsolete resize. + if current || cache.ready.is_none() { + if let Some(previous) = cache.ready.replace((key, next)) { + cache.older.push(previous); + if cache.older.len() > 3 { + let (_, expired) = cache.older.remove(0); + gpui::ImageSource::Render(expired).evict(None, cx); + } + } + cx.refresh_windows(); + } + if current { + cache.running = false; + } + current + }); + if done { + break; + } + } + }) + .detach(); image } + fn halftone_pixels(&self, width: u32, height: u32) -> image::RgbaImage { + let bounds = gpui::size(px(width as f32), px(height as f32)); + let mut pixels = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 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 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; + pixels.put_pixel( + x + dx, + y + dy, + image::Rgba([ + (b as f32 * coverage) as u8, + (g as f32 * coverage) as u8, + (r as f32 * coverage) as u8, + 255, + ]), + ); + } + } + } + } + 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)); - image::RgbaImage::from_fn(width, height, |x, y| { - // Two logical pixels per dot: screen-space, not source-space. - // Sampling once per cell keeps the stipple intact on detailed art. - let column = x / 2; - let row = y / 2; - let index = self.cover_index(bounds, (column * 2 + 1) as f32, (row * 2 + 1) as f32); - let [r, g, b, a] = dither_color( - self.colors[index], - BAYER[row as usize % 4][column as usize % 4], - ); - // RenderImage consumes BGRA, unlike image::Image's encoded decoder. - image::Rgba([b, g, r, a]) - }) + 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 { @@ -124,6 +217,7 @@ fn background_luminance(path: &Path) -> Option>() + .collect::>(); + let lines = std::sync::Arc::new(lines); + *cached = Some((key, lines.clone())); + lines }, move |bounds, ascii_lines, window, cx| match effect { NewThreadBackgroundEffect::None => {} @@ -255,37 +375,7 @@ pub(super) fn treatment( ); } } - NewThreadBackgroundEffect::Halftone => { - let step = 4.0; - let columns = (f32::from(bounds.size.width) / step).ceil() as usize; - let rows = (f32::from(bounds.size.height) / step).ceil() as usize; - for row in 0..rows { - for column in 0..columns { - let Some(luminance) = luminance.as_ref() else { - continue; - }; - let luma = luminance.sample_cover( - bounds.size, - column as f32 * step, - row as f32 * step, - ); - let dot = step * (0.3 + 0.7 * (luma as f32 / 255.0).sqrt()); - let color = luminance.color_cover( - bounds.size, - (column as f32 + 0.5) * step, - (row as f32 + 0.5) * step, - ); - paint_dot( - window, - bounds, - column as f32 * step + (step - dot) * 0.5, - row as f32 * step + (step - dot) * 0.5, - dot, - color, - ); - } - } - } + NewThreadBackgroundEffect::Halftone => {} NewThreadBackgroundEffect::Scanlines => { let rows = (f32::from(bounds.size.height) / 3.0).ceil() as usize; for row in 0..rows { @@ -370,31 +460,70 @@ fn ascii_cell_width(window: &gpui::Window, font: &gpui::Font, color: gpui::Hsla) f32::from(probe.width).max(1.0) } -fn paint_dot( - window: &mut gpui::Window, - bounds: gpui::Bounds, - x: f32, - y: f32, - diameter: f32, - color: gpui::Hsla, -) { - window.paint_quad(gpui::quad( - gpui::Bounds::new( - gpui::point(bounds.left() + px(x), bounds.top() + px(y)), - gpui::size(px(diameter), px(diameter)), - ), - px(diameter / 2.0), - color, - px(0.0), - gpui::transparent_black(), - BorderStyle::default(), - )); +fn ascii_bucket(size: gpui::Size) -> (u32, u32) { + ( + ((f32::from(size.width).max(1.0) / 32.0).ceil() as u32) * 32, + ((f32::from(size.height).max(1.0) / 8.0).ceil() as u32) * 8, + ) } #[cfg(test)] mod tests { use super::*; + #[gpui::test] + fn raster_requests_coalesce_and_keep_last_frame_until_ready(cx: &mut gpui::TestAppContext) { + let source = std::sync::Arc::new(dither_fixture()); + for effect in [ + NewThreadBackgroundEffect::Dither, + NewThreadBackgroundEffect::Halftone, + ] { + cx.update(|cx| { + for width in 320..420 { + let _ = source.raster_image((width, 16, effect), cx); + } + let cache = source.dither.lock().unwrap(); + assert!(cache.running); + assert_eq!(cache.requested, Some((419, 16, effect))); + }); + cx.run_until_parked(); + let first = { + let cache = source.dither.lock().unwrap(); + assert!(!cache.running); + assert_eq!(cache.ready.as_ref().unwrap().0, (419, 16, effect)); + cache.ready.as_ref().unwrap().1.clone() + }; + cx.update(|cx| { + let resized = source.raster_image((500, 16, effect), cx).unwrap(); + assert!(std::sync::Arc::ptr_eq(&first, &resized)); + }); + cx.run_until_parked(); + cx.update(|cx| { + let latest = source.raster_image((500, 16, effect), cx).unwrap(); + assert!(!std::sync::Arc::ptr_eq(&first, &latest)); + assert!(!source.dither.lock().unwrap().running); + let previous_size = source.raster_image((419, 16, effect), cx).unwrap(); + assert!(std::sync::Arc::ptr_eq(&first, &previous_size)); + assert!(!source.dither.lock().unwrap().running); + }); + } + } + + #[test] + fn ascii_resize_buckets_bound_rebuilds_and_cover_the_viewport() { + let mut buckets = std::collections::BTreeSet::new(); + for width in 800..1120 { + let bucket = ascii_bucket(gpui::size(px(width as f32), px(437.0))); + assert!(bucket.0 >= width && bucket.0 < width + 32); + assert_eq!(bucket.1, 440); + buckets.insert(bucket); + } + assert!( + buckets.len() <= 11, + "one-pixel resize frames should reuse shaped lines" + ); + } + fn dither_fixture() -> BackgroundLuminance { BackgroundLuminance { width: 4, @@ -402,6 +531,7 @@ mod tests { pixels: vec![128; 16].into_boxed_slice(), colors: vec![[128, 64, 32, 200]; 16].into_boxed_slice(), dither: Default::default(), + ascii: Default::default(), } } @@ -447,15 +577,7 @@ mod tests { } } let handle = cx.add_window(|_, _| Fixture); - cx.update_window(handle.into(), |_, window, cx| { - let source = dither_fixture(); - let first = source.dither_image(320, 8, window, cx); - assert!(std::sync::Arc::ptr_eq( - &first, - &source.dither_image(320, 8, window, cx) - )); - let resized = source.dither_image(768, 8, window, cx); - assert!(!std::sync::Arc::ptr_eq(&first, &resized)); + cx.update_window(handle.into(), |_, window, _| { let font = gpui::font("Menlo"); let color = gpui::white(); let advance = ascii_cell_width(window, &font, color); @@ -491,6 +613,7 @@ mod tests { pixels: vec![0, 1, 2, 3, 10, 11, 12, 13].into_boxed_slice(), colors: vec![[0, 0, 0, 255]; 8].into_boxed_slice(), dither: Default::default(), + ascii: Default::default(), }; let square = gpui::size(px(100.0), px(100.0)); assert_eq!(sample.sample_cover(square, 0.0, 0.0), 1); From 02cb283805188d774a684779083e903496e94e67 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 02:48:32 +0200 Subject: [PATCH 24/40] fix(ui): keep workspace footer aligned through composer docking --- crates/ui/src/composer.rs | 4 +- crates/ui/src/pickers.rs | 105 ++++++++++++++++++++++++++++---------- 2 files changed, 79 insertions(+), 30 deletions(-) diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index e37038f69..a91d906fd 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -7890,7 +7890,7 @@ impl Render for Composer { .inset_0() .px(px(10.0)) .flex() - .items_start() + .items_center() .opacity(new_thread_chrome_opacity) .children(new_thread_git_selectors), ) @@ -7906,7 +7906,7 @@ impl Render for Composer { .items_center() .opacity(session_chrome_opacity) .child(div().flex_1().min_w_0().children(footer.flatten())) - .child(div().pr(px(10.0)).mb(px(-8.0)).child( + .child(div().flex_none().pr(px(10.0)).child( crate::context_usage::render(usage, self.state.clone(), &theme), )), ) diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index aa7a76d2e..5ed759b41 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -33,6 +33,18 @@ 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}; @@ -2537,12 +2549,7 @@ impl Pickers { cx, ); Some( - div() - .flex_none() - .flex() - .flex_row() - .items_center() - .gap(px(4.0)) + workspace_footer_row() .child(attach_overlay_below( checkout_chip, &mut overlay, @@ -2591,14 +2598,7 @@ impl Pickers { // 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)) + workspace_footer_row().px(px(10.0)) }; if let Some(chat) = &session { @@ -2614,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() @@ -2632,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 @@ -2647,7 +2638,15 @@ impl Pickers { .map(SharedString::from) .unwrap_or_else(|| SharedString::from("No ref")), &theme, - )); + )) + .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, + )) + }); // 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()); @@ -2696,8 +2695,8 @@ impl Pickers { &theme, cx, ); - // Checkout on the left edge, ref on the right — the row's - // justify_between splits them. + // 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() @@ -2715,7 +2714,7 @@ impl Pickers { .flex_row() .items_center() .min_w_0() - .child(attach_overlay_end( + .child(attach_overlay( ref_chip, &mut overlay, PickerKind::Branch, @@ -4331,6 +4330,56 @@ 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)), + ) + } + } + 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() - 2..]; + 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()); + } + } + #[gpui::test] fn picker_completion_and_dismissal_have_distinct_focus_behavior(cx: &mut gpui::TestAppContext) { use std::cell::Cell; From 0176406e4f600d5472b52421dbfb081e6485d926 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 03:17:08 +0200 Subject: [PATCH 25/40] fix(ui): use source-space effects and anchor scroll control to composer --- .../ui/src/new_thread_background_effects.rs | 630 +++++------------- crates/ui/src/shell.rs | 62 +- crates/ui/src/transcript.rs | 39 +- 3 files changed, 217 insertions(+), 514 deletions(-) diff --git a/crates/ui/src/new_thread_background_effects.rs b/crates/ui/src/new_thread_background_effects.rs index 0098f947d..29d10c6e9 100644 --- a/crates/ui/src/new_thread_background_effects.rs +++ b/crates/ui/src/new_thread_background_effects.rs @@ -1,111 +1,108 @@ -//! Non-destructive treatments for the optional new-thread hero artwork. - -use std::path::{Path, PathBuf}; - -use gpui::{ - AnyElement, BorderStyle, Empty, IntoElement, Pixels, SharedString, TextRun, div, prelude::*, px, -}; - +//! Effects are source-space images. Resizing only changes ObjectFit::Cover. use crate::settings::NewThreadBackgroundEffect; use crate::theme::Theme; - -const ASCII_FONT_SIZE: f32 = 6.0; -const ASCII_LINE_HEIGHT: f32 = 8.0; -const ASCII_STRENGTH: f32 = 0.72; - -type RasterKey = (u32, u32, NewThreadBackgroundEffect); - -#[derive(Debug, Default)] -struct RasterCache { - requested: Option, - running: bool, - ready: Option<(RasterKey, std::sync::Arc)>, - // A few recent sizes also prevent different windows from continuously - // invalidating each other's sole cached result when refreshed together. - older: Vec<(RasterKey, std::sync::Arc)>, -} - -type AsciiCache = Option<( - (u32, u32, gpui::Font), - std::sync::Arc>, -)>; - +use gpui::{AnyElement, Empty, IntoElement, Pixels, prelude::*, px}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; +type EffectEntry = (NewThreadBackgroundEffect, Option>); #[derive(Debug)] struct BackgroundLuminance { width: u32, height: u32, pixels: Box<[u8]>, colors: Box<[[u8; 4]]>, - dither: std::sync::Mutex, - ascii: std::sync::Mutex, + effects: Mutex>, } - impl BackgroundLuminance { fn raster_image( - self: &std::sync::Arc, - key: RasterKey, + self: &Arc, + effect: NewThreadBackgroundEffect, cx: &mut gpui::App, - ) -> Option> { - let mut cache = self.dither.lock().unwrap_or_else(|e| e.into_inner()); - cache.requested = Some(key); - if let Some((_, image)) = cache.older.iter().find(|(old, _)| *old == key) { - return Some(image.clone()); + ) -> Option> { + let mut effects = self.effects.lock().unwrap(); + if let Some((_, image)) = effects.iter().find(|(key, _)| *key == effect) { + return image.clone(); } - let image = cache - .ready - .as_ref() - .filter(|(ready, _)| ready.2 == key.2) - .map(|(_, image)| image.clone()); - if cache.running || cache.ready.as_ref().is_some_and(|(ready, _)| *ready == key) { - return image; - } - cache.running = true; - drop(cache); + // None marks the single pending job for this source/effect, not a viewport. + effects.push((effect, None)); + drop(effects); let source = self.clone(); cx.spawn(async move |cx| { - loop { - let key = source.dither.lock().unwrap().requested.unwrap(); - let worker = source.clone(); - let pixels = cx - .background_executor() - .spawn(async move { - match key.2 { - NewThreadBackgroundEffect::Halftone => { - worker.halftone_pixels(key.0, key.1) - } - _ => worker.dither_pixels(key.0, key.1), + let worker = source.clone(); + let image = cx + .background_executor() + .spawn(async move { + let pixels = match effect { + NewThreadBackgroundEffect::Dither => { + worker.dither_pixels(worker.width, worker.height) } - }) - .await; - let next = std::sync::Arc::new(gpui::RenderImage::new([image::Frame::new(pixels)])); - let done = cx.update(|cx| { - let mut cache = source.dither.lock().unwrap(); - let current = cache.requested == Some(key); - // Never replace a visible result with an obsolete resize. - if current || cache.ready.is_none() { - if let Some(previous) = cache.ready.replace((key, next)) { - cache.older.push(previous); - if cache.older.len() > 3 { - let (_, expired) = cache.older.remove(0); - gpui::ImageSource::Render(expired).evict(None, cx); - } + NewThreadBackgroundEffect::Halftone => { + worker.halftone_pixels(worker.width, worker.height) } - cx.refresh_windows(); - } - if current { - cache.running = false; - } - current - }); - if done { - break; + NewThreadBackgroundEffect::Ascii => worker.ascii_pixels(), + _ => worker.scanline_pixels(), + }; + Arc::new(gpui::RenderImage::new([image::Frame::new(pixels)])) + }) + .await; + cx.update(|cx| { + if let Some((_, ready)) = source + .effects + .lock() + .unwrap() + .iter_mut() + .find(|(key, _)| *key == effect) + { + *ready = Some(image); } - } + cx.refresh_windows(); + }); }) .detach(); - image + None + } + fn scanline_pixels(&self) -> 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 }; + image::Rgba([ + (b as f32 * gain) as u8, + (g as f32 * gain) as u8, + (r as f32 * gain) as u8, + a, + ]) + }) + } + fn ascii_pixels(&self) -> 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 index = ((self.pixels[sample] 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| { + (base as f32 * 0.28 + if ink { glyph as f32 * 0.72 } else { 0.0 }) as u8 + }; + image::Rgba([mix(b, cb), mix(g, cg), mix(r, cr), a]) + }) } - fn halftone_pixels(&self, width: u32, height: u32) -> image::RgbaImage { let bounds = gpui::size(px(width as f32), px(height as f32)); let mut pixels = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 255])); @@ -164,11 +161,6 @@ impl BackgroundLuminance { self.pixels[self.cover_index(bounds, x, y)] } - fn color_cover(&self, bounds: gpui::Size, x: f32, y: f32) -> gpui::Hsla { - let [r, g, b, a] = self.colors[self.cover_index(bounds, x, y)]; - gpui::rgba(u32::from_be_bytes([r, g, b, a])).into() - } - 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); @@ -185,254 +177,62 @@ impl BackgroundLuminance { } } -fn needs_luminance(effect: NewThreadBackgroundEffect) -> bool { - matches!( - effect, - NewThreadBackgroundEffect::Dither - | NewThreadBackgroundEffect::Ascii - | NewThreadBackgroundEffect::Halftone - ) -} - -fn background_luminance(path: &Path) -> Option> { - type Cache = Vec<(PathBuf, std::sync::Arc)>; - static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); - let cache = CACHE.get_or_init(|| std::sync::Mutex::new(Vec::new())); - if let Some(sample) = cache +fn background_luminance(path: &Path) -> Option> { + type Cache = Vec<(PathBuf, Arc)>; + 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(|(cached, sample)| (cached == path).then(|| sample.clone())) + .find_map(|(key, source)| (key == path).then(|| source.clone())) { - return Some(sample); + return Some(source); } - - // Bound the retained color/luminance proxy independently of window size. - let decoded = image::ImageReader::open(path).ok()?.decode().ok()?; - let proxy = decoded.thumbnail(2048, 2048); + let proxy = image::ImageReader::open(path) + .ok()? + .decode() + .ok()? + .thumbnail(2048, 2048); let gray = proxy.to_luma8(); - let sample = std::sync::Arc::new(BackgroundLuminance { + let source = 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(), - dither: Default::default(), - ascii: Default::default(), + effects: Mutex::new(Vec::new()), }); let mut cache = cache.lock().ok()?; - cache.push((path.to_path_buf(), sample.clone())); + cache.push((path.to_path_buf(), source.clone())); if cache.len() > 4 { cache.remove(0); } - Some(sample) + Some(source) } - -/// Returns the image opacity and optional texture layer as one resolved -/// treatment. Unsupported raster decoding falls back to the original image. pub(super) fn treatment( - requested: NewThreadBackgroundEffect, - theme: &Theme, + effect: NewThreadBackgroundEffect, + _theme: &Theme, path: &Path, base_opacity: f32, + cx: &mut gpui::App, ) -> (f32, AnyElement) { - let luminance = needs_luminance(requested) - .then(|| background_luminance(path)) - .flatten(); - let effect = if needs_luminance(requested) && luminance.is_none() { - NewThreadBackgroundEffect::None - } else { - requested - }; - let image_opacity = base_opacity - * match effect { - NewThreadBackgroundEffect::None => 1.0, - NewThreadBackgroundEffect::Dither => 0.0, - NewThreadBackgroundEffect::Ascii => 0.0, - NewThreadBackgroundEffect::Halftone => 0.0, - NewThreadBackgroundEffect::Scanlines => 1.0, - }; if effect == NewThreadBackgroundEffect::None { - return (image_opacity, Empty.into_any_element()); + return (base_opacity, Empty.into_any_element()); } - // Cache one screen-sized raster. Resizes regenerate the crop and retire - // the previous GPU image; docking reuses it without resampling the dots. - if matches!( - effect, - NewThreadBackgroundEffect::Dither | NewThreadBackgroundEffect::Halftone - ) { - let source = luminance.expect("decoded dither source"); - let texture = gpui::canvas( - move |bounds, _, cx| { - let width = f32::from(bounds.size.width).ceil().clamp(1.0, 8192.0) as u32; - let height = f32::from(bounds.size.height).ceil().clamp(1.0, 440.0) as u32; - source.raster_image((width, height, effect), cx) - }, - |bounds, image, window, _| { - if let Some(image) = image { - let _ = window.paint_image(bounds, gpui::Corners::default(), image, 0, false); - } - }, - ) - .absolute() - .inset_0(); - return ( + match background_luminance(path).and_then(|source| source.raster_image(effect, cx)) { + Some(image) => ( 0.0, - div() + gpui::img(image) .absolute() .inset_0() + .size_full() + .object_fit(gpui::ObjectFit::Cover) .opacity(base_opacity) - .child( - gpui::img(path.to_path_buf()) - .absolute() - .inset_0() - .size_full() - .object_fit(gpui::ObjectFit::Cover), - ) - .child(texture) .into_any_element(), - ); + ), + None => (base_opacity, Empty.into_any_element()), } - - let color = gpui::white(); - let ascii_font = theme.font_mono.clone(); - let prepaint_luminance = luminance.clone(); - let texture = gpui::canvas( - move |bounds, window, _| { - if effect != NewThreadBackgroundEffect::Ascii { - return std::sync::Arc::new(Vec::new()); - } - let Some(luminance) = prepaint_luminance.as_ref() else { - return std::sync::Arc::new(Vec::new()); - }; - let font = gpui::font(ascii_font.clone()); - // Small overscan buckets avoid reshaping for every one-pixel drag. - // Paint remains clipped to the real bounds, including when shrinking. - let (width, height) = ascii_bucket(bounds.size); - let key = (width, height, font.clone()); - let mut cached = luminance.ascii.lock().unwrap_or_else(|e| e.into_inner()); - if let Some((previous, lines)) = cached.as_ref() { - if previous == &key { - return lines.clone(); - } - } - let sample_size = gpui::size(px(width as f32), px(height as f32)); - // Font size is not glyph advance. Using it as cell width made - // the rendered ASCII field stop halfway across the artwork and - // compressed its source sampling into the wrong horizontal span. - let cell_width = ascii_cell_width(window, &font, color); - let columns = (width as f32 / cell_width).ceil() as usize + 1; - let rows = (height as f32 / ASCII_LINE_HEIGHT).ceil() as usize + 1; - let ramp = b" .:-=+*#%@"; - let lines = (0..rows) - .map(|row| { - let mut text = String::with_capacity(columns); - let mut runs = Vec::with_capacity(columns); - for column in 0..columns { - let luma = luminance.sample_cover( - sample_size, - (column as f32 + 0.5) * cell_width, - (row as f32 + 0.5) * ASCII_LINE_HEIGHT, - ); - let index = - ((luma as f32 / 255.0).sqrt() * (ramp.len() - 1) as f32) as usize; - text.push(ramp[index] as char); - runs.push(TextRun { - len: 1, - font: font.clone(), - color: luminance.color_cover( - sample_size, - (column as f32 + 0.5) * cell_width, - (row as f32 + 0.5) * ASCII_LINE_HEIGHT, - ), - background_color: None, - underline: None, - strikethrough: None, - }); - } - let text: SharedString = text.into(); - window - .text_system() - .shape_line(text, px(ASCII_FONT_SIZE), &runs, None) - }) - .collect::>(); - let lines = std::sync::Arc::new(lines); - *cached = Some((key, lines.clone())); - lines - }, - move |bounds, ascii_lines, window, cx| match effect { - NewThreadBackgroundEffect::None => {} - NewThreadBackgroundEffect::Dither => {} - NewThreadBackgroundEffect::Ascii => { - let line_height = px(ASCII_LINE_HEIGHT); - for (row, line) in ascii_lines.iter().enumerate() { - let _ = line.paint( - gpui::point(bounds.left(), bounds.top() + line_height * row as f32), - line_height, - gpui::TextAlign::Left, - Some(bounds.size.width), - window, - cx, - ); - } - } - NewThreadBackgroundEffect::Halftone => {} - NewThreadBackgroundEffect::Scanlines => { - let rows = (f32::from(bounds.size.height) / 3.0).ceil() as usize; - for row in 0..rows { - window.paint_quad(gpui::quad( - gpui::Bounds::new( - gpui::point(bounds.left(), bounds.top() + px(row as f32 * 3.0)), - gpui::size(bounds.size.width, px(1.0)), - ), - px(0.0), - gpui::black().opacity(0.48), - px(0.0), - gpui::transparent_black(), - BorderStyle::default(), - )); - } - } - }, - ) - .absolute() - .inset_0(); - - let surface = div() - .absolute() - .inset_0() - .when(effect == NewThreadBackgroundEffect::Ascii, |surface| { - surface.opacity(ASCII_STRENGTH) - }) - .when( - matches!( - effect, - NewThreadBackgroundEffect::Ascii | NewThreadBackgroundEffect::Halftone - ), - |layer| layer.bg(gpui::black()), - ) - .child(texture); - // Mix the artwork and glyph treatment before applying glass transparency. - let layer = div() - .absolute() - .inset_0() - .opacity(base_opacity) - .when(effect == NewThreadBackgroundEffect::Ascii, |layer| { - layer.child( - gpui::img(path.to_path_buf()) - .absolute() - .inset_0() - .size_full() - .object_fit(gpui::ObjectFit::Cover), - ) - }) - .child(surface) - .into_any_element(); - (image_opacity, layer) } - -// Dither between a dark ink and a bright, hue-preserving source color. -// RGB-channel quantization mostly posterized the artwork and its fine Bayer -// pattern vanished when the source raster was downsampled. 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; @@ -445,187 +245,67 @@ fn dither_color([r, g, b, a]: [u8; 4], threshold: u8) -> [u8; 4] { ] } -fn ascii_cell_width(window: &gpui::Window, font: &gpui::Font, color: gpui::Hsla) -> f32 { - let run = TextRun { - len: 1, - font: font.clone(), - color, - background_color: None, - underline: None, - strikethrough: None, - }; - let probe = window - .text_system() - .shape_line("M".into(), px(ASCII_FONT_SIZE), &[run], None); - f32::from(probe.width).max(1.0) -} - -fn ascii_bucket(size: gpui::Size) -> (u32, u32) { - ( - ((f32::from(size.width).max(1.0) / 32.0).ceil() as u32) * 32, - ((f32::from(size.height).max(1.0) / 8.0).ceil() as u32) * 8, - ) -} - #[cfg(test)] mod tests { use super::*; - + 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 raster_requests_coalesce_and_keep_last_frame_until_ready(cx: &mut gpui::TestAppContext) { - let source = std::sync::Arc::new(dither_fixture()); + 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 width in 320..420 { - let _ = source.raster_image((width, 16, effect), cx); + for _ in 0..100 { + assert!(source.raster_image(effect, cx).is_none()); } - let cache = source.dither.lock().unwrap(); - assert!(cache.running); - assert_eq!(cache.requested, Some((419, 16, effect))); - }); - cx.run_until_parked(); - let first = { - let cache = source.dither.lock().unwrap(); - assert!(!cache.running); - assert_eq!(cache.ready.as_ref().unwrap().0, (419, 16, effect)); - cache.ready.as_ref().unwrap().1.clone() - }; - cx.update(|cx| { - let resized = source.raster_image((500, 16, effect), cx).unwrap(); - assert!(std::sync::Arc::ptr_eq(&first, &resized)); + assert_eq!( + source + .effects + .lock() + .unwrap() + .iter() + .filter(|(key, _)| *key == effect) + .count(), + 1 + ); }); cx.run_until_parked(); cx.update(|cx| { - let latest = source.raster_image((500, 16, effect), cx).unwrap(); - assert!(!std::sync::Arc::ptr_eq(&first, &latest)); - assert!(!source.dither.lock().unwrap().running); - let previous_size = source.raster_image((419, 16, effect), cx).unwrap(); - assert!(std::sync::Arc::ptr_eq(&first, &previous_size)); - assert!(!source.dither.lock().unwrap().running); + let first = source.raster_image(effect, 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, cx).unwrap() + )); + } }); } } - #[test] - fn ascii_resize_buckets_bound_rebuilds_and_cover_the_viewport() { - let mut buckets = std::collections::BTreeSet::new(); - for width in 800..1120 { - let bucket = ascii_bucket(gpui::size(px(width as f32), px(437.0))); - assert!(bucket.0 >= width && bucket.0 < width + 32); - assert_eq!(bucket.1, 440); - buckets.insert(bucket); - } - assert!( - buckets.len() <= 11, - "one-pixel resize frames should reuse shaped lines" - ); - } - - fn dither_fixture() -> BackgroundLuminance { - BackgroundLuminance { - width: 4, - height: 4, - pixels: vec![128; 16].into_boxed_slice(), - colors: vec![[128, 64, 32, 200]; 16].into_boxed_slice(), - dither: Default::default(), - ascii: Default::default(), - } - } - - #[test] - fn dither_has_visible_contrast_without_changing_hue_or_alpha() { - let dark = dither_color([128, 64, 32, 200], 15); - let bright = dither_color([128, 64, 32, 200], 0); - assert!(bright[0] - dark[0] > 200); - assert_eq!(bright, [255, 128, 64, 200]); - assert_eq!(dark[3], 200); - for threshold in 0..16 { - assert_eq!(dither_color([0, 0, 0, 0], threshold), [0, 0, 0, 0]); - assert_eq!(dither_color([255, 255, 255, 255], threshold), [255; 4]); - } - } - - #[test] - fn dither_cells_remain_two_pixels_across_window_sizes() { - let source = dither_fixture(); - for width in [320, 768, 2560] { - let pixels = source.dither_pixels(width, 8); - assert_eq!(pixels.dimensions(), (width, 8)); - for x in (0..width).step_by(2) { - assert_eq!(pixels.get_pixel(x, 0), pixels.get_pixel(x + 1, 0)); - assert_eq!(pixels.get_pixel(x, 0), pixels.get_pixel(x, 1)); - } - assert_ne!(pixels.get_pixel(0, 0), pixels.get_pixel(2, 0)); - // Direct GPU uploads are BGRA, including the original alpha. - assert_eq!(pixels.get_pixel(0, 0).0, [64, 128, 255, 200]); - } - } - - #[gpui::test] - fn ascii_advance_covers_narrow_and_fullscreen_artwork(cx: &mut gpui::TestAppContext) { - struct Fixture; - impl gpui::Render for Fixture { - fn render( - &mut self, - _: &mut gpui::Window, - _: &mut gpui::Context, - ) -> impl IntoElement { - div() - } + fn raster_treatments_preserve_source_dimensions_and_alpha() { + let source = fixture(); + for image in [ + source.dither_pixels(60, 32), + source.ascii_pixels(), + source.scanline_pixels(), + ] { + assert_eq!(image.dimensions(), (60, 32)); + assert!(image.pixels().all(|pixel| pixel.0[3] == 200)); } - let handle = cx.add_window(|_, _| Fixture); - cx.update_window(handle.into(), |_, window, _| { - let font = gpui::font("Menlo"); - let color = gpui::white(); - let advance = ascii_cell_width(window, &font, color); - for width in [320.0, 768.0, 2560.0] { - let columns = (width / advance).ceil() as usize + 1; - let run = TextRun { - len: columns, - font: font.clone(), - color, - background_color: None, - underline: None, - strikethrough: None, - }; - let line = window.text_system().shape_line( - "M".repeat(columns).into(), - px(ASCII_FONT_SIZE), - &[run], - None, - ); - let painted_width = f32::from(line.width); - assert!(painted_width >= width, "pattern stopped before {width}px"); - assert!(painted_width < width + 2.0 * advance + 0.1); - } - }) - .unwrap(); - } - - #[test] - fn cover_sampling_crops_the_long_axis_from_the_center() { - let sample = BackgroundLuminance { - width: 4, - height: 2, - pixels: vec![0, 1, 2, 3, 10, 11, 12, 13].into_boxed_slice(), - colors: vec![[0, 0, 0, 255]; 8].into_boxed_slice(), - dither: Default::default(), - ascii: Default::default(), - }; - let square = gpui::size(px(100.0), px(100.0)); - assert_eq!(sample.sample_cover(square, 0.0, 0.0), 1); - assert_eq!(sample.sample_cover(square, 99.0, 99.0), 12); - } - - #[test] - fn adaptive_effects_are_the_only_ones_that_need_pixels() { - assert!(!needs_luminance(NewThreadBackgroundEffect::None)); - assert!(needs_luminance(NewThreadBackgroundEffect::Dither)); - assert!(needs_luminance(NewThreadBackgroundEffect::Ascii)); - assert!(needs_luminance(NewThreadBackgroundEffect::Halftone)); - assert!(!needs_luminance(NewThreadBackgroundEffect::Scanlines)); + assert_eq!(source.halftone_pixels(60, 32).dimensions(), (60, 32)); } } diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 677dc465e..a06c818fe 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -827,6 +827,7 @@ fn new_thread_background( theme: &Theme, viewport_height: f32, dissolve: f32, + cx: &mut App, ) -> AnyElement { let Some(background) = background else { return Empty.into_any_element(); @@ -843,6 +844,7 @@ fn new_thread_background( theme, &path, new_thread_background_opacity(theme.is_frost()), + cx, ); // Image and treatment share a fixed crop and fade together in place. div() @@ -6905,6 +6907,7 @@ impl Shell { theme, self.viewport_height, dock_frame.dissolve(), + cx, ) }); @@ -7089,25 +7092,20 @@ impl Shell { // 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: @@ -7151,9 +7149,15 @@ impl Shell { el.child(crate::composer_dock::docked_composer( div() .id("persistent-composer") + .relative() .w(px(composer_width)) .mx_auto() - .child(self.composer.clone()), + .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, @@ -7182,25 +7186,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() diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index a819e8e92..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. @@ -2908,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; @@ -2932,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; } @@ -2995,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(); } } @@ -3046,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); } @@ -3212,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; } @@ -4176,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(); } @@ -7004,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) { @@ -7108,6 +7121,18 @@ impl Render for Transcript { 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(); From 4ae04d58c11ec7f417e9737980d841e42cb5a066 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 03:39:41 +0200 Subject: [PATCH 26/40] Polish composer spacing and adapt background effects to light themes --- crates/ui/src/composer.rs | 8 +- crates/ui/src/composer_dock.rs | 2 +- .../ui/src/new_thread_background_effects.rs | 148 ++++++++++++++---- crates/ui/src/pickers.rs | 40 +++-- crates/ui/src/shell.rs | 5 +- 5 files changed, 153 insertions(+), 50 deletions(-) diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index a91d906fd..946235197 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -78,7 +78,9 @@ 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; -const SESSION_FOOTER_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 @@ -7879,7 +7881,7 @@ impl Render for Composer { container.child( div() .w_full() - .h(px(NEW_THREAD_SELECTOR_ROW_HEIGHT * bottom_slot)) + .h(px(SESSION_FOOTER_HEIGHT * bottom_slot)) .mt(px(-Theme::SPACE_SM * (1.0 - bottom_slot))) .mb(px(-Theme::SPACE_SM * bottom_slot)) .relative() @@ -9255,8 +9257,8 @@ mod tests { #[test] fn new_thread_selectors_restore_the_compact_floating_row() { - assert_eq!(NEW_THREAD_SELECTOR_ROW_HEIGHT, SESSION_FOOTER_HEIGHT); assert_eq!(NEW_THREAD_SELECTOR_ROW_HEIGHT, 20.0); + assert_eq!(SESSION_FOOTER_HEIGHT, 24.0); } #[test] diff --git a/crates/ui/src/composer_dock.rs b/crates/ui/src/composer_dock.rs index 7dbaa579e..23617c257 100644 --- a/crates/ui/src/composer_dock.rs +++ b/crates/ui/src/composer_dock.rs @@ -283,7 +283,7 @@ impl Element for DockedComposer { let y = if docked { f32::from(bounds.top()) } else { - (self.viewport_height - f32::from(bounds.size.height)) * 0.5 - 16.0 + (self.viewport_height - f32::from(bounds.size.height)) * 0.5 + 8.0 }; let dt = if state.last_docked != docked { 0.0 diff --git a/crates/ui/src/new_thread_background_effects.rs b/crates/ui/src/new_thread_background_effects.rs index 29d10c6e9..eb96e2971 100644 --- a/crates/ui/src/new_thread_background_effects.rs +++ b/crates/ui/src/new_thread_background_effects.rs @@ -4,7 +4,10 @@ use crate::theme::Theme; use gpui::{AnyElement, Empty, IntoElement, Pixels, prelude::*, px}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; -type EffectEntry = (NewThreadBackgroundEffect, Option>); +type EffectEntry = ( + (NewThreadBackgroundEffect, bool), + Option>, +); #[derive(Debug)] struct BackgroundLuminance { width: u32, @@ -17,14 +20,16 @@ impl BackgroundLuminance { fn raster_image( self: &Arc, effect: NewThreadBackgroundEffect, + light: bool, cx: &mut gpui::App, ) -> Option> { let mut effects = self.effects.lock().unwrap(); - if let Some((_, image)) = effects.iter().find(|(key, _)| *key == effect) { + let key = (effect, light && effect != NewThreadBackgroundEffect::Dither); + 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((effect, None)); + effects.push((key, None)); drop(effects); let source = self.clone(); cx.spawn(async move |cx| { @@ -37,10 +42,10 @@ impl BackgroundLuminance { worker.dither_pixels(worker.width, worker.height) } NewThreadBackgroundEffect::Halftone => { - worker.halftone_pixels(worker.width, worker.height) + worker.halftone_pixels(worker.width, worker.height, light) } - NewThreadBackgroundEffect::Ascii => worker.ascii_pixels(), - _ => worker.scanline_pixels(), + NewThreadBackgroundEffect::Ascii => worker.ascii_pixels(light), + _ => worker.scanline_pixels(light), }; Arc::new(gpui::RenderImage::new([image::Frame::new(pixels)])) }) @@ -51,7 +56,7 @@ impl BackgroundLuminance { .lock() .unwrap() .iter_mut() - .find(|(key, _)| *key == effect) + .find(|(cached, _)| *cached == key) { *ready = Some(image); } @@ -61,19 +66,21 @@ impl BackgroundLuminance { .detach(); None } - fn scanline_pixels(&self) -> image::RgbaImage { + 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 }; - image::Rgba([ - (b as f32 * gain) as u8, - (g as f32 * gain) as u8, - (r as f32 * gain) as u8, - a, - ]) + 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) -> image::RgbaImage { + 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] = [ @@ -92,23 +99,37 @@ impl BackgroundLuminance { 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 index = ((self.pixels[sample] as f32 / 255.0).sqrt() * 9.0) 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| { - (base as f32 * 0.28 + if ink { glyph as f32 * 0.72 } else { 0.0 }) as u8 + let paper = if light { 255.0 } else { 0.0 }; + (base as f32 * 0.28 + + if ink { + glyph as f32 * 0.72 + } else { + paper * 0.72 + }) as u8 }; image::Rgba([mix(b, cb), mix(g, cg), mix(r, cr), a]) }) } - fn halftone_pixels(&self, width: u32, height: u32) -> image::RgbaImage { + 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 mut pixels = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 255])); + 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)]; @@ -121,9 +142,9 @@ impl BackgroundLuminance { x + dx, y + dy, image::Rgba([ - (b as f32 * coverage) as u8, - (g as f32 * coverage) as u8, - (r as f32 * coverage) as u8, + (b as f32 * coverage + paper as f32 * (1.0 - coverage)) as u8, + (g as f32 * coverage + paper as f32 * (1.0 - coverage)) as u8, + (r as f32 * coverage + paper as f32 * (1.0 - coverage)) as u8, 255, ]), ); @@ -211,7 +232,7 @@ fn background_luminance(path: &Path) -> Option> { } pub(super) fn treatment( effect: NewThreadBackgroundEffect, - _theme: &Theme, + theme: &Theme, path: &Path, base_opacity: f32, cx: &mut gpui::App, @@ -219,7 +240,8 @@ pub(super) fn treatment( if effect == NewThreadBackgroundEffect::None { return (base_opacity, Empty.into_any_element()); } - match background_luminance(path).and_then(|source| source.raster_image(effect, cx)) { + let light = matches!(theme.appearance, crate::theme::Appearance::Light); + match background_luminance(path).and_then(|source| source.raster_image(effect, light, cx)) { Some(image) => ( 0.0, gpui::img(image) @@ -268,7 +290,7 @@ mod tests { ] { cx.update(|cx| { for _ in 0..100 { - assert!(source.raster_image(effect, cx).is_none()); + assert!(source.raster_image(effect, false, cx).is_none()); } assert_eq!( source @@ -276,36 +298,100 @@ mod tests { .lock() .unwrap() .iter() - .filter(|(key, _)| *key == effect) + .filter(|(key, _)| *key == (effect, false)) .count(), 1 ); }); cx.run_until_parked(); cx.update(|cx| { - let first = source.raster_image(effect, cx).unwrap(); + 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, cx).unwrap() + &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(), - source.scanline_pixels(), + 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).dimensions(), (60, 32)); + assert_eq!(source.halftone_pixels(60, 32, false).dimensions(), (60, 32)); } } diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index 5ed759b41..38eafb60f 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -2638,18 +2638,27 @@ impl Pickers { .map(SharedString::from) .unwrap_or_else(|| SharedString::from("No ref")), &theme, - )) - .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, - )) - }); - // 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 + @@ -4352,7 +4361,9 @@ mod tests { workspace_footer_row() .px(px(10.0)) .child(chip(120.0)) - .child(chip(90.0)), + .child(chip(90.0)) + .child(div().flex_1().min_w_0()) + .child(chip(60.0)), ) } } @@ -4373,10 +4384,11 @@ mod tests { cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) .unwrap(); let measured = bounds.borrow(); - let pair = &measured[measured.len() - 2..]; + 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); } } diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index a06c818fe..4c8a28ce2 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -862,7 +862,7 @@ fn new_thread_background( .child( crate::edge_fade::edge_faded( 0.0, - false, + true, true, // Give the mask a definite relayout box. A percentage-sized // image as the custom element's direct child could briefly @@ -881,6 +881,9 @@ fn new_thread_background( ) .child(effect_layer), ) + // A shallow native alpha fade restores the theme surface under + // window controls without a hard toolbar band or another overlay. + .band_top(Theme::TITLEBAR_HEIGHT + 16.0) .band_bottom(hero_height * NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO), ) .into_any_element() From e428d374a862f814ad690554ec1f7b4ce44e5160 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 03:44:12 +0200 Subject: [PATCH 27/40] Soften the light-theme composer background feather --- crates/ui/src/shell.rs | 49 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 4c8a28ce2..7e49b8aed 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -821,6 +821,23 @@ fn new_thread_background_height(viewport_height: f32) -> f32 { .min(NEW_THREAD_BACKGROUND_MAX_HEIGHT) } +fn new_thread_background_fade( + appearance: crate::theme::Appearance, + height: f32, +) -> (f32, f32, f32) { + let top = Theme::TITLEBAR_HEIGHT + 16.0; + match appearance { + // Light canvas against saturated artwork needs a longer feather. Start + // above the window so the top retains a trace of artwork, rather than + // reading as a solid white strip. This changes only the native mask: + // image crop, effect cache, and docking geometry remain untouched. + crate::theme::Appearance::Light => (top * 2.0, -24.0, height * 0.72), + crate::theme::Appearance::Dark => { + (top, 0.0, height * NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO) + } + } +} + fn new_thread_background( background: Option<&settings::NewThreadComposerBackground>, effect: settings::NewThreadBackgroundEffect, @@ -837,6 +854,8 @@ fn new_thread_background( return Empty.into_any_element(); } let hero_height = new_thread_background_height(viewport_height); + let (top_band, top_inset, bottom_band) = + new_thread_background_fade(theme.appearance, hero_height); let dissolve = dissolve.clamp(0.0, 1.0); let (image_opacity, effect_layer) = crate::new_thread_background_effects::treatment( @@ -883,8 +902,9 @@ fn new_thread_background( ) // A shallow native alpha fade restores the theme surface under // window controls without a hard toolbar band or another overlay. - .band_top(Theme::TITLEBAR_HEIGHT + 16.0) - .band_bottom(hero_height * NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO), + .band_top(top_band) + .inset_top(top_inset) + .band_bottom(bottom_band), ) .into_any_element() } @@ -9494,6 +9514,31 @@ mod tests { } } + #[test] + fn light_hero_feather_is_gradual_without_changing_dark_mode() { + for viewport in [400.0, 600.0, 1000.0, 2000.0] { + let height = new_thread_background_height(viewport); + let (dark_top, dark_inset, dark_bottom) = + new_thread_background_fade(crate::theme::Appearance::Dark, height); + assert_eq!(dark_top, Theme::TITLEBAR_HEIGHT + 16.0); + assert_eq!(dark_inset, 0.0); + assert_eq!( + dark_bottom, + height * NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO + ); + let (top, inset, bottom) = + new_thread_background_fade(crate::theme::Appearance::Light, height); + assert!(top > dark_top && bottom > dark_bottom); + assert!(bottom < height); + // Native edge fade uses a squared ramp: retain a little artwork + // at the edge but keep most of the theme surface under controls. + let alpha = |y: f32| ((y - inset) / top).clamp(0.0, 1.0).powi(2); + assert!(alpha(0.0) > 0.0 && alpha(0.0) < 0.1); + assert!(alpha(Theme::TITLEBAR_HEIGHT * 0.5) < 0.2); + assert_eq!(alpha(top + inset), 1.0); + } + } + #[test] fn new_thread_handoff_is_continuous_and_staged() { assert!(bottom_stack_measurement_matches(false, false)); From 368232d68a3fcaaa9239c25b2799f0e7ae99ca37 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 03:52:01 +0200 Subject: [PATCH 28/40] Replace hero top fade with animated floating control island --- crates/ui/src/shell.rs | 91 ++++++++++++++++++++++++++---------------- 1 file changed, 57 insertions(+), 34 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 7e49b8aed..5c8ee8901 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -821,20 +821,12 @@ fn new_thread_background_height(viewport_height: f32) -> f32 { .min(NEW_THREAD_BACKGROUND_MAX_HEIGHT) } -fn new_thread_background_fade( - appearance: crate::theme::Appearance, - height: f32, -) -> (f32, f32, f32) { - let top = Theme::TITLEBAR_HEIGHT + 16.0; +fn new_thread_background_bottom_band(appearance: crate::theme::Appearance, height: f32) -> f32 { match appearance { - // Light canvas against saturated artwork needs a longer feather. Start - // above the window so the top retains a trace of artwork, rather than - // reading as a solid white strip. This changes only the native mask: - // image crop, effect cache, and docking geometry remain untouched. - crate::theme::Appearance::Light => (top * 2.0, -24.0, height * 0.72), - crate::theme::Appearance::Dark => { - (top, 0.0, height * NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO) - } + // Only the lower feather remains; toolbar contrast belongs to the + // floating island, not a window-wide strip over the artwork. + crate::theme::Appearance::Light => height * 0.72, + crate::theme::Appearance::Dark => height * NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO, } } @@ -854,8 +846,7 @@ fn new_thread_background( return Empty.into_any_element(); } let hero_height = new_thread_background_height(viewport_height); - let (top_band, top_inset, bottom_band) = - new_thread_background_fade(theme.appearance, hero_height); + let bottom_band = new_thread_background_bottom_band(theme.appearance, hero_height); let dissolve = dissolve.clamp(0.0, 1.0); let (image_opacity, effect_layer) = crate::new_thread_background_effects::treatment( @@ -881,7 +872,7 @@ fn new_thread_background( .child( crate::edge_fade::edge_faded( 0.0, - true, + false, true, // Give the mask a definite relayout box. A percentage-sized // image as the custom element's direct child could briefly @@ -900,10 +891,6 @@ fn new_thread_background( ) .child(effect_layer), ) - // A shallow native alpha fade restores the theme surface under - // window controls without a hard toolbar band or another overlay. - .band_top(top_band) - .inset_top(top_inset) .band_bottom(bottom_band), ) .into_any_element() @@ -1487,6 +1474,7 @@ pub struct Shell { 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, @@ -1790,6 +1778,7 @@ impl Shell { terminal_tween: None, fullscreen: None, titlebar_tween: None, + titlebar_island: None, titlebar_should_move: false, linux_captions: None, button_layout_sub: None, @@ -4669,6 +4658,29 @@ 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); div() .absolute() .top_0() @@ -4679,6 +4691,26 @@ impl Shell { .items_center() .pt(px(Theme::TITLEBAR_TOP_PAD)) .px(px(TITLEBAR_CLUSTER_PAD)) + .child( + div() + .absolute() + .left(px(6.0)) + .right(px(2.0)) + .top(px(4.0 + 2.0 * (1.0 - island))) + .h(px(30.0 - 4.0 * (1.0 - island))) + .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 @@ -9515,27 +9547,18 @@ mod tests { } #[test] - fn light_hero_feather_is_gradual_without_changing_dark_mode() { + fn lower_hero_feather_preserves_appearance_specific_spacing() { for viewport in [400.0, 600.0, 1000.0, 2000.0] { let height = new_thread_background_height(viewport); - let (dark_top, dark_inset, dark_bottom) = - new_thread_background_fade(crate::theme::Appearance::Dark, height); - assert_eq!(dark_top, Theme::TITLEBAR_HEIGHT + 16.0); - assert_eq!(dark_inset, 0.0); + let dark_bottom = + new_thread_background_bottom_band(crate::theme::Appearance::Dark, height); assert_eq!( dark_bottom, height * NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO ); - let (top, inset, bottom) = - new_thread_background_fade(crate::theme::Appearance::Light, height); - assert!(top > dark_top && bottom > dark_bottom); + let bottom = new_thread_background_bottom_band(crate::theme::Appearance::Light, height); + assert!(bottom > dark_bottom); assert!(bottom < height); - // Native edge fade uses a squared ramp: retain a little artwork - // at the edge but keep most of the theme surface under controls. - let alpha = |y: f32| ((y - inset) / top).clamp(0.0, 1.0).powi(2); - assert!(alpha(0.0) > 0.0 && alpha(0.0) < 0.1); - assert!(alpha(Theme::TITLEBAR_HEIGHT * 0.5) < 0.2); - assert_eq!(alpha(top + inset), 1.0); } } From c84abb69885ed417daf5045564fa5ac7a55d9416 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 03:55:31 +0200 Subject: [PATCH 29/40] Align and tighten the floating titlebar island --- crates/ui/src/shell.rs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 5c8ee8901..e88ce6669 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -801,6 +801,14 @@ 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 2px of air. + let height = 24.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, @@ -4681,6 +4689,7 @@ impl Shell { _ => {} } let island = self.eval_tween(self.titlebar_island, island_target); + let (island_top, island_height) = titlebar_island_vertical_geometry(island); div() .absolute() .top_0() @@ -4694,10 +4703,10 @@ impl Shell { .child( div() .absolute() - .left(px(6.0)) - .right(px(2.0)) - .top(px(4.0 + 2.0 * (1.0 - island))) - .h(px(30.0 - 4.0 * (1.0 - island))) + .left(px(8.0)) + .right(px(4.0)) + .top(px(island_top)) + .h(px(island_height)) .opacity(island) .children((island > 0.001).then(|| { crate::frost::frosted( @@ -9546,6 +9555,19 @@ 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!((24.0..=28.0).contains(&height)); + } + let (top, height) = titlebar_island_vertical_geometry(1.0); + assert_eq!(center - 12.0 - top, 2.0); + assert_eq!(top + height - (center + 12.0), 2.0); + } + #[test] fn lower_hero_feather_preserves_appearance_specific_spacing() { for viewport in [400.0, 600.0, 1000.0, 2000.0] { From b281d4a8072fe3f4e398fe836614bebdaf9556b1 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 04:02:10 +0200 Subject: [PATCH 30/40] Align titlebar controls with native lights and relax island padding --- crates/ui/src/lib.rs | 4 ++-- crates/ui/src/shell.rs | 15 ++++++++------- crates/ui/src/theme.rs | 5 +++-- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index ed2378bd0..aab052631 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -289,8 +289,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/shell.rs b/crates/ui/src/shell.rs index e88ce6669..d959877a3 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -803,8 +803,8 @@ 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 2px of air. - let height = 24.0 + 4.0 * progress.clamp(0.0, 1.0); + // 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) } @@ -4703,8 +4703,8 @@ impl Shell { .child( div() .absolute() - .left(px(8.0)) - .right(px(4.0)) + .left(px(6.0)) + .right_0() .top(px(island_top)) .h(px(island_height)) .opacity(island) @@ -9561,11 +9561,12 @@ mod tests { 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!((24.0..=28.0).contains(&height)); + assert!((28.0..=32.0).contains(&height)); } let (top, height) = titlebar_island_vertical_geometry(1.0); - assert_eq!(center - 12.0 - top, 2.0); - assert_eq!(top + height - (center + 12.0), 2.0); + assert_eq!(center, 21.0); + assert_eq!(center - 12.0 - top, 4.0); + assert_eq!(top + height - (center + 12.0), 4.0); } #[test] 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; From b1571020254efcc7f89818b78421ff635b962195 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 12:29:31 +0200 Subject: [PATCH 31/40] Align rebased Appshot docking measurement and format conflict resolutions --- crates/ui/src/composer.rs | 14 +++++++++++--- crates/ui/src/shell.rs | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 946235197..236eaade2 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -7560,7 +7560,10 @@ impl Render for Composer { } self.last_rendered_height = pill_height; 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 + 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() { @@ -7569,8 +7572,13 @@ impl Render for Composer { 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) - comment_strip_h - PILL_BORDER_V - ACTIONS_ROW_HEIGHT).max(0.0); + let textarea_height = (pill_height + - strip_h + - appshot_strip_height(appshot_count) + - comment_strip_h + - PILL_BORDER_V + - ACTIONS_ROW_HEIGHT) + .max(0.0); self.input.update(cx, |input, cx| { let height = if expanded { (textarea_height - text_pt - 4.0).max(0.0) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index d959877a3..94ba73554 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -7182,7 +7182,7 @@ impl Shell { .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_selection; + let contains_composer = (has_spaces || no_project || has_appshots) && has_selection; let composer = self.composer.clone(); div() .flex_none() From a55769aff2ab0ad1a87291774c6c81c281ee2515 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 14:17:48 +0200 Subject: [PATCH 32/40] Polish pane resize feedback and hit targets --- crates/ui/src/files/mod.rs | 15 +- crates/ui/src/files/preview.rs | 171 +++++++++++++- crates/ui/src/motion.rs | 85 +++++++ crates/ui/src/settings.rs | 11 +- crates/ui/src/shell.rs | 414 ++++++++++++++++++++++++++++----- crates/ui/src/shell/tabs.rs | 2 +- 6 files changed, 626 insertions(+), 72 deletions(-) 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/motion.rs b/crates/ui/src/motion.rs index ac6b041c2..4849c5832 100644 --- a/crates/ui/src/motion.rs +++ b/crates/ui/src/motion.rs @@ -382,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) // --------------------------------------------------------------------------- diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index 243ef8638..a3cdb6c39 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; @@ -1756,6 +1756,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/shell.rs b/crates/ui/src/shell.rs index 94ba73554..371a423e7 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) @@ -702,6 +704,30 @@ 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, @@ -1460,7 +1486,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, @@ -1777,7 +1814,13 @@ 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, @@ -2229,7 +2272,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 { @@ -2241,7 +2284,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); @@ -2251,7 +2298,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); @@ -3277,7 +3327,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(); @@ -3290,13 +3345,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, @@ -3308,11 +3386,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; @@ -4472,6 +4569,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 @@ -4489,23 +4621,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 @@ -4515,19 +4630,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() @@ -4976,20 +5093,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 @@ -6843,6 +6957,7 @@ impl Shell { fn resize_handle( &self, id: &'static str, + kind: PaneResizeKind, marker: fn() -> T, reset: fn(&mut Shell, &mut Context), cx: &mut Context, @@ -6852,12 +6967,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() @@ -6876,7 +7002,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() @@ -6891,18 +7017,37 @@ 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(); }), ) } @@ -7366,21 +7511,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| { @@ -7389,19 +7561,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)) @@ -7632,9 +7815,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(), ) } @@ -8312,7 +8500,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); @@ -9299,7 +9490,7 @@ impl Render for Shell { // sizes itself to the viewport. self.viewport_width = viewport; let main_target_width = - conversation_width(viewport, self.sidebar_target(), self.right_target(cx)); + conversation_width(viewport, self.sidebar_target(), self.right_now(cx)); let main_transition = self.active_tween_endpoints(self.main_takeover_tween); let main_content_width = stable_panel_content_width(main_target_width, main_transition); @@ -9327,8 +9518,12 @@ impl Render for Shell { 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, main_content_width, cx); @@ -9346,8 +9541,12 @@ impl Render for Shell { .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 @@ -9398,7 +9597,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. @@ -9423,7 +9622,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() @@ -9541,6 +9740,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 @@ -9732,6 +10032,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] 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 From f8b3ceebccb81bf84bd23f6689355927ce94b570 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 14:48:17 +0200 Subject: [PATCH 33/40] Fix macOS resize fixture boundary --- crates/ui/examples/browser-fixture.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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(); From e90471b0cf20fac90bd5c23173fa1d574d23cbf7 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Sun, 13 Sep 2026 17:36:29 +0200 Subject: [PATCH 34/40] Restore compact message queue tray --- crates/ui/src/queue.rs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) 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() } From 51470ab98f75e3365bd67b272552e20e5149996c Mon Sep 17 00:00:00 2001 From: gaelcado Date: Mon, 14 Sep 2026 00:58:42 +0200 Subject: [PATCH 35/40] Preserve source colors beneath ASCII and halftone textures --- .../ui/src/new_thread_background_effects.rs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/crates/ui/src/new_thread_background_effects.rs b/crates/ui/src/new_thread_background_effects.rs index eb96e2971..416b22db1 100644 --- a/crates/ui/src/new_thread_background_effects.rs +++ b/crates/ui/src/new_thread_background_effects.rs @@ -111,11 +111,12 @@ impl BackgroundLuminance { let [cr, cg, cb, _] = self.colors[sample]; let mix = |base: u8, glyph: u8| { let paper = if light { 255.0 } else { 0.0 }; - (base as f32 * 0.28 + // Keep a colored image beneath the glyph texture in both themes. + (base as f32 * 0.60 + if ink { - glyph as f32 * 0.72 + glyph as f32 * 0.40 } else { - paper * 0.72 + paper * 0.40 }) as u8 }; image::Rgba([mix(b, cb), mix(g, cg), mix(r, cr), a]) @@ -138,15 +139,16 @@ impl BackgroundLuminance { 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([ - (b as f32 * coverage + paper as f32 * (1.0 - coverage)) as u8, - (g as f32 * coverage + paper as f32 * (1.0 - coverage)) as u8, - (r as f32 * coverage + paper as f32 * (1.0 - coverage)) as u8, - 255, - ]), + image::Rgba([blend(sb, b), blend(sg, g), blend(sr, r), sa]), ); } } From c0dab92a7402f1a010f14fe7ef6161d09af972c1 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Mon, 14 Sep 2026 00:59:04 +0200 Subject: [PATCH 36/40] Feather background artwork around the measured composer --- crates/ui/src/composer.rs | 26 +++ crates/ui/src/lib.rs | 1 + .../ui/src/new_thread_background_effects.rs | 47 +++-- crates/ui/src/new_thread_background_mask.rs | 168 ++++++++++++++++++ crates/ui/src/shell.rs | 88 +++------ 5 files changed, 253 insertions(+), 77 deletions(-) create mode 100644 crates/ui/src/new_thread_background_mask.rs diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 236eaade2..81362ea9a 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -4118,6 +4118,7 @@ pub struct Composer { last_rendered_height: f32, dock_frame: Option, dock_clearance_correction: f32, + hero_surface_bounds: Rc>>>, last_target_height: f32, height_morph: Option, /// Monotonic clock anchor for the morph timeline. @@ -4154,6 +4155,10 @@ impl Composer { self.dock_clearance_correction } + pub(crate) fn hero_surface_bounds(&self) -> Option> { + self.hero_surface_bounds.get() + } + /// The picker entity, for the shell's canvas target selectors. pub fn pickers(&self) -> &Entity { &self.pickers @@ -4306,6 +4311,7 @@ impl Composer { last_rendered_height: 0.0, dock_frame: None, dock_clearance_correction: 0.0, + hero_surface_bounds: Default::default(), last_target_height: 0.0, height_morph: None, morph_clock: Instant::now(), @@ -7830,6 +7836,26 @@ impl Render for Composer { .relative() .id("composer-surface") .child(crate::frost::frosted(surface_radius, 16.0, body)) + .when( + self.dock_frame + .is_some_and(|frame| !frame.docked && frame.amount == 0.0), + |el| { + let measured = self.hero_surface_bounds.clone(); + el.child( + gpui::canvas( + move |bounds, window, _| { + if measured.get() != Some(bounds) { + measured.set(Some(bounds)); + window.request_animation_frame(); + } + }, + |_, _, _, _| {}, + ) + .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)) diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index aab052631..859b8aed9 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -37,6 +37,7 @@ pub mod loaders; pub mod markdown; pub mod motion; mod new_thread_background_effects; +mod new_thread_background_mask; pub mod notify; pub mod pickers; pub mod popover; diff --git a/crates/ui/src/new_thread_background_effects.rs b/crates/ui/src/new_thread_background_effects.rs index 416b22db1..4c5982f74 100644 --- a/crates/ui/src/new_thread_background_effects.rs +++ b/crates/ui/src/new_thread_background_effects.rs @@ -1,4 +1,4 @@ -//! Effects are source-space images. Resizing only changes ObjectFit::Cover. +//! Effects remain cached source-space images; a separate alpha mask follows layout. use crate::settings::NewThreadBackgroundEffect; use crate::theme::Theme; use gpui::{AnyElement, Empty, IntoElement, Pixels, prelude::*, px}; @@ -24,7 +24,14 @@ impl BackgroundLuminance { cx: &mut gpui::App, ) -> Option> { let mut effects = self.effects.lock().unwrap(); - let key = (effect, light && effect != NewThreadBackgroundEffect::Dither); + let key = ( + effect, + light + && !matches!( + effect, + NewThreadBackgroundEffect::Dither | NewThreadBackgroundEffect::None + ), + ); if let Some((_, image)) = effects.iter().find(|(cached, _)| *cached == key) { return image.clone(); } @@ -38,6 +45,12 @@ impl BackgroundLuminance { .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) } @@ -237,24 +250,22 @@ pub(super) fn treatment( theme: &Theme, path: &Path, base_opacity: f32, + mask: crate::new_thread_background_mask::Mask, cx: &mut gpui::App, -) -> (f32, AnyElement) { - if effect == NewThreadBackgroundEffect::None { - return (base_opacity, Empty.into_any_element()); - } +) -> AnyElement { let light = matches!(theme.appearance, crate::theme::Appearance::Light); - match background_luminance(path).and_then(|source| source.raster_image(effect, light, cx)) { - Some(image) => ( - 0.0, - gpui::img(image) - .absolute() - .inset_0() - .size_full() - .object_fit(gpui::ObjectFit::Cover) - .opacity(base_opacity) - .into_any_element(), - ), - None => (base_opacity, Empty.into_any_element()), + match background_luminance(path) + .and_then(|source| source.raster_image(effect, light, cx)) + .and_then(|source| crate::new_thread_background_mask::image(source, mask, cx)) + { + Some(image) => gpui::img(image) + .absolute() + .inset_0() + .size_full() + .object_fit(gpui::ObjectFit::Cover) + .opacity(base_opacity) + .into_any_element(), + None => Empty.into_any_element(), } } fn dither_color([r, g, b, a]: [u8; 4], threshold: u8) -> [u8; 4] { 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..222efe608 --- /dev/null +++ b/crates/ui/src/new_thread_background_mask.rs @@ -0,0 +1,168 @@ +//! A source-alpha feather following the measured composer, independent of theme. +//! Raster work is coalesced off-thread; route opacity never invalidates the mask. +use std::sync::{Arc, Mutex, OnceLock}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Mask { + pub width: f32, + pub height: f32, + pub left: f32, + pub top: f32, + pub right: f32, + pub bottom: f32, + pub radius: f32, +} + +fn smooth(value: f32) -> f32 { + let t = value.clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +impl Mask { + fn alpha(self, x: f32, y: f32) -> f32 { + // Signed distance to the actual rounded surface, with a small quiet + // margin. Equal-distance contours naturally wrap its top corners. + let radius = self.radius.min((self.right - self.left).max(0.0) * 0.5); + let qx = + (x - (self.left + self.right) * 0.5).abs() - ((self.right - self.left) * 0.5 - radius); + let qy = + (y - (self.top + self.bottom) * 0.5).abs() - ((self.bottom - self.top) * 0.5 - radius); + let distance = qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius; + let feather = (self.height * 0.52).clamp(120.0, 220.0); + let around_composer = smooth((distance - 8.0) / feather); + // Finish the side tails smoothly as well, without imposing a straight + // wide wash across the middle of the artwork. + let bottom = smooth((self.height - y) / (self.height * 0.22).max(1.0)); + around_composer.min(bottom) + } + + fn raster(self, source: &gpui::RenderImage) -> image::RgbaImage { + let size = source.size(0); + let width = size.width.0 as u32; + let height = size.height.0 as u32; + let mut pixels = + image::RgbaImage::from_raw(width, height, source.as_bytes(0).unwrap().to_vec()) + .unwrap(); + let scale = (self.width / width as f32).max(self.height / height as f32); + let offset_x = (self.width - width as f32 * scale) * 0.5; + let offset_y = (self.height - height as f32 * scale) * 0.5; + for (x, y, pixel) in pixels.enumerate_pixels_mut() { + let alpha = self.alpha( + (x as f32 + 0.5) * scale + offset_x, + (y as f32 + 0.5) * scale + offset_y, + ); + pixel.0[3] = (pixel.0[3] as f32 * alpha).round() as u8; + } + pixels + } +} + +struct Entry { + ready: Option<(Mask, Arc)>, + busy: bool, +} + +pub(crate) fn image( + source: Arc, + mask: Mask, + cx: &mut gpui::App, +) -> Option> { + type Cache = Vec<(gpui::ImageId, Arc>)>; + static CACHE: OnceLock> = OnceLock::new(); + let entry = { + let mut cache = CACHE.get_or_init(Default::default).lock().unwrap(); + if let Some(index) = cache.iter().position(|(id, _)| *id == source.id) { + let item = cache.remove(index); + let entry = item.1.clone(); + cache.push(item); + entry + } else { + let entry = Arc::new(Mutex::new(Entry { + ready: None, + busy: false, + })); + cache.push((source.id, entry.clone())); + if cache.len() > 4 { + cache.remove(0); + } + entry + } + }; + let mut state = entry.lock().unwrap(); + let ready = state.ready.as_ref().map(|(_, image)| image.clone()); + if state.busy || state.ready.as_ref().is_some_and(|(key, _)| *key == mask) { + return ready; + } + state.busy = true; + drop(state); + cx.spawn(async move |cx| { + let image = cx + .background_executor() + .spawn(async move { + Arc::new(gpui::RenderImage::new([image::Frame::new( + mask.raster(&source), + )])) + }) + .await; + cx.update(|cx| { + let mut state = entry.lock().unwrap(); + // A resize can supersede this work; retain the last ready image + // until the next frame starts the latest requested geometry. + state.ready = Some((mask, image)); + state.busy = false; + cx.refresh_windows(); + }); + }) + .detach(); + ready +} + +#[cfg(test)] +mod tests { + use super::*; + fn mask() -> Mask { + Mask { + width: 1000.0, + height: 440.0, + left: 160.0, + right: 840.0, + top: 360.0, + bottom: 484.0, + radius: 26.0, + } + } + #[test] + fn contour_wraps_surface_and_preserves_upper_artwork() { + let mask = mask(); + assert_eq!(mask.alpha(500.0, 20.0), 1.0); + assert_eq!(mask.alpha(500.0, 360.0), 0.0); + assert!(mask.alpha(100.0, 340.0) > mask.alpha(500.0, 340.0)); + assert_eq!(mask.alpha(0.0, 440.0), 0.0); + for y in 0..440 { + assert!((mask.alpha(100.0, y as f32) - mask.alpha(900.0, y as f32)).abs() < 0.00001); + } + } + #[test] + fn feather_has_soft_endpoints_and_keeps_midpoint_color() { + assert_eq!(smooth(0.5), 0.5); + assert!(smooth(0.01) < 0.001); + assert!(smooth(0.99) > 0.999); + } + + #[test] + fn masking_changes_only_alpha_and_never_amplifies_source_opacity() { + let source = gpui::RenderImage::new([image::Frame::new(image::RgbaImage::from_pixel( + 1000, + 440, + image::Rgba([173, 89, 231, 180]), + ))]); + let output = mask().raster(&source); + assert!( + output + .pixels() + .all(|pixel| pixel.0[..3] == [173, 89, 231] && pixel.0[3] <= 180) + ); + assert_eq!(output.get_pixel(500, 10).0[3], 180); + assert_eq!(output.get_pixel(500, 360).0[3], 0); + } +} diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 371a423e7..a1ae1728b 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -695,9 +695,6 @@ const SIDEBAR_GLASS_FADE_BAND: f32 = 24.0; 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; -// Keep the upper artwork clear, with a quiet tail over the lower 56%. -// The artwork meets the panel edges directly in every animation frame. -const NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO: f32 = 0.56; /// Drag marker for the sidebar resize handle. struct SidebarResize; @@ -855,20 +852,13 @@ fn new_thread_background_height(viewport_height: f32) -> f32 { .min(NEW_THREAD_BACKGROUND_MAX_HEIGHT) } -fn new_thread_background_bottom_band(appearance: crate::theme::Appearance, height: f32) -> f32 { - match appearance { - // Only the lower feather remains; toolbar contrast belongs to the - // floating island, not a window-wide strip over the artwork. - crate::theme::Appearance::Light => height * 0.72, - crate::theme::Appearance::Dark => height * NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO, - } -} - fn new_thread_background( background: Option<&settings::NewThreadComposerBackground>, effect: settings::NewThreadBackgroundEffect, theme: &Theme, viewport_height: f32, + hero_width: f32, + composer_bounds: Option>, dissolve: f32, cx: &mut App, ) -> AnyElement { @@ -880,53 +870,41 @@ fn new_thread_background( return Empty.into_any_element(); } let hero_height = new_thread_background_height(viewport_height); - let bottom_band = new_thread_background_bottom_band(theme.appearance, hero_height); + let Some(composer_bounds) = composer_bounds else { + return Empty.into_any_element(); + }; let dissolve = dissolve.clamp(0.0, 1.0); - let (image_opacity, effect_layer) = crate::new_thread_background_effects::treatment( + let artwork = crate::new_thread_background_effects::treatment( effect, theme, &path, new_thread_background_opacity(theme.is_frost()), + crate::new_thread_background_mask::Mask { + width: hero_width.round().max(1.0), + height: hero_height.round().max(1.0), + left: f32::from(composer_bounds.left()).round(), + top: f32::from(composer_bounds.top()).round(), + right: f32::from(composer_bounds.right()).round(), + bottom: f32::from(composer_bounds.bottom()).round(), + radius: crate::composer::COMPOSER_RADIUS, + }, cx, ); // 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() - .right_0() + .w(px(hero_width)) .h(px(hero_height)) .overflow_hidden() .opacity(1.0 - dissolve) - // Fade the image primitive itself instead of painting a theme-colored - // gradient above it. That creates a real alpha mask, so the tail - // resolves into the exact canvas beneath it on both opaque and glass - // themes without a horizontal color seam. - .child( - crate::edge_fade::edge_faded( - 0.0, - false, - true, - // Give the mask a definite relayout box. A percentage-sized - // image as the custom element's direct child could briefly - // resolve to zero during live window resize. - div() - .relative() - .w_full() - .h(px(hero_height)) - .child( - img(path) - .absolute() - .inset_0() - .size_full() - .object_fit(ObjectFit::Cover) - .opacity(image_opacity), - ) - .child(effect_layer), - ) - .band_bottom(bottom_band), - ) + // Alpha resolves into the real canvas, including translucent themes; + // no theme-colored overlay bleaches or darkens the source pixels. + .child(artwork) .into_any_element() } @@ -7115,6 +7093,14 @@ impl Shell { new_thread_background_effect, theme, self.viewport_height, + (self.viewport_width - self.sidebar_now()).max(0.0), + self.composer + .read(cx) + .hero_surface_bounds() + .map(|mut bounds| { + bounds.origin.x -= px(self.sidebar_now()); + bounds + }), dock_frame.dissolve(), cx, ) @@ -9869,22 +9855,6 @@ mod tests { assert_eq!(top + height - (center + 12.0), 4.0); } - #[test] - fn lower_hero_feather_preserves_appearance_specific_spacing() { - for viewport in [400.0, 600.0, 1000.0, 2000.0] { - let height = new_thread_background_height(viewport); - let dark_bottom = - new_thread_background_bottom_band(crate::theme::Appearance::Dark, height); - assert_eq!( - dark_bottom, - height * NEW_THREAD_BACKGROUND_BOTTOM_FADE_RATIO - ); - let bottom = new_thread_background_bottom_band(crate::theme::Appearance::Light, height); - assert!(bottom > dark_bottom); - assert!(bottom < height); - } - } - #[test] fn new_thread_handoff_is_continuous_and_staged() { assert!(bottom_stack_measurement_matches(false, false)); From db052cc9c7799b902e7129fd9dd7274359d65ba3 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Mon, 14 Sep 2026 00:59:04 +0200 Subject: [PATCH 37/40] Smooth right-panel composer handoffs and preserve exit geometry --- crates/ui/src/composer_dock.rs | 131 ++++++++++++++++++- crates/ui/src/composer_dock/panel_handoff.rs | 99 ++++++++++++++ crates/ui/src/shell.rs | 28 +++- 3 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 crates/ui/src/composer_dock/panel_handoff.rs diff --git a/crates/ui/src/composer_dock.rs b/crates/ui/src/composer_dock.rs index 23617c257..4bef342ad 100644 --- a/crates/ui/src/composer_dock.rs +++ b/crates/ui/src/composer_dock.rs @@ -3,6 +3,8 @@ 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, @@ -123,9 +125,21 @@ impl Visuals { } } } + + 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)), + dissolve: self.dissolve * (1.0 - stage(time, 0.0, 0.80)), + } + } } pub(crate) struct DockState { + pane: panel_handoff::PanelHandoff, phase: Glide, last_frame: Option, pub frame: DockFrame, @@ -137,11 +151,15 @@ pub(crate) struct DockState { last_width_frame: Option, route_changed: bool, choreography: Option<(Instant, Visuals)>, + panel_return: 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), @@ -153,11 +171,41 @@ impl Default for DockState { last_width_frame: None, route_changed: false, choreography: None, + panel_return: 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 @@ -168,7 +216,12 @@ impl DockState { }; self.last_width_frame = Some(now); let width = self.width.get_or_insert(Glide::new(target)); - if reduced || (!self.frame.active && !self.moving) { + 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)); @@ -178,6 +231,9 @@ impl DockState { 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(); + } 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); @@ -199,14 +255,33 @@ impl DockState { } self.last_frame = Some(now); let visuals = if let Some((started, from)) = self.choreography { - let time = now.saturating_duration_since(started).as_secs_f32() / duration(docked); + 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; } - from.advance(docked, time) + if self.panel_return { + from.return_from_panel(time) + } else { + from.advance(docked, time) + } } 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, @@ -296,8 +371,17 @@ impl Element for DockedComposer { 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 self.reduced || !moving { + 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)); @@ -345,6 +429,45 @@ mod tests { use super::*; use gpui::{Context, Render, canvas, div, prelude::*}; + #[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 { 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/shell.rs b/crates/ui/src/shell.rs index a1ae1728b..b6f561b7c 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -7034,6 +7034,7 @@ impl Shell { &mut self, window: &mut Window, main_content_width: f32, + transcript_width: f32, cx: &mut Context, ) -> AnyElement { let theme_owned = Theme::of(cx).clone(); @@ -7124,6 +7125,7 @@ impl Shell { .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 { @@ -7341,11 +7343,13 @@ impl Shell { ) .child(status) .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 { @@ -9475,12 +9479,28 @@ impl Render for Shell { // 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_now(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); + 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. @@ -9512,16 +9532,16 @@ impl Render for Shell { }, cx, ); - let main = self.render_main(window, main_content_width, 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(|| { From db5645acf32fe45838ad860e4a48e9a96022377e Mon Sep 17 00:00:00 2001 From: gaelcado Date: Mon, 14 Sep 2026 02:44:33 +0200 Subject: [PATCH 38/40] Validate replacement artwork before changing saved background --- crates/ui/src/lib.rs | 1 + crates/ui/src/new_thread_background_image.rs | 10 ++ crates/ui/src/settings.rs | 101 +++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 crates/ui/src/new_thread_background_image.rs diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 859b8aed9..f3af6f072 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -37,6 +37,7 @@ 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; 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/settings.rs b/crates/ui/src/settings.rs index a3cdb6c39..e8f4f3a7e 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -280,6 +280,11 @@ pub fn current(cx: &App) -> UiSettings { /// 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()) @@ -1408,6 +1413,102 @@ mod tests { 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( From cee0afad49ccca3a3279d51ee44a7a167489e2ba Mon Sep 17 00:00:00 2001 From: gaelcado Date: Mon, 14 Sep 2026 02:46:27 +0200 Subject: [PATCH 39/40] Render composer background masks on the GPU --- Cargo.lock | 40 +-- Cargo.toml | 13 +- crates/ui/src/composer.rs | 39 +-- crates/ui/src/composer_dock.rs | 48 ++- .../ui/src/new_thread_background_effects.rs | 167 +++++++--- crates/ui/src/new_thread_background_mask.rs | 300 +++++++++--------- crates/ui/src/shell.rs | 94 +++--- 7 files changed, 418 insertions(+), 283 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c37337dc..66633f693 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" dependencies = [ "accesskit", "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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" 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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" dependencies = [ "perf", "quote", diff --git a/Cargo.toml b/Cargo.toml index 052e96e3c..1541844fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,14 +64,15 @@ 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 = [ +# Pending https://github.com/zeronsh/zui/pull/9: paint-time rounded image masks. +gpui = { git = "https://github.com/gaelcado/zui", rev = "ce4a177684f1af929eefc6bfba7a44b208c9eec7" } +gpui_platform = { git = "https://github.com/gaelcado/zui", rev = "ce4a177684f1af929eefc6bfba7a44b208c9eec7", features = [ "wayland", "x11", "font-kit", "runtime_shaders", ] } -gpui_tokio = { git = "https://github.com/zeronsh/zui", rev = "07fd941ad72e7edc812fed317aab66adb69fa8cc" } +gpui_tokio = { git = "https://github.com/gaelcado/zui", rev = "ce4a177684f1af929eefc6bfba7a44b208c9eec7" } gpui-base = { git = "https://github.com/zeronsh/gpui-component", rev = "8c3af053189209db83b92b64aaa5cbcbedd9b72f" } # diffs @@ -125,6 +126,12 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" ignore = "0.4" nucleo-matcher = "0.3" +# gpui-base still pins the previous upstream Zui revision. Keep its GPUI types +# identical to the application while the companion renderer PR is pending. +[patch."https://github.com/zeronsh/zui"] +gpui = { git = "https://github.com/gaelcado/zui", rev = "ce4a177684f1af929eefc6bfba7a44b208c9eec7" } +gpui_macros = { git = "https://github.com/gaelcado/zui", rev = "ce4a177684f1af929eefc6bfba7a44b208c9eec7" } + [profile.release] # Distribution profile (scripts/package-linux.sh): thin LTO buys most of fat # LTO's size/speed win at a fraction of the link time; strip debug symbols. diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 81362ea9a..b8c9abfec 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -4118,7 +4118,7 @@ pub struct Composer { last_rendered_height: f32, dock_frame: Option, dock_clearance_correction: f32, - hero_surface_bounds: Rc>>>, + surface_bounds: crate::new_thread_background_mask::SurfaceBounds, last_target_height: f32, height_morph: Option, /// Monotonic clock anchor for the morph timeline. @@ -4155,8 +4155,8 @@ impl Composer { self.dock_clearance_correction } - pub(crate) fn hero_surface_bounds(&self) -> Option> { - self.hero_surface_bounds.get() + 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. @@ -4311,7 +4311,7 @@ impl Composer { last_rendered_height: 0.0, dock_frame: None, dock_clearance_correction: 0.0, - hero_surface_bounds: Default::default(), + surface_bounds: Default::default(), last_target_height: 0.0, height_morph: None, morph_clock: Instant::now(), @@ -7836,26 +7836,17 @@ impl Render for Composer { .relative() .id("composer-surface") .child(crate::frost::frosted(surface_radius, 16.0, body)) - .when( - self.dock_frame - .is_some_and(|frame| !frame.docked && frame.amount == 0.0), - |el| { - let measured = self.hero_surface_bounds.clone(); - el.child( - gpui::canvas( - move |bounds, window, _| { - if measured.get() != Some(bounds) { - measured.set(Some(bounds)); - window.request_animation_frame(); - } - }, - |_, _, _, _| {}, - ) - .absolute() - .inset_0(), - ) - }, - ) + .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)), + |_, _, _, _| {}, + ) + .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)) diff --git a/crates/ui/src/composer_dock.rs b/crates/ui/src/composer_dock.rs index 4bef342ad..47bce807b 100644 --- a/crates/ui/src/composer_dock.rs +++ b/crates/ui/src/composer_dock.rs @@ -133,7 +133,9 @@ impl Visuals { 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)), - dissolve: self.dissolve * (1.0 - stage(time, 0.0, 0.80)), + // 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)), } } } @@ -152,6 +154,7 @@ pub(crate) struct DockState { route_changed: bool, choreography: Option<(Instant, Visuals)>, panel_return: bool, + panel_departure: bool, column_width: Option, departing_column_width: Option, } @@ -172,6 +175,7 @@ impl Default for DockState { route_changed: false, choreography: None, panel_return: false, + panel_departure: false, column_width: None, departing_column_width: None, } @@ -233,6 +237,7 @@ impl DockState { 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() { @@ -264,11 +269,18 @@ impl DockState { if time >= 1.0 { self.choreography = None; } - if self.panel_return { + 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) }; @@ -429,6 +441,38 @@ 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(); diff --git a/crates/ui/src/new_thread_background_effects.rs b/crates/ui/src/new_thread_background_effects.rs index 4c5982f74..17a21d69f 100644 --- a/crates/ui/src/new_thread_background_effects.rs +++ b/crates/ui/src/new_thread_background_effects.rs @@ -1,9 +1,36 @@ //! Effects remain cached source-space images; a separate alpha mask follows layout. use crate::settings::NewThreadBackgroundEffect; use crate::theme::Theme; -use gpui::{AnyElement, Empty, IntoElement, Pixels, prelude::*, px}; +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>, @@ -213,8 +240,9 @@ impl BackgroundLuminance { } } -fn background_luminance(path: &Path) -> Option> { - type Cache = Vec<(PathBuf, Arc)>; +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 @@ -223,50 +251,54 @@ fn background_luminance(path: &Path) -> Option> { .iter() .find_map(|(key, source)| (key == path).then(|| source.clone())) { - return Some(source); + return source.lock().ok()?.clone(); } - let proxy = image::ImageReader::open(path) - .ok()? - .decode() - .ok()? - .thumbnail(2048, 2048); - let gray = proxy.to_luma8(); - let source = 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()), - }); - let mut cache = cache.lock().ok()?; - cache.push((path.to_path_buf(), source.clone())); - if cache.len() > 4 { - cache.remove(0); + 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); + } } - Some(source) + 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 } -pub(super) fn treatment( + +/// 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, - base_opacity: f32, - mask: crate::new_thread_background_mask::Mask, cx: &mut gpui::App, -) -> AnyElement { +) -> Option> { let light = matches!(theme.appearance, crate::theme::Appearance::Light); - match background_luminance(path) - .and_then(|source| source.raster_image(effect, light, cx)) - .and_then(|source| crate::new_thread_background_mask::image(source, mask, cx)) - { - Some(image) => gpui::img(image) - .absolute() - .inset_0() - .size_full() - .object_fit(gpui::ObjectFit::Cover) - .opacity(base_opacity) - .into_any_element(), - None => Empty.into_any_element(), - } + 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; @@ -283,6 +315,63 @@ fn dither_color([r, g, b, a]: [u8; 4], threshold: u8) -> [u8; 4] { #[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, diff --git a/crates/ui/src/new_thread_background_mask.rs b/crates/ui/src/new_thread_background_mask.rs index 222efe608..3105c7cf6 100644 --- a/crates/ui/src/new_thread_background_mask.rs +++ b/crates/ui/src/new_thread_background_mask.rs @@ -1,168 +1,168 @@ -//! A source-alpha feather following the measured composer, independent of theme. -//! Raster work is coalesced off-thread; route opacity never invalidates the mask. -use std::sync::{Arc, Mutex, OnceLock}; +//! 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}; -#[derive(Clone, Copy, Debug, PartialEq)] -pub(crate) struct Mask { - pub width: f32, - pub height: f32, - pub left: f32, - pub top: f32, - pub right: f32, - pub bottom: f32, - pub radius: f32, -} - -fn smooth(value: f32) -> f32 { - let t = value.clamp(0.0, 1.0); - t * t * (3.0 - 2.0 * t) -} +pub(crate) type SurfaceBounds = Rc>>>; -impl Mask { - fn alpha(self, x: f32, y: f32) -> f32 { - // Signed distance to the actual rounded surface, with a small quiet - // margin. Equal-distance contours naturally wrap its top corners. - let radius = self.radius.min((self.right - self.left).max(0.0) * 0.5); - let qx = - (x - (self.left + self.right) * 0.5).abs() - ((self.right - self.left) * 0.5 - radius); - let qy = - (y - (self.top + self.bottom) * 0.5).abs() - ((self.bottom - self.top) * 0.5 - radius); - let distance = qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius; - let feather = (self.height * 0.52).clamp(120.0, 220.0); - let around_composer = smooth((distance - 8.0) / feather); - // Finish the side tails smoothly as well, without imposing a straight - // wide wash across the middle of the artwork. - let bottom = smooth((self.height - y) / (self.height * 0.22).max(1.0)); - around_composer.min(bottom) - } - - fn raster(self, source: &gpui::RenderImage) -> image::RgbaImage { - let size = source.size(0); - let width = size.width.0 as u32; - let height = size.height.0 as u32; - let mut pixels = - image::RgbaImage::from_raw(width, height, source.as_bytes(0).unwrap().to_vec()) - .unwrap(); - let scale = (self.width / width as f32).max(self.height / height as f32); - let offset_x = (self.width - width as f32 * scale) * 0.5; - let offset_y = (self.height - height as f32 * scale) * 0.5; - for (x, y, pixel) in pixels.enumerate_pixels_mut() { - let alpha = self.alpha( - (x as f32 + 0.5) * scale + offset_x, - (y as f32 + 0.5) * scale + offset_y, - ); - pixel.0[3] = (pixel.0[3] as f32 * alpha).round() as u8; - } - pixels +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)))), } } -struct Entry { - ready: Option<(Mask, Arc)>, - busy: bool, -} - -pub(crate) fn image( - source: Arc, - mask: Mask, - cx: &mut gpui::App, -) -> Option> { - type Cache = Vec<(gpui::ImageId, Arc>)>; - static CACHE: OnceLock> = OnceLock::new(); - let entry = { - let mut cache = CACHE.get_or_init(Default::default).lock().unwrap(); - if let Some(index) = cache.iter().position(|(id, _)| *id == source.id) { - let item = cache.remove(index); - let entry = item.1.clone(); - cache.push(item); - entry - } else { - let entry = Arc::new(Mutex::new(Entry { - ready: None, - busy: false, - })); - cache.push((source.id, entry.clone())); - if cache.len() > 4 { - cache.remove(0); - } - entry - } - }; - let mut state = entry.lock().unwrap(); - let ready = state.ready.as_ref().map(|(_, image)| image.clone()); - if state.busy || state.ready.as_ref().is_some_and(|(key, _)| *key == mask) { - return ready; +/// 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; } - state.busy = true; - drop(state); - cx.spawn(async move |cx| { - let image = cx - .background_executor() - .spawn(async move { - Arc::new(gpui::RenderImage::new([image::Frame::new( - mask.raster(&source), - )])) - }) - .await; - cx.update(|cx| { - let mut state = entry.lock().unwrap(); - // A resize can supersede this work; retain the last ready image - // until the next frame starts the latest requested geometry. - state.ready = Some((mask, image)); - state.busy = false; - cx.refresh_windows(); - }); - }) - .detach(); - ready + 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::*; - fn mask() -> Mask { - Mask { - width: 1000.0, - height: 440.0, - left: 160.0, - right: 840.0, - top: 360.0, - bottom: 484.0, - radius: 26.0, + 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, } - } - #[test] - fn contour_wraps_surface_and_preserves_upper_artwork() { - let mask = mask(); - assert_eq!(mask.alpha(500.0, 20.0), 1.0); - assert_eq!(mask.alpha(500.0, 360.0), 0.0); - assert!(mask.alpha(100.0, 340.0) > mask.alpha(500.0, 340.0)); - assert_eq!(mask.alpha(0.0, 440.0), 0.0); - for y in 0..440 { - assert!((mask.alpha(100.0, y as f32) - mask.alpha(900.0, y as f32)).abs() < 0.00001); + 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 feather_has_soft_endpoints_and_keeps_midpoint_color() { - assert_eq!(smooth(0.5), 0.5); - assert!(smooth(0.01) < 0.001); - assert!(smooth(0.99) > 0.999); } #[test] - fn masking_changes_only_alpha_and_never_amplifies_source_opacity() { - let source = gpui::RenderImage::new([image::Frame::new(image::RgbaImage::from_pixel( - 1000, - 440, - image::Rgba([173, 89, 231, 180]), - ))]); - let output = mask().raster(&source); - assert!( - output - .pixels() - .all(|pixel| pixel.0[..3] == [173, 89, 231] && pixel.0[3] <= 180) - ); - assert_eq!(output.get_pixel(500, 10).0[3], 180); - assert_eq!(output.get_pixel(500, 360).0[3], 0); + 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/shell.rs b/crates/ui/src/shell.rs index b6f561b7c..759f60ceb 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -18,8 +18,8 @@ use chrono::Utc; use gpui::{ Action, AnyElement, App, ClipboardItem, Context, Empty, Entity, FocusHandle, Focusable as _, IntoElement, KeyBinding, Keystroke, ModifiersChangedEvent, MouseButton, MouseDownEvent, - MouseUpEvent, ObjectFit, Pixels, Point, Render, SharedString, StyledImage as _, Subscription, - Task, Window, WindowControlArea, actions, div, img, prelude::*, px, + MouseUpEvent, Pixels, Point, Render, SharedString, Subscription, Task, Window, + WindowControlArea, actions, div, prelude::*, px, }; use gpui_tokio::Tokio; @@ -853,44 +853,18 @@ fn new_thread_background_height(viewport_height: f32) -> f32 { } fn new_thread_background( - background: Option<&settings::NewThreadComposerBackground>, - effect: settings::NewThreadBackgroundEffect, - theme: &Theme, + artwork: Option>, viewport_height: f32, hero_width: f32, - composer_bounds: Option>, + composer_bounds: crate::new_thread_background_mask::SurfaceBounds, dissolve: f32, - cx: &mut App, + opacity: f32, ) -> AnyElement { - let Some(background) = background else { + let Some(artwork) = artwork else { return Empty.into_any_element(); }; - let path = PathBuf::from(&background.path); - if !path.is_file() { - return Empty.into_any_element(); - } let hero_height = new_thread_background_height(viewport_height); - let Some(composer_bounds) = composer_bounds else { - return Empty.into_any_element(); - }; let dissolve = dissolve.clamp(0.0, 1.0); - - let artwork = crate::new_thread_background_effects::treatment( - effect, - theme, - &path, - new_thread_background_opacity(theme.is_frost()), - crate::new_thread_background_mask::Mask { - width: hero_width.round().max(1.0), - height: hero_height.round().max(1.0), - left: f32::from(composer_bounds.left()).round(), - top: f32::from(composer_bounds.top()).round(), - right: f32::from(composer_bounds.right()).round(), - bottom: f32::from(composer_bounds.bottom()).round(), - radius: crate::composer::COMPOSER_RADIUS, - }, - cx, - ); // 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. @@ -901,10 +875,26 @@ fn new_thread_background( .w(px(hero_width)) .h(px(hero_height)) .overflow_hidden() - .opacity(1.0 - dissolve) + .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(artwork) + .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() } @@ -1297,6 +1287,7 @@ pub struct Shell { 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). @@ -1703,6 +1694,7 @@ impl Shell { 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, @@ -7070,6 +7062,23 @@ impl Shell { 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() @@ -7089,21 +7098,16 @@ impl Shell { }); 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( - new_thread_background_setting.as_ref(), - new_thread_background_effect, - theme, + artwork, self.viewport_height, (self.viewport_width - self.sidebar_now()).max(0.0), - self.composer - .read(cx) - .hero_surface_bounds() - .map(|mut bounds| { - bounds.origin.x -= px(self.sidebar_now()); - bounds - }), + self.composer.read(cx).surface_bounds(), dock_frame.dissolve(), - cx, + artwork_opacity * new_thread_background_opacity(theme.is_frost()), ) }); From df98d54733a5e1046dfa3428877cf5c4ef80c6df Mon Sep 17 00:00:00 2001 From: wing-anara Date: Mon, 14 Sep 2026 02:38:10 +0000 Subject: [PATCH 40/40] Pin composer masks to merged official Zui revision --- Cargo.lock | 42 +++++++++++++++++++++--------------------- Cargo.toml | 16 +++++----------- 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 66633f693..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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +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/gaelcado/zui?rev=ce4a177684f1af929eefc6bfba7a44b208c9eec7#ce4a177684f1af929eefc6bfba7a44b208c9eec7" +source = "git+https://github.com/zeronsh/zui?rev=aa009411c2dbfb39556bff1b1febed0512fdf609#aa009411c2dbfb39556bff1b1febed0512fdf609" dependencies = [ "perf", "quote", diff --git a/Cargo.toml b/Cargo.toml index 1541844fd..c91a6fff2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,16 +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). -# Pending https://github.com/zeronsh/zui/pull/9: paint-time rounded image masks. -gpui = { git = "https://github.com/gaelcado/zui", rev = "ce4a177684f1af929eefc6bfba7a44b208c9eec7" } -gpui_platform = { git = "https://github.com/gaelcado/zui", rev = "ce4a177684f1af929eefc6bfba7a44b208c9eec7", 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/gaelcado/zui", rev = "ce4a177684f1af929eefc6bfba7a44b208c9eec7" } -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" @@ -126,12 +126,6 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" ignore = "0.4" nucleo-matcher = "0.3" -# gpui-base still pins the previous upstream Zui revision. Keep its GPUI types -# identical to the application while the companion renderer PR is pending. -[patch."https://github.com/zeronsh/zui"] -gpui = { git = "https://github.com/gaelcado/zui", rev = "ce4a177684f1af929eefc6bfba7a44b208c9eec7" } -gpui_macros = { git = "https://github.com/gaelcado/zui", rev = "ce4a177684f1af929eefc6bfba7a44b208c9eec7" } - [profile.release] # Distribution profile (scripts/package-linux.sh): thin LTO buys most of fat # LTO's size/speed win at a fraction of the link time; strip debug symbols.