Ease divider snap into place over ~100ms

After releasing a divider, the boundary now eases (cubic ease-out, ~100ms) from
where the finger lifted to the snapped fraction instead of jumping. Starting a
new drag cancels an in-flight ease; membership changes still apply instantly.
This commit is contained in:
Skyler Lehmkuhl 2026-06-30 01:28:08 -04:00
parent 3b39e80e40
commit 01fe14f197
2 changed files with 57 additions and 12 deletions

View File

@ -118,6 +118,15 @@ pub struct StackDrag {
pub offset: f32,
}
/// A short ease of the visible pane weights toward their snapped values after a divider release.
#[derive(Debug, Clone, Copy)]
pub struct SnapAnim {
pub from: [f32; 3],
pub to: [f32; 3],
/// egui time (seconds) when the animation started.
pub start: f64,
}
/// Persistent mobile-shell state, cached on `EditorApp`.
pub struct MobileState {
/// Index into `STACK` of the topmost visible pane.
@ -131,6 +140,8 @@ pub struct MobileState {
pub show_instruments: bool,
/// Active handle drag (transient).
pub drag: Option<StackDrag>,
/// In-flight snap ease after a divider release (transient).
pub snap_anim: Option<SnapAnim>,
}
impl Default for MobileState {
@ -142,6 +153,7 @@ impl Default for MobileState {
weights: [1.0, 1.0, 1.0],
show_instruments: false,
drag: None,
snap_anim: None,
}
}
}

View File

@ -18,7 +18,7 @@
use eframe::egui;
use super::{icons, slot_path, MobileState, StackDrag, StackPane, STACK};
use super::{icons, slot_path, MobileState, SnapAnim, StackDrag, StackPane, STACK};
use crate::RenderContext;
const N: usize = STACK.len();
@ -120,6 +120,12 @@ const COLLAPSE_LO: f32 = 0.12;
const COLLAPSE_HI: f32 = 0.88;
/// Minimum normalized size a pane may be squeezed to during a live divider drag.
const MIN_FRAC: f32 = 0.05;
/// Duration (seconds) of the ease into a snapped divider position.
const SNAP_ANIM_SECS: f64 = 0.10;
fn ease_out(p: f32) -> f32 {
1.0 - (1.0 - p).powi(3)
}
/// Normalized pane weights (the first `count` stored weights, summing to 1).
fn nweights(weights: &[f32; 3], count: usize) -> Vec<f32> {
@ -243,7 +249,22 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state
let trigger = pane_h.min(TRIGGER_MAX);
// Resting band rects (weighted; used for interaction so drags don't chase the animated layout).
let nw = nweights(&state.weights, count);
// While a snap ease is in flight (and we're not dragging), override the weights with the eased
// values so the boundary glides into place.
let now = ui.input(|i| i.time);
let mut nw = nweights(&state.weights, count);
if state.drag.is_none() {
if let Some(a) = state.snap_anim {
let p = ((now - a.start) / SNAP_ANIM_SECS).clamp(0.0, 1.0) as f32;
if p >= 1.0 {
state.snap_anim = None;
} else {
let e = ease_out(p);
nw = (0..count).map(|i| lerp_f(a.from[i], a.to[i], e)).collect();
ui.ctx().request_repaint();
}
}
}
let rest_bands = config_rects(top, count, content_area, &nw);
// The draw layout. A divider drag resizes its two panes live; an edge drag animates toward the
@ -300,7 +321,7 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state
draw_footer(ui, footer_rect, top + count >= N);
// 3) Interactions on the resting header/footer rects (added last → they win the press).
handle_interactions(ui, &rest_bands, content_area, footer_rect, trigger, state);
handle_interactions(ui, &rest_bands, content_area, footer_rect, trigger, now, state);
}
fn draw_header(ui: &egui::Ui, hr: egui::Rect, sp: StackPane, show_instruments: bool, fullscreen: bool) {
@ -398,6 +419,7 @@ fn handle_interactions(
content_area: egui::Rect,
footer_rect: egui::Rect,
trigger: f32,
now: f64,
state: &mut MobileState,
) {
// (handle, header_rect, slot) — slot is Some for band headers, None for the footer.
@ -441,6 +463,7 @@ fn handle_interactions(
if resp.drag_started() {
state.drag = Some(StackDrag { handle, offset: 0.0 });
state.snap_anim = None; // a fresh drag cancels any in-flight snap ease
}
if resp.dragged() {
if let Some(d) = &mut state.drag {
@ -451,7 +474,7 @@ fn handle_interactions(
}
if resp.drag_stopped() {
if let Some(d) = state.drag.take() {
commit_drag(d, state, content_area, trigger);
commit_drag(d, state, content_area, trigger, now);
}
}
if resp.hovered() || state.drag.map(|d| d.handle == handle).unwrap_or(false) {
@ -460,10 +483,10 @@ fn handle_interactions(
}
}
fn commit_drag(d: StackDrag, state: &mut MobileState, content_area: egui::Rect, trigger: f32) {
fn commit_drag(d: StackDrag, state: &mut MobileState, content_area: egui::Rect, trigger: f32, now: f64) {
match d.handle {
Handle::Divider(k) if k + 1 < state.window_count => {
commit_divider(state, k, d.offset / content_area.height().max(1.0));
commit_divider(state, k, d.offset / content_area.height().max(1.0), now);
}
_ => {
// Edges (and degenerate dividers): membership transition, then reset to even sizing.
@ -480,7 +503,7 @@ fn commit_drag(d: StackDrag, state: &mut MobileState, content_area: egui::Rect,
/// Apply a divider release: collapse a pane (→ membership change) past the thresholds, otherwise
/// snap the boundary to the nearest intermediate fraction.
fn commit_divider(state: &mut MobileState, k: usize, offset_frac: f32) {
fn commit_divider(state: &mut MobileState, k: usize, offset_frac: f32, now: f64) {
let top = state.window_top;
let count = state.window_count;
let nw = nweights(&state.weights, count);
@ -500,18 +523,27 @@ fn commit_divider(state: &mut MobileState, k: usize, offset_frac: f32) {
}
} else {
// Snap the boundary to the nearest intermediate fraction, preserving the {k, k+1} span and
// the other panes' sizes.
// the other panes' sizes — and ease into it.
let sf = *SNAP_FRACS
.iter()
.min_by(|a, b| (**a - f).abs().total_cmp(&(**b - f).abs()))
.unwrap();
let span = nw[k] + nw[k + 1];
let mut w = nw;
w[k] = sf * span;
w[k + 1] = (1.0 - sf) * span;
// `from` = where the finger released; `to` = the snapped target.
let mut from = [0.0_f32; 3];
let mut to = [0.0_f32; 3];
for i in 0..count {
state.weights[i] = w[i];
from[i] = nw[i];
to[i] = nw[i];
}
from[k] = f * span;
from[k + 1] = (1.0 - f) * span;
to[k] = sf * span;
to[k + 1] = (1.0 - sf) * span;
state.weights = to;
state.snap_anim = Some(SnapAnim { from, to, start: now });
}
}
@ -520,4 +552,5 @@ fn set_window(state: &mut MobileState, top: usize, count: usize) {
state.window_top = top;
state.window_count = count;
state.weights = [1.0, 1.0, 1.0];
state.snap_anim = None;
}