refactor: unify UI motion runtime
This commit is contained in:
1 parent
33e0daa3da
commit
def82fa978
8 files changed
+333
-133
No files matched your search
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user