From def82fa978d52c8d4c0c8fabde82f4552840486b Mon Sep 17 00:00:00 2001 From: kuddev Date: Thu, 16 Jul 2026 13:48:49 +0800 Subject: [PATCH] refactor: unify UI motion runtime --- nebula_app/src/display/chrome.rs | 31 ++++--- nebula_app/src/display/command_palette.rs | 16 +--- nebula_app/src/display/context_menu.rs | 53 +++++------ nebula_app/src/display/mod.rs | 91 ++++++++++++++---- nebula_app/src/display/side_panel.rs | 27 ++---- nebula_app/src/event.rs | 56 +++++++---- nebula_app/src/motion.rs | 107 ++++++++++++++++++++++ nebula_app/src/window_context/split.rs | 85 ++++++++++++----- 8 files changed, 333 insertions(+), 133 deletions(-) diff --git a/nebula_app/src/display/chrome.rs b/nebula_app/src/display/chrome.rs index 90b1a34..dc562aa 100644 --- a/nebula_app/src/display/chrome.rs +++ b/nebula_app/src/display/chrome.rs @@ -279,9 +279,7 @@ pub(super) fn chrome_tab_layout( // margin (symmetric with the top bar's top at `margin`), leaving only a // breathing gap before the terminal grid on its right. let sw = SIDEBAR_W_LOGICAL * scale_factor; - let t = expand.clamp(0.0, 1.0); - let eased = 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t); - let slide = (1.0 - eased) * sw; + let slide = (1.0 - expand.clamp(0.0, 1.0)) * sw; let panel_x = margin - slide; let panel_w = (sw - margin - s(12.0)).max(s(120.0)); let panel_top = top + bar_h; @@ -630,9 +628,11 @@ pub fn resize_edge( /// panels and pills with the dedicated UI renderer to validate the native /// (egui-free) chrome pipeline. Text labels and interactivity follow. pub(super) fn draw_chrome(d: &mut Display) { + d.step_chrome_anims(); + let motion_frame = d.nebula_ui_anims.frame(); // Tick command palette cursor animation (blink cycle). if d.nebula_palette.is_open() { - d.nebula_palette.tick_cursor(); + d.nebula_palette.tick_cursor(motion_frame); } // Chrome colors come from the theme skin (hover washes flip to dark @@ -800,7 +800,11 @@ pub(super) fn draw_chrome(d: &mut Display) { // Ease each row toward its target draw-y (reorder "make way") instead of // snapping; a tab-count change resets to the freshly laid-out positions. if d.nebula_tab_anim.len() != tab_layout.tabs.len() { - d.nebula_tab_anim = tab_layout.tabs.iter().map(|t| t.1).collect(); + d.nebula_tab_anim = tab_layout + .tabs + .iter() + .map(|tab| crate::motion::Spring::new(tab.1).with_response(0.14)) + .collect(); } let mut tab_anim_active = false; @@ -808,22 +812,24 @@ pub(super) fn draw_chrome(d: &mut Display) { // Scrolled-out / folded rows carry a zero rect: skip them, and snap // their eased position so they don't fly in when they reappear. if tab_w <= 0.0 { - d.nebula_tab_anim[index] = row_y; + d.nebula_tab_anim[index].snap_to(row_y); continue; } let target_y = d.tab_drag_draw_y(index, row_y, &tab_layout); - let cur = d.nebula_tab_anim[index]; // The grabbed pill tracks the pointer 1:1 (easing it would feel // laggy); only the rows making way ease toward their slots. let dragging_this = d.nebula_tab_drag.as_ref().is_some_and(|d| d.active && d.source == index); - let tab_y = if dragging_this || (target_y - cur).abs() < 0.5 { + let tab_y = if dragging_this { + d.nebula_tab_anim[index].snap_to(target_y); target_y } else { - tab_anim_active = true; - cur + (target_y - cur) * 0.28 + let spring = &mut d.nebula_tab_anim[index]; + spring.set_target(target_y, crate::motion::MotionPolicy::Full); + spring.step(motion_frame); + tab_anim_active |= spring.is_active(); + spring.value() }; - d.nebula_tab_anim[index] = tab_y; let tab_hovered = matches!( d.nebula_chrome_hover, ChromeHit::Tab(i) | ChromeHit::TabClose(i) if i == index @@ -1100,7 +1106,6 @@ pub(super) fn draw_chrome(d: &mut Display) { // Right-side drawer (directory tree / git) remains part of the app shell. Keeps // drawing through slide-out; animation stepping is centralized in Display. - d.step_chrome_anims(); if d.side_panel_visible() { if let Some(panel) = d.nebula_sftp_panel.as_ref() { let layout = sftp_panel::layout(&d.side_panel_layout(), scale); @@ -1257,7 +1262,7 @@ pub(super) fn draw_chrome(d: &mut Display) { if tab_w <= 0.0 { continue; } - let row_y = d.nebula_tab_anim.get(index).copied().unwrap_or(row_y); + let row_y = d.nebula_tab_anim.get(index).map(|motion| motion.value()).unwrap_or(row_y); let tab_hovered = matches!( d.nebula_chrome_hover, ChromeHit::Tab(i) | ChromeHit::TabClose(i) if i == index diff --git a/nebula_app/src/display/command_palette.rs b/nebula_app/src/display/command_palette.rs index e1d7bc9..71502f5 100644 --- a/nebula_app/src/display/command_palette.rs +++ b/nebula_app/src/display/command_palette.rs @@ -288,8 +288,7 @@ pub struct CommandPalette { /// Mouse-hovered row within the visible window (`None` when not hovering). hover: Option, /// Cursor blink animation state for the search input. - cursor_visible: bool, - cursor_last_toggle: std::time::Instant, + cursor_pulse: crate::motion::Pulse, } impl CommandPalette { @@ -306,8 +305,7 @@ impl CommandPalette { profiles_only: false, picking_default: false, hover: None, - cursor_visible: true, - cursor_last_toggle: std::time::Instant::now(), + cursor_pulse: crate::motion::Pulse::new(std::time::Duration::from_millis(1060)), }; palette.refilter(); palette @@ -440,16 +438,12 @@ impl CommandPalette { } /// Update cursor blink state. Call this each frame while the palette is open. - pub fn tick_cursor(&mut self) { - const BLINK_INTERVAL_MS: u128 = 530; - if self.cursor_last_toggle.elapsed().as_millis() >= BLINK_INTERVAL_MS { - self.cursor_visible = !self.cursor_visible; - self.cursor_last_toggle = std::time::Instant::now(); - } + pub fn tick_cursor(&mut self, frame: crate::motion::Frame) { + self.cursor_pulse.step(frame); } pub fn cursor_visible(&self) -> bool { - self.cursor_visible + self.cursor_pulse.visible(0.5) } pub fn toggle(&mut self) { diff --git a/nebula_app/src/display/context_menu.rs b/nebula_app/src/display/context_menu.rs index 3ff81c4..18be07a 100644 --- a/nebula_app/src/display/context_menu.rs +++ b/nebula_app/src/display/context_menu.rs @@ -4,9 +4,7 @@ //! hit-testing and animation live together here so pointer targets cannot drift //! away from the pixels the user sees at non-integer DPI scales. -use std::time::Instant; - -use super::design_tokens::{control, elevation, motion, space}; +use super::design_tokens::{control, elevation, space}; use super::*; // Maple Mono Normal NF CN ships the stable Codicon block below. Keep menu @@ -65,8 +63,7 @@ pub enum ContextMenuHit { pub(super) struct ContextMenu { target: ContextMenuTarget, anchor: (f32, f32), - opened_at: Instant, - closing_at: Option, + motion: crate::motion::Tween, hover: Option, current_color: Option, } @@ -77,18 +74,23 @@ impl ContextMenu { anchor: (f32, f32), current_color: Option, ) -> Self { - Self { - target, - anchor, - opened_at: Instant::now(), - closing_at: None, - hover: None, - current_color, - } + let mut motion = crate::motion::Tween::new(0.0); + motion.animate_role( + 1.0, + crate::motion::MotionRole::Enter, + crate::motion::MotionPolicy::Full, + ); + Self { target, anchor, motion, hover: None, current_color } } pub(super) fn begin_close(&mut self) { - self.closing_at.get_or_insert_with(Instant::now); + if self.motion.target() != 0.0 { + self.motion.animate_role( + 0.0, + crate::motion::MotionRole::Exit, + crate::motion::MotionPolicy::Full, + ); + } self.hover = None; } @@ -100,24 +102,16 @@ impl ContextMenu { true } - fn progress(&self) -> f32 { - if let Some(start) = self.closing_at { - return (1.0 - start.elapsed().as_secs_f32() / motion::MENU_CLOSE.as_secs_f32()) - .clamp(0.0, 1.0); - } - (self.opened_at.elapsed().as_secs_f32() / motion::MENU_OPEN.as_secs_f32()).clamp(0.0, 1.0) - } - pub(super) fn finished(&self) -> bool { - self.closing_at.is_some_and(|start| start.elapsed() >= motion::MENU_CLOSE) + self.motion.target() == 0.0 && !self.motion.is_active() } pub(super) fn animating(&self) -> bool { - self.closing_at.is_some() || self.opened_at.elapsed() < motion::MENU_OPEN + self.motion.is_active() } pub(super) fn interactive(&self) -> bool { - self.closing_at.is_none() + self.motion.target() != 0.0 } } @@ -373,18 +367,19 @@ fn fade_ink(base: Rgba, ink: Rgb, opacity: f32) -> Rgb { /// Paint the menu after the chrome/logo pass and before true modal dialogs. pub(super) fn draw(display: &mut Display) { - let Some(menu) = display.nebula_context_menu.clone() else { return }; + let Some(mut menu) = display.nebula_context_menu.clone() else { return }; + menu.motion.step(display.nebula_ui_anims.frame()); if menu.finished() { display.nebula_context_menu = None; return; } - let progress = menu.progress(); - let eased = 1.0 - (1.0 - progress) * (1.0 - progress) * (1.0 - progress); + let progress = menu.motion.value().clamp(0.0, 1.0); + display.nebula_context_menu = Some(menu.clone()); let size = display.size_info; let scale = display.window.scale_factor as f32; let s = |v: f32| v * scale; - let layout = layout(&menu, size, scale, -s(5.0) * (1.0 - eased)); + let layout = layout(&menu, size, scale, -s(5.0) * (1.0 - progress)); let sk = display.nebula_theme.skin(); let radius = s(9.0); let (px, py, pw, ph) = layout.panel; diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index aa145a8..5fc6283 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -257,6 +257,7 @@ impl UiAnim { #[derive(Debug, Clone)] struct NebulaUiAnims { clock: crate::motion::MotionClock, + frame: Option, left_sidebar: UiAnim, right_drawer: UiAnim, ssh_editor: UiAnim, @@ -266,6 +267,7 @@ impl NebulaUiAnims { fn new() -> Self { Self { clock: crate::motion::MotionClock::default(), + frame: None, left_sidebar: UiAnim::new(1.0), right_drawer: UiAnim::new(0.0), ssh_editor: UiAnim::new(0.0), @@ -274,17 +276,67 @@ impl NebulaUiAnims { fn step(&mut self, left_open: bool, right_open: bool, ssh_open: bool) { let frame = self.clock.tick(); + self.frame = Some(frame); self.left_sidebar.step(frame, if left_open { 1.0 } else { 0.0 }); self.right_drawer.step(frame, if right_open { 1.0 } else { 0.0 }); self.ssh_editor.step(frame, if ssh_open { 1.0 } else { 0.0 }); } + fn frame(&mut self) -> crate::motion::Frame { + if let Some(frame) = self.frame { + frame + } else { + let frame = self.clock.tick(); + self.frame = Some(frame); + frame + } + } + fn animating(&self, left_open: bool, right_open: bool) -> bool { self.left_sidebar.animating_to(if left_open { 1.0 } else { 0.0 }) || self.right_drawer.animating_to(if right_open { 1.0 } else { 0.0 }) } } +#[derive(Debug, Clone, Copy)] +struct ResizeHud { + columns: usize, + rows: usize, + opacity: crate::motion::Tween, +} + +impl ResizeHud { + fn new(columns: usize, rows: usize) -> Self { + let mut opacity = crate::motion::Tween::new(1.0); + opacity.animate_to( + 0.0, + Duration::from_millis(900), + crate::motion::Easing::Linear, + crate::motion::MotionPolicy::Full, + ); + Self { columns, rows, opacity } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct SplitReveal { + rect: (f32, f32, f32, f32), + direction: SplitDirection, + motion: crate::motion::Tween, +} + +impl SplitReveal { + pub fn new(rect: (f32, f32, f32, f32), direction: SplitDirection) -> Self { + let mut motion = crate::motion::Tween::new(0.0); + motion.animate_role( + 1.0, + crate::motion::MotionRole::Enter, + crate::motion::MotionPolicy::Full, + ); + Self { rect, direction, motion } + } +} + #[derive(Debug, Clone, Copy)] enum NebulaPowerlineIconKind { Folder, @@ -1308,7 +1360,7 @@ pub struct Display { /// Transient "cols × rows" HUD shown briefly after a window resize; it fades /// out over ~0.9s. `None` when nothing is showing. - nebula_resize_hud: Option<(usize, usize, Instant)>, + nebula_resize_hud: Option, /// Skip the first resize (window creation) so no HUD flashes at startup. nebula_resize_hud_armed: bool, @@ -1321,14 +1373,14 @@ pub struct Display { nebula_commands: std::sync::Arc>>, /// Per-displayed-tab animated draw-x, eased toward the laid-out / drag /// target each frame so tab reorder "make way" slides instead of snapping. - nebula_tab_anim: Vec, + nebula_tab_anim: Vec, /// Active scrollbar drag: the pointer's y-offset inside the thumb captured /// at press time, so the thumb tracks the pointer without jumping. pub nebula_scrollbar_drag: Option, /// Slide-in reveal for a freshly created split pane: its final rect, the /// split direction and the animation start time. Drawn as a shrinking /// bg-coloured cover in `draw_split_overlays`; cleared when done. - pub nebula_split_reveal: Option<(f32, f32, f32, f32, SplitDirection, std::time::Instant)>, + pub nebula_split_reveal: Option, /// Pending destructive-action confirmation (close with busy children / /// multi-line paste), drawn as a centered modal that owns the keyboard. pub nebula_confirm: Option, @@ -3653,17 +3705,15 @@ impl Display { // panel's right edge (`sw - 12` logical, see `chrome_tab_layout`); // resting collapsed: the chrome margin. let t = self.left_sidebar_progress().clamp(0.0, 1.0); - let eased = 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t); let sw = (SIDEBAR_W_LOGICAL * scale).round(); - let x = s(8.0) + eased * (sw - s(4.0) - s(8.0)); + let x = s(8.0) + t * (sw - s(4.0) - s(8.0)); // Top edge: the top bar's bottom (margin 8 + bar height 40, matching // `draw_chrome`), plus a seam so the card visibly floats below it. let y = s(8.0 + 40.0) + seam; // Right edge follows the file/git drawer the same way: as it slides // in, the card cedes its width (drawer width + margin) plus the seam. let dt = self.nebula_ui_anims.right_drawer.value().clamp(0.0, 1.0); - let deased = 1.0 - (1.0 - dt) * (1.0 - dt) * (1.0 - dt); - let drawer = deased * (side_panel::PANEL_W_LOGICAL * scale + s(8.0)); + let drawer = dt * (side_panel::PANEL_W_LOGICAL * scale + s(8.0)); let w = (self.size_info.width() - drawer - seam - x).max(0.0); let h = (self.size_info.height() - seam - y).max(0.0); (x, y, w, h) @@ -4095,7 +4145,7 @@ impl Display { // resize so nothing flashes when the window is first created. if self.nebula_resize_hud_armed { self.nebula_resize_hud = - Some((new_size.columns(), new_size.screen_lines(), Instant::now())); + Some(ResizeHud::new(new_size.columns(), new_size.screen_lines())); } self.nebula_resize_hud_armed = true; nebula_link_log(format!( @@ -4793,16 +4843,17 @@ impl Display { // pane's far edge shrinks away over ~160ms (ease-out), so the new pane // wipes in from the divider instead of popping. Timestamp-derived, no // per-frame allocation (same discipline as the quick-terminal slide). - if let Some((x, y, w, h, direction, start)) = self.nebula_split_reveal { - const DUR: f32 = 0.16; - let t = (start.elapsed().as_secs_f32() / DUR).min(1.0); - if t >= 1.0 { + if let Some(mut reveal) = self.nebula_split_reveal { + reveal.motion.step(self.nebula_ui_anims.frame()); + let e = reveal.motion.value(); + if !reveal.motion.is_active() { self.nebula_split_reveal = None; } else { - let e = 1.0 - (1.0 - t).powi(3); // ease-out cubic + self.nebula_split_reveal = Some(reveal); + let (x, y, w, h) = reveal.rect; let bg = self.nebula_background.unwrap_or(Rgb::new(15, 17, 26)); let cover = Rgba::new(bg.r, bg.g, bg.b, 255); - let (cx, cy, cw, chh) = match direction { + let (cx, cy, cw, chh) = match reveal.direction { SplitDirection::LeftRight => (x + w * e, y, w * (1.0 - e), h), SplitDirection::TopBottom => (x, y + h * e, w, h * (1.0 - e)), }; @@ -5246,14 +5297,16 @@ impl Display { /// shown briefly after a resize (a resize overlay HUD). Keeps requesting /// redraws until it fades out, then clears itself. fn draw_resize_hud(&mut self) { - let Some((cols, rows, start)) = self.nebula_resize_hud else { return }; - const HUD_DUR: f32 = 0.9; - let elapsed = start.elapsed().as_secs_f32(); - if elapsed >= HUD_DUR { + let Some(mut hud) = self.nebula_resize_hud else { return }; + hud.opacity.step(self.nebula_ui_anims.frame()); + if !hud.opacity.is_active() { self.nebula_resize_hud = None; return; } - let fade = (1.0 - elapsed / HUD_DUR).clamp(0.0, 1.0); + self.nebula_resize_hud = Some(hud); + let cols = hud.columns; + let rows = hud.rows; + let fade = hud.opacity.value().clamp(0.0, 1.0); let size = self.size_info; let scale = self.window.scale_factor as f32; diff --git a/nebula_app/src/display/side_panel.rs b/nebula_app/src/display/side_panel.rs index 09598fc..e277fe0 100644 --- a/nebula_app/src/display/side_panel.rs +++ b/nebula_app/src/display/side_panel.rs @@ -649,10 +649,9 @@ pub fn panel_layout( let bar_h = s(40.0); let gap = s(12.0); let w = s(PANEL_W_LOGICAL).min(win_w * 0.42); - // Swift-out easing (design sheet's cubic-bezier(0.2, 0.8, 0.2, 1) feel): - // fast launch, soft landing. - let t = slide.clamp(0.0, 1.0); - let eased = 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t); + // Motion Runtime already provides the physical response. Applying another + // curve here would double-ease the drawer and make its ending feel sticky. + let eased = slide.clamp(0.0, 1.0); // Resting x is inset by `margin` (mirroring the left panel's left inset); // closed, it rides fully off the right edge. Travel = the panel width plus // its margin so nothing peeks while closed. @@ -707,10 +706,7 @@ pub fn panel_action_rects( if custom_root { let follow_width = s(62.0); let follow_x = open_x - s(4.0) - follow_width; - actions.push(( - PanelHit::FollowCurrentDirectory, - (follow_x, y, follow_width, height), - )); + actions.push((PanelHit::FollowCurrentDirectory, (follow_x, y, follow_width, height))); } actions } @@ -1238,7 +1234,8 @@ pub(super) fn draw_text( .into_iter() .map(|(_, rect)| rect.0) .fold(px + pw - text_pad, f32::min); - let summary_cols = (((action_x - px - 2.0 * text_pad) / cell_w).floor() as usize).max(4); + let summary_cols = + (((action_x - px - 2.0 * text_pad) / cell_w).floor() as usize).max(4); let (summary, summary_ink) = if let Some(notice) = panel.root_notice() { (clip_tail(notice, summary_cols), Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b)) } else { @@ -1539,10 +1536,8 @@ mod tests { #[test] fn missing_custom_root_returns_to_latest_cwd_with_visible_feedback() { - let base = std::env::temp_dir().join(format!( - "nebula-panel-missing-root-test-{}", - std::process::id() - )); + let base = std::env::temp_dir() + .join(format!("nebula-panel-missing-root-test-{}", std::process::id())); let cwd = base.join("cwd"); let custom = base.join("custom"); std::fs::create_dir_all(&cwd).unwrap(); @@ -1564,10 +1559,8 @@ mod tests { #[test] fn invalid_custom_root_refreshes_notice_when_followed_cwd_is_the_same_path() { - let root = std::env::temp_dir().join(format!( - "nebula-panel-same-missing-root-test-{}", - std::process::id() - )); + let root = std::env::temp_dir() + .join(format!("nebula-panel-same-missing-root-test-{}", std::process::id())); std::fs::create_dir_all(&root).unwrap(); let mut panel = SidePanel::new(); diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index a4f86e0..530ee6d 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -110,9 +110,9 @@ pub struct Processor { quick_terminal: Option, /// Whether the quick terminal is currently shown (target state). quick_visible: bool, - /// Active slide animation: `(start, sliding_in)`; `None` when idle. Just a - /// timestamp + direction — position is computed per frame, no frame cache. - quick_anim: Option<(Instant, bool)>, + /// Renderer-independent quick-terminal motion state. + quick_motion: crate::motion::Tween, + quick_motion_clock: crate::motion::MotionClock, /// Global hotkey manager, kept alive so its registration stays active. global_hotkey: Option, /// Id of the registered quick-terminal toggle hotkey. @@ -179,7 +179,8 @@ impl Processor { config_monitor, quick_terminal: None, quick_visible: false, - quick_anim: None, + quick_motion: crate::motion::Tween::new(1.0), + quick_motion_clock: crate::motion::MotionClock::default(), global_hotkey, quick_hotkey_id, detached: Vec::new(), @@ -356,14 +357,26 @@ impl Processor { let show = self.quick_visible; if show { if let Some(wc) = self.windows.get(&id) { - // Park above the top edge, reveal, then slide down. - wc.display.window.set_quick_terminal_slide(1.0); + // A fully hidden window starts above the edge. Reversing + // an active exit keeps its current position. + if !self.quick_motion.is_active() { + wc.display.window.set_quick_terminal_slide(1.0); + self.quick_motion.snap_to(1.0); + } wc.display.window.set_visible(true); wc.display.window.focus_window(); } } // Slide-out keeps the window visible until the animation ends. - self.quick_anim = Some((Instant::now(), show)); + self.quick_motion.animate_role( + if show { 0.0 } else { 1.0 }, + if show { + crate::motion::MotionRole::Enter + } else { + crate::motion::MotionRole::Exit + }, + crate::motion::MotionPolicy::Full, + ); return; } // The window was closed by the user; fall through and recreate it. @@ -387,34 +400,37 @@ impl Processor { wc.display.window.set_quick_terminal_slide(1.0); wc.display.window.focus_window(); } - self.quick_anim = Some((Instant::now(), true)); + self.quick_motion.snap_to(1.0); + self.quick_motion.animate_role( + 0.0, + crate::motion::MotionRole::Enter, + crate::motion::MotionPolicy::Full, + ); }, Err(err) => error!("Failed to create quick terminal: {err}"), } } /// Advance the quick-terminal slide one frame. Returns `true` while - /// animating (so the loop keeps polling). Position is derived from a - /// timestamp with an ease-out cubic — no frame cache, no allocation. + /// animating (so the loop keeps polling). Motion Runtime owns timing and + /// easing; this path only applies the resulting normalized position. fn animate_quick_terminal(&mut self) -> bool { - let Some((start, sliding_in)) = self.quick_anim else { return false }; + if !self.quick_motion.is_active() { + return false; + } let Some(id) = self.quick_terminal else { - self.quick_anim = None; + self.quick_motion.snap_to(1.0); return false; }; - - const DURATION: f32 = 0.15; - let t = (start.elapsed().as_secs_f32() / DURATION).min(1.0); - let eased = 1.0 - (1.0 - t).powi(3); // ease-out cubic - let hidden = if sliding_in { 1.0 - eased } else { eased }; + self.quick_motion.step(self.quick_motion_clock.tick()); + let hidden = self.quick_motion.value().clamp(0.0, 1.0); if let Some(wc) = self.windows.get(&id) { wc.display.window.set_quick_terminal_slide(hidden); } - if t >= 1.0 { - self.quick_anim = None; - if !sliding_in { + if !self.quick_motion.is_active() { + if self.quick_motion.target() >= 1.0 { // Slide-out finished: actually hide the window. if let Some(wc) = self.windows.get(&id) { wc.display.window.set_visible(false); diff --git a/nebula_app/src/motion.rs b/nebula_app/src/motion.rs index 9c8e66d..69e4846 100644 --- a/nebula_app/src/motion.rs +++ b/nebula_app/src/motion.rs @@ -212,6 +212,10 @@ impl Tween { pub fn is_active(self) -> bool { self.active } + + pub fn target(self) -> f32 { + self.target + } } /// Allocation-free, interruption-friendly damped spring for interactive UI. @@ -286,6 +290,87 @@ pub fn lerp(start: f32, end: f32, progress: f32) -> f32 { start + (end - start) * progress } +pub trait Interpolate: Copy { + fn interpolate(self, end: Self, progress: f32) -> Self; +} + +impl Interpolate for f32 { + fn interpolate(self, end: Self, progress: f32) -> Self { + lerp(self, end, progress) + } +} + +impl Interpolate for [f32; N] { + fn interpolate(self, end: Self, progress: f32) -> Self { + std::array::from_fn(|index| lerp(self[index], end[index], progress)) + } +} + +/// Generic allocation-free tween for positions, rectangles and linear color. +#[derive(Debug, Clone, Copy)] +pub struct ValueTween { + start: T, + end: T, + progress: Tween, +} + +/// Deterministic phase source for cursor blink, spinners and other loops. +#[derive(Debug, Clone, Copy)] +pub struct Pulse { + elapsed: Duration, + period: Duration, +} + +impl Pulse { + pub fn new(period: Duration) -> Self { + Self { elapsed: Duration::ZERO, period: period.max(Duration::from_millis(1)) } + } + + pub fn step(&mut self, frame: Frame) { + self.elapsed += frame.delta; + while self.elapsed >= self.period { + self.elapsed -= self.period; + } + } + + pub fn phase(self) -> f32 { + self.elapsed.as_secs_f32() / self.period.as_secs_f32() + } + + pub fn visible(self, duty_cycle: f32) -> bool { + self.phase() < duty_cycle.clamp(0.0, 1.0) + } + + pub fn reset(&mut self) { + self.elapsed = Duration::ZERO; + } +} + +impl ValueTween { + pub fn new(value: T) -> Self { + Self { start: value, end: value, progress: Tween::new(1.0) } + } + + pub fn animate_to(&mut self, end: T, role: MotionRole, policy: MotionPolicy) { + self.start = self.value(); + self.end = end; + self.progress.snap_to(0.0); + self.progress.animate_role(1.0, role, policy); + } + + pub fn step(&mut self, frame: Frame) -> bool { + self.progress.step(frame) + } + + pub fn value(self) -> T { + self.start.interpolate(self.end, self.progress.value()) + } + + pub fn is_active(self) -> bool { + self.progress.is_active() + } +} + #[cfg(test)] mod tests { use super::*; @@ -354,4 +439,26 @@ mod tests { } assert!((spring.value() - 1.0).abs() < 0.001); } + + #[test] + fn value_tween_interpolates_rect_without_allocating() { + let now = Instant::now(); + let mut motion = ValueTween::new([0.0, 10.0, 20.0, 30.0]); + motion.animate_to([10.0, 20.0, 40.0, 50.0], MotionRole::Relocate, MotionPolicy::Full); + motion.step(Frame { now, delta: MotionRole::Relocate.spec().duration / 2 }); + let value = motion.value(); + assert!(value[0] > 0.0 && value[0] < 10.0); + assert!(value[2] > 20.0 && value[2] < 40.0); + } + + #[test] + fn pulse_wraps_without_losing_phase_contract() { + let now = Instant::now(); + let mut pulse = Pulse::new(Duration::from_millis(1000)); + pulse.step(Frame { now, delta: Duration::from_millis(750) }); + assert!(!pulse.visible(0.5)); + pulse.step(Frame { now, delta: Duration::from_millis(300) }); + assert!(pulse.visible(0.5)); + assert!((pulse.phase() - 0.05).abs() < 0.001); + } } diff --git a/nebula_app/src/window_context/split.rs b/nebula_app/src/window_context/split.rs index 7bd23af..1edba72 100644 --- a/nebula_app/src/window_context/split.rs +++ b/nebula_app/src/window_context/split.rs @@ -17,7 +17,7 @@ use nebula_terminal::grid::Dimensions; use nebula_terminal::term::test::TermSize; use crate::display::{ - SizeInfo, SplitDirection, SplitNav, NEBULA_SPLIT_DIVIDER_GAP, NEBULA_SPLIT_HIT_SLOP, + NEBULA_SPLIT_DIVIDER_GAP, NEBULA_SPLIT_HIT_SLOP, SizeInfo, SplitDirection, SplitNav, }; use super::{Layout, Pane, PaneId, WindowContext}; @@ -180,7 +180,8 @@ fn collect_layout( match direction { SplitDirection::LeftRight => { let usable = (vw - divider).max(cell_w); - let first_w = (usable * r).floor().max(cell_w).min((usable - cell_w).max(cell_w)); + let first_w = + (usable * r).floor().max(cell_w).min((usable - cell_w).max(cell_w)); let second_w = (usable - first_w).max(cell_w); dividers.push(DividerInfo { rect: (vx + first_w, vy, divider, vh), @@ -190,20 +191,41 @@ fn collect_layout( }); path.push(false); collect_layout( - first, cell_w, cell_h, divider, use_preview, vx, vy, first_w, vh, path, - panes, dividers, + first, + cell_w, + cell_h, + divider, + use_preview, + vx, + vy, + first_w, + vh, + path, + panes, + dividers, ); path.pop(); path.push(true); collect_layout( - second, cell_w, cell_h, divider, use_preview, vx + first_w + divider, vy, - second_w, vh, path, panes, dividers, + second, + cell_w, + cell_h, + divider, + use_preview, + vx + first_w + divider, + vy, + second_w, + vh, + path, + panes, + dividers, ); path.pop(); }, SplitDirection::TopBottom => { let usable = (vh - divider).max(cell_h); - let first_h = (usable * r).floor().max(cell_h).min((usable - cell_h).max(cell_h)); + let first_h = + (usable * r).floor().max(cell_h).min((usable - cell_h).max(cell_h)); let second_h = (usable - first_h).max(cell_h); dividers.push(DividerInfo { rect: (vx, vy + first_h, vw, divider), @@ -213,14 +235,34 @@ fn collect_layout( }); path.push(false); collect_layout( - first, cell_w, cell_h, divider, use_preview, vx, vy, vw, first_h, path, - panes, dividers, + first, + cell_w, + cell_h, + divider, + use_preview, + vx, + vy, + vw, + first_h, + path, + panes, + dividers, ); path.pop(); path.push(true); collect_layout( - second, cell_w, cell_h, divider, use_preview, vx, vy + first_h + divider, vw, - second_h, path, panes, dividers, + second, + cell_w, + cell_h, + divider, + use_preview, + vx, + vy + first_h + divider, + vw, + second_h, + path, + panes, + dividers, ); path.pop(); }, @@ -265,10 +307,7 @@ impl WindowContext { /// Like [`layout_geometry`], but keeps each divider's owning-node identity /// so the drag handler can resize any split, not just the root. - fn layout_geometry_ex( - &self, - use_preview: bool, - ) -> (Vec<(PaneId, SizeInfo)>, Vec) { + fn layout_geometry_ex(&self, use_preview: bool) -> (Vec<(PaneId, SizeInfo)>, Vec) { // A zoomed pane fills the whole window; splits and dividers are hidden. if let Some(zoomed) = self.zoom { return (vec![(zoomed, self.display.size_info)], Vec::new()); @@ -328,8 +367,7 @@ impl WindowContext { let Some((_, fview)) = rects.iter().find(|(id, _)| *id == focused) else { return }; let center = |v: &SizeInfo| { let cx = v.padding_x() + (v.width() - 2.0 * v.padding_x()) * 0.5; - let cy = - v.padding_y() + (v.height() - v.padding_y() - v.padding_bottom()) * 0.5; + let cy = v.padding_y() + (v.height() - v.padding_y() - v.padding_bottom()) * 0.5; (cx, cy) }; let (fcx, fcy) = center(fview); @@ -385,14 +423,14 @@ impl WindowContext { if let Some((_, view)) = self.layout_geometry(false).0.into_iter().find(|(id, _)| *id == new_id) { - self.display.nebula_split_reveal = Some(( + let rect = ( view.padding_x(), view.padding_y(), view.width() - 2.0 * view.padding_x(), view.height() - view.padding_y() - view.padding_bottom(), - direction, - std::time::Instant::now(), - )); + ); + self.display.nebula_split_reveal = + Some(crate::display::SplitReveal::new(rect, direction)); self.display.window.request_redraw(); } self.dirty = true; @@ -789,9 +827,8 @@ mod resize_layout_tests { #[test] fn pane_layout_keeps_message_bar_rows_out_of_the_pty_viewport() { - let mut size = SizeInfo::new_fully_asymmetric( - 1000.0, 1000.0, 10.0, 20.0, 100.0, 20.0, 80.0, 20.0, - ); + let mut size = + SizeInfo::new_fully_asymmetric(1000.0, 1000.0, 10.0, 20.0, 100.0, 20.0, 80.0, 20.0); assert_eq!(size.screen_lines(), 45); size.reserve_lines(2); assert_eq!(size.screen_lines(), 43);