refactor: unify UI motion runtime

This commit is contained in:
kuddev committed 2026-07-16 13:48:49 +08:00
1 parent 33e0daa3da
commit def82fa978
8 files changed
+333 -133

No files matched your search

+18 -13
View File
@@ -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
+5 -11
View File
@@ -288,8 +288,7 @@ pub struct CommandPalette {
/// Mouse-hovered row within the visible window (`None` when not hovering).
hover: Option<usize>,
/// 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) {
+24 -29
View File
@@ -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<Instant>,
motion: crate::motion::Tween,
hover: Option<ContextMenuAction>,
current_color: Option<Rgb>,
}
@@ -77,18 +74,23 @@ impl ContextMenu {
anchor: (f32, f32),
current_color: Option<Rgb>,
) -> 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;
+72 -19
View File
@@ -257,6 +257,7 @@ impl UiAnim {
#[derive(Debug, Clone)]
struct NebulaUiAnims {
clock: crate::motion::MotionClock,
frame: Option<crate::motion::Frame>,
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<ResizeHud>,
/// 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<std::sync::Mutex<Vec<String>>>,
/// 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<f32>,
nebula_tab_anim: Vec<crate::motion::Spring>,
/// 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<f32>,
/// 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<SplitReveal>,
/// Pending destructive-action confirmation (close with busy children /
/// multi-line paste), drawn as a centered modal that owns the keyboard.
pub nebula_confirm: Option<NebulaConfirm>,
@@ -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;
+10 -17
View File
@@ -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();
+36 -20
View File
@@ -110,9 +110,9 @@ pub struct Processor {
quick_terminal: Option<WindowId>,
/// 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<GlobalHotKeyManager>,
/// 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);
+107
View File
@@ -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<const N: usize> 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<T: Interpolate> {
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<T: Interpolate> ValueTween<T> {
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);
}
}
+61 -24
View File
@@ -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<DividerInfo>) {
fn layout_geometry_ex(&self, use_preview: bool) -> (Vec<(PaneId, SizeInfo)>, Vec<DividerInfo>) {
// 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);