Linked-divider preset snapping + animated transitions for the stack

Rework 3-pane divider behavior and animate every layout change:

- All transitions now ease over ~100ms via a LayoutAnim between two full stack
  configs (resize, slide, collapse, fullscreen) instead of jumping; edge
  transitions continue from the drag's progress.
- 3-pane dividers are linked along a single path parameter through the three
  presets ([.5,.25,.25] / even / [.25,.25,.5]) so both dividers move together
  and reach their snaps simultaneously. Over-grow past the extreme preset is
  anchored to the path endpoint so the non-dragged divider stays continuous.
- Growing a pane past ~66% collapses the window to 2 panes (dropping the
  opposite-end pane); the surviving divider snaps to the nearest 2-pane preset
  (1/4, 1/2, 3/4) based on the drop position. Dragging to the slide extreme
  still slides the window.
- 2-pane snaps to 1/4, 1/2, 3/4.
This commit is contained in:
Skyler Lehmkuhl 2026-06-30 02:07:20 -04:00
parent 01fe14f197
commit b25e639a1c
2 changed files with 282 additions and 114 deletions

View File

@ -118,12 +118,18 @@ pub struct StackDrag {
pub offset: f32, pub offset: f32,
} }
/// A short ease of the visible pane weights toward their snapped values after a divider release. /// A short ease between two stack layouts (resize snap, membership change, or fullscreen toggle).
/// Both endpoints are full configs so the same animation covers panes resizing AND panes
/// entering/leaving the window.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct SnapAnim { pub struct LayoutAnim {
pub from: [f32; 3], pub from_top: usize,
pub to: [f32; 3], pub from_count: usize,
/// egui time (seconds) when the animation started. pub from_w: [f32; 3],
pub to_top: usize,
pub to_count: usize,
pub to_w: [f32; 3],
/// egui time (seconds) when the animation started (may be back-dated to continue a drag).
pub start: f64, pub start: f64,
} }
@ -140,8 +146,8 @@ pub struct MobileState {
pub show_instruments: bool, pub show_instruments: bool,
/// Active handle drag (transient). /// Active handle drag (transient).
pub drag: Option<StackDrag>, pub drag: Option<StackDrag>,
/// In-flight snap ease after a divider release (transient). /// In-flight layout ease (transient).
pub snap_anim: Option<SnapAnim>, pub anim: Option<LayoutAnim>,
} }
impl Default for MobileState { impl Default for MobileState {
@ -153,7 +159,7 @@ impl Default for MobileState {
weights: [1.0, 1.0, 1.0], weights: [1.0, 1.0, 1.0],
show_instruments: false, show_instruments: false,
drag: None, drag: None,
snap_anim: None, anim: None,
} }
} }
} }

View File

@ -18,7 +18,7 @@
use eframe::egui; use eframe::egui;
use super::{icons, slot_path, MobileState, SnapAnim, StackDrag, StackPane, STACK}; use super::{icons, slot_path, LayoutAnim, MobileState, StackDrag, StackPane, STACK};
use crate::RenderContext; use crate::RenderContext;
const N: usize = STACK.len(); const N: usize = STACK.len();
@ -113,11 +113,93 @@ fn resolve(
// --- rect layout --- // --- rect layout ---
/// Intermediate divider snap fractions (pane k's share of the {k, k+1} span). /// Below/above these boundary positions, a divider release collapses a group → membership change.
const SNAP_FRACS: [f32; 5] = [0.25, 1.0 / 3.0, 0.5, 2.0 / 3.0, 0.75];
/// Below/above these local fractions, a divider release collapses a pane → membership change.
const COLLAPSE_LO: f32 = 0.12; const COLLAPSE_LO: f32 = 0.12;
const COLLAPSE_HI: f32 = 0.88; const COLLAPSE_HI: f32 = 0.88;
// The only allowed pane-weight distributions per window size. Snapping to these keeps the minimum
// pane height manageable (no pane below ~1/4).
const PRESETS_1: [[f32; 3]; 1] = [[1.0, 0.0, 0.0]];
const PRESETS_2: [[f32; 3]; 3] = [[0.25, 0.75, 0.0], [0.5, 0.5, 0.0], [0.75, 0.25, 0.0]];
const PRESETS_3: [[f32; 3]; 3] = [
[0.5, 0.25, 0.25],
[1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0],
[0.25, 0.25, 0.5],
];
fn presets(count: usize) -> &'static [[f32; 3]] {
match count {
2 => &PRESETS_2,
3 => &PRESETS_3,
_ => &PRESETS_1,
}
}
/// The allowed preset nearest (L2) to the current weights.
fn nearest_preset(cur: &[f32], count: usize) -> [f32; 3] {
let dist = |p: &[f32; 3]| (0..count).map(|i| (p[i] - cur[i]).powi(2)).sum::<f32>();
presets(count)
.iter()
.min_by(|a, b| dist(a).total_cmp(&dist(b)))
.copied()
.unwrap_or([1.0, 0.0, 0.0])
}
// 3-pane snapping rides a single path parameter s in [0, 2] through the three presets, so the two
// dividers move together and reach their snaps simultaneously. The dragged divider's boundary
// position (pane0 for the upper divider; pane0+pane1 for the lower) maps onto s.
const PATH3_UPPER: [f32; 3] = [0.5, 1.0 / 3.0, 0.25]; // pane0 at s = 0, 1, 2
const PATH3_LOWER: [f32; 3] = [0.75, 2.0 / 3.0, 0.5]; // pane0+pane1 at s = 0, 1, 2
/// A pane grown beyond this collapses the 3-pane window to 2 panes on release.
const COLLAPSE_GROW: f32 = 0.66;
fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
[
lerp_f(a[0], b[0], t),
lerp_f(a[1], b[1], t),
lerp_f(a[2], b[2], t),
]
}
/// Weights along the 3-pane preset path at parameter `s` ∈ [0, 2].
fn path3(s: f32) -> [f32; 3] {
let s = s.clamp(0.0, 2.0);
if s <= 1.0 {
lerp3(PRESETS_3[0], PRESETS_3[1], s)
} else {
lerp3(PRESETS_3[1], PRESETS_3[2], s - 1.0)
}
}
/// Path parameter for divider `k` whose boundary position is `b` (within the path's range).
fn s_from_boundary(k: usize, b: f32) -> f32 {
let pts = if k == 0 { PATH3_UPPER } else { PATH3_LOWER };
if b >= pts[1] {
(pts[0] - b) / (pts[0] - pts[1]) // 0 at pts[0] … 1 at pts[1]
} else {
1.0 + (pts[1] - b) / (pts[1] - pts[2]) // 1 at pts[1] … 2 at pts[2]
}
}
/// The 3-pane path boundary range for divider `k`: (min, max) of its position across the presets.
fn path3_range(k: usize) -> (f32, f32) {
let pts = if k == 0 { PATH3_UPPER } else { PATH3_LOWER };
(pts[2], pts[0])
}
/// Live weights while dragging divider `k` to boundary `b` in a 3-pane window: follow the linked
/// path within range, and beyond it group-resize from the nearer path endpoint (so the motion is
/// continuous across the extreme presets — no jump in the other divider).
fn live_weights3(k: usize, b_unclamped: f32) -> [f32; 3] {
let (lo, hi) = path3_range(k);
if b_unclamped >= lo && b_unclamped <= hi {
path3(s_from_boundary(k, b_unclamped))
} else {
let anchor = if b_unclamped > hi { path3(0.0) } else { path3(2.0) };
let (bmin, bmax) = boundary_bounds(3, k);
to_arr(&weights_for_boundary(&anchor, k, b_unclamped.clamp(bmin, bmax)))
}
}
/// Minimum normalized size a pane may be squeezed to during a live divider drag. /// Minimum normalized size a pane may be squeezed to during a live divider drag.
const MIN_FRAC: f32 = 0.05; const MIN_FRAC: f32 = 0.05;
/// Duration (seconds) of the ease into a snapped divider position. /// Duration (seconds) of the ease into a snapped divider position.
@ -172,34 +254,52 @@ fn collapsed(rect: egui::Rect, at_top: bool) -> egui::Rect {
egui::Rect::from_min_max(egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)) egui::Rect::from_min_max(egui::pos2(rect.left(), y), egui::pos2(rect.right(), y))
} }
/// The local boundary fraction (pane k's share of the {k, k+1} span) after applying a normalized /// Allowed range of divider `k`'s boundary position (normalized) given MIN_FRAC per pane.
/// `offset_frac` to divider `k`, clamped so neither pane is squeezed past MIN_FRAC. /// The boundary splits the window into the group above (panes 0..=k) and below (panes k+1..).
fn divider_local(nw: &[f32], k: usize, offset_frac: f32) -> f32 { fn boundary_bounds(count: usize, k: usize) -> (f32, f32) {
let span_lo: f32 = nw[..k].iter().sum(); let above = (k + 1) as f32;
let span = nw[k] + nw[k + 1]; let below = (count - 1 - k) as f32;
if span <= 0.0 { (above * MIN_FRAC, 1.0 - below * MIN_FRAC)
return 0.5;
}
let base_b = span_lo + nw[k];
let b = (base_b + offset_frac).clamp(span_lo + MIN_FRAC * span, span_lo + span - MIN_FRAC * span);
((b - span_lo) / span).clamp(0.0, 1.0)
} }
/// Live pane rects while dragging divider `k` (resize-only; membership is unchanged until release). /// New normalized weights when divider `k` is moved to boundary position `b`: the panes above the
fn divider_live( /// divider are scaled to fill `[0, b]` and those below to fill `[b, 1]`, each group keeping its
top: usize, /// internal proportions. This makes the *other* dividers in the group move with the dragged one.
count: usize, fn weights_for_boundary(nw: &[f32], k: usize, b: f32) -> Vec<f32> {
rect: egui::Rect, let count = nw.len();
nw: &[f32], let above_sum: f32 = nw[..=k].iter().sum();
k: usize, let below_sum: f32 = nw[k + 1..].iter().sum();
offset_frac: f32,
) -> Vec<(usize, egui::Rect)> {
let f = divider_local(nw, k, offset_frac);
let span = nw[k] + nw[k + 1];
let mut w = nw.to_vec(); let mut w = nw.to_vec();
w[k] = f * span; if above_sum > 0.0 {
w[k + 1] = (1.0 - f) * span; let s = b / above_sum;
config_rects(top, count, rect, &w) for x in &mut w[..=k] {
*x *= s;
}
}
if below_sum > 0.0 {
let s = (1.0 - b) / below_sum;
for x in &mut w[k + 1..count] {
*x *= s;
}
}
w
}
fn to_arr(v: &[f32]) -> [f32; 3] {
let mut a = [0.0; 3];
for (i, x) in v.iter().take(3).enumerate() {
a[i] = *x;
}
a
}
fn even_arr(count: usize) -> [f32; 3] {
let mut a = [0.0; 3];
let e = 1.0 / count as f32;
for x in a.iter_mut().take(count) {
*x = e;
}
a
} }
/// Interpolate between two precomputed rect lists by `t` (used for edge membership transitions). /// Interpolate between two precomputed rect lists by `t` (used for edge membership transitions).
@ -248,42 +348,50 @@ pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state
let pane_h = content_area.height() / count as f32; let pane_h = content_area.height() / count as f32;
let trigger = pane_h.min(TRIGGER_MAX); let trigger = pane_h.min(TRIGGER_MAX);
// Resting band rects (weighted; used for interaction so drags don't chase the animated layout).
// 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 now = ui.input(|i| i.time);
let mut nw = nweights(&state.weights, count); // Resting band rects from the committed weights — used for interaction so handles sit at their
if state.drag.is_none() { // final positions even while the visuals animate.
if let Some(a) = state.snap_anim { let nw = nweights(&state.weights, count);
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); 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 // The draw layout: a live drag, an in-flight layout ease, or the resting config.
// membership transition. let draw_layout = if let Some(d) = state.drag {
let draw_layout = match state.drag { match d.handle {
Some(d) => match d.handle {
Handle::Divider(k) if k + 1 < count => { Handle::Divider(k) if k + 1 < count => {
// Live resize: move divider k's boundary. In 3-pane the dividers are linked along the
// preset path; in 2-pane it's a simple group resize.
let off_frac = d.offset / content_area.height().max(1.0); let off_frac = d.offset / content_area.height().max(1.0);
divider_live(top, count, content_area, &nw, k, off_frac) let b0: f32 = nw[..=k].iter().sum();
let b = b0 + off_frac;
let w = if count == 3 {
live_weights3(k, b)
} else {
let (bmin, bmax) = boundary_bounds(count, k);
to_arr(&weights_for_boundary(&nw, k, b.clamp(bmin, bmax)))
};
config_rects(top, count, content_area, &nweights(&w, count))
} }
_ => resolve(d.handle, d.offset, top, count, trigger) _ => resolve(d.handle, d.offset, top, count, trigger)
.map(|(tt, tc, t)| { .map(|(tt, tc, t)| {
let even = vec![1.0 / tc as f32; tc]; let target = config_rects(tt, tc, content_area, &nweights(&even_arr(tc), tc));
let target = config_rects(tt, tc, content_area, &even);
interp_layout(&rest_bands, &target, top, tt, t, content_area) interp_layout(&rest_bands, &target, top, tt, t, content_area)
}) })
.unwrap_or_else(|| rest_bands.clone()), .unwrap_or_else(|| rest_bands.clone()),
}, }
None => rest_bands.clone(), } else if let Some(a) = state.anim {
let p = (((now - a.start) / SNAP_ANIM_SECS) as f32).clamp(0.0, 1.0);
if p >= 1.0 {
state.anim = None;
rest_bands.clone()
} else {
ui.ctx().request_repaint();
let e = ease_out(p);
let from = config_rects(a.from_top, a.from_count, content_area, &nweights(&a.from_w, a.from_count));
let to = config_rects(a.to_top, a.to_count, content_area, &nweights(&a.to_w, a.to_count));
interp_layout(&from, &to, a.from_top, a.to_top, e, content_area)
}
} else {
rest_bands.clone()
}; };
// 1) Pane content, carving the header off the top of each band. // 1) Pane content, carving the header off the top of each band.
@ -402,14 +510,14 @@ fn handle_key(h: Handle) -> (usize, usize) {
} }
/// Toggle a slot between filling the stack (count==1) and a 2-pane split with an adjacent pane. /// Toggle a slot between filling the stack (count==1) and a 2-pane split with an adjacent pane.
fn toggle_fullscreen(state: &mut MobileState, slot: usize) { fn toggle_fullscreen(state: &mut MobileState, slot: usize, now: f64) {
if state.window_count == 1 && state.window_top == slot { if state.window_count == 1 && state.window_top == slot {
// Restore: split with the pane below if possible, else above. // Restore: split with the pane below if possible, else above.
if let Some((t, c)) = op_r5(slot, 1).or_else(|| op_r6(slot, 1)) { if let Some((t, c)) = op_r5(slot, 1).or_else(|| op_r6(slot, 1)) {
set_window(state, t, c); set_window(state, t, c, now);
} }
} else { } else {
set_window(state, slot, 1); set_window(state, slot, 1, now);
} }
} }
@ -447,7 +555,7 @@ fn handle_interactions(
); );
let fsresp = ui.interact(fs, ui.id().with(("mobile_fs", slot)), egui::Sense::click()); let fsresp = ui.interact(fs, ui.id().with(("mobile_fs", slot)), egui::Sense::click());
if fsresp.clicked() { if fsresp.clicked() {
toggle_fullscreen(state, slot); toggle_fullscreen(state, slot, now);
} }
if STACK[slot] == StackPane::NodeInstrument { if STACK[slot] == StackPane::NodeInstrument {
let nt = egui::Rect::from_min_max( let nt = egui::Rect::from_min_max(
@ -463,7 +571,7 @@ fn handle_interactions(
if resp.drag_started() { if resp.drag_started() {
state.drag = Some(StackDrag { handle, offset: 0.0 }); state.drag = Some(StackDrag { handle, offset: 0.0 });
state.snap_anim = None; // a fresh drag cancels any in-flight snap ease state.anim = None; // a fresh drag cancels any in-flight ease
} }
if resp.dragged() { if resp.dragged() {
if let Some(d) = &mut state.drag { if let Some(d) = &mut state.drag {
@ -484,73 +592,127 @@ fn handle_interactions(
} }
fn commit_drag(d: StackDrag, state: &mut MobileState, content_area: egui::Rect, trigger: f32, now: f64) { fn commit_drag(d: StackDrag, state: &mut MobileState, content_area: egui::Rect, trigger: f32, now: f64) {
let h = content_area.height().max(1.0);
match d.handle { match d.handle {
Handle::Divider(k) if k + 1 < state.window_count => { Handle::Divider(k) if k + 1 < state.window_count => {
commit_divider(state, k, d.offset / content_area.height().max(1.0), now); commit_divider(state, k, d.offset / h, now);
} }
_ => { _ => {
// Edges (and degenerate dividers): membership transition, then reset to even sizing. // Edges (and degenerate dividers): membership transition. Animate from the current
if let Some((tt, tc, t)) = // config to the new one, continuing from where the drag's interp left off (~progress t).
resolve(d.handle, d.offset, state.window_top, state.window_count, trigger) let top = state.window_top;
{ let count = state.window_count;
if let Some((tt, tc, t)) = resolve(d.handle, d.offset, top, count, trigger) {
if t >= 0.5 { if t >= 0.5 {
set_window(state, tt, tc); let from_w = to_arr(&nweights(&state.weights, count));
begin_anim(state, top, count, from_w, tt, tc, even_arr(tc), t, now);
} }
} }
} }
} }
} }
/// Apply a divider release: collapse a pane (→ membership change) past the thresholds, otherwise /// Apply a divider release. 3-pane and 2-pane have different snap rules (see below). Either way the
/// snap the boundary to the nearest intermediate fraction. /// result is eased in from whatever was on screen at release.
fn commit_divider(state: &mut MobileState, k: usize, offset_frac: f32, now: f64) { fn commit_divider(state: &mut MobileState, k: usize, offset_frac: f32, now: f64) {
let top = state.window_top; let top = state.window_top;
let count = state.window_count; let count = state.window_count;
let nw = nweights(&state.weights, count); let nw = nweights(&state.weights, count);
let f = divider_local(&nw, k, offset_frac); let b0: f32 = nw[..=k].iter().sum();
let b = b0 + offset_frac; // unclamped boundary position of divider k
if f <= COLLAPSE_LO { if count == 3 {
// Pane k squeezed out at the top. // `from` = what's on screen at release.
let op = if k == 0 { op_r1(top, count) } else { op_r3(top, count) }; let from_w = live_weights3(k, b);
if let Some((tt, tc)) = op { // Sizes of the two extreme panes after this drag, to test the "grown past 66%" rule.
set_window(state, tt, tc); let pane_top = from_w[0]; // grown when dragging the upper divider down
let pane_bot = from_w[2]; // grown when dragging the lower divider up
if k == 0 && pane_top >= COLLAPSE_GROW {
// Upper pane grown out → drop the bottom pane; the surviving {0,1} divider snaps to
// where it was dropped.
if let Some((tt, tc)) = op_r4(top, 3) {
let to_w = collapse_2pane(from_w[0], from_w[1]);
begin_anim(state, top, 3, from_w, tt, tc, to_w, 0.0, now);
} }
} else if f >= COLLAPSE_HI { } else if k == 1 && pane_bot >= COLLAPSE_GROW {
// Pane k+1 squeezed out at the bottom. // Bottom pane grown out → drop the top pane; surviving {1,2} divider snaps to drop.
let op = if k == count - 2 { op_r2(top, count) } else { op_r4(top, count) }; if let Some((tt, tc)) = op_r3(top, 3) {
if let Some((tt, tc)) = op { let to_w = collapse_2pane(from_w[1], from_w[2]);
set_window(state, tt, tc); begin_anim(state, top, 3, from_w, tt, tc, to_w, 0.0, now);
}
} else if b <= COLLAPSE_LO {
if let Some((tt, tc)) = op_r1(top, 3) {
begin_anim(state, top, 3, from_w, tt, tc, even_arr(tc), 0.0, now);
}
} else if b >= COLLAPSE_HI {
if let Some((tt, tc)) = op_r2(top, 3) {
begin_anim(state, top, 3, from_w, tt, tc, even_arr(tc), 0.0, now);
} }
} else { } else {
// Snap the boundary to the nearest intermediate fraction, preserving the {k, k+1} span and // Snap both dividers together: round the path parameter to the nearest preset.
// the other panes' sizes — and ease into it. let (lo, hi) = path3_range(k);
let sf = *SNAP_FRACS let s = s_from_boundary(k, b.clamp(lo, hi)).round().clamp(0.0, 2.0);
.iter() let to_w = PRESETS_3[s as usize];
.min_by(|a, b| (**a - f).abs().total_cmp(&(**b - f).abs())) begin_anim(state, top, 3, from_w, top, 3, to_w, 0.0, now);
.unwrap(); }
let span = nw[k] + nw[k + 1]; return;
// `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 {
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; // 2-pane: group resize, snap to nearest 2-pane preset, slide off at the extremes.
state.snap_anim = Some(SnapAnim { from, to, start: now }); let (bmin, bmax) = boundary_bounds(count, k);
let from_w = to_arr(&weights_for_boundary(&nw, k, b.clamp(bmin, bmax)));
if b <= COLLAPSE_LO {
if let Some((tt, tc)) = op_r1(top, count) {
begin_anim(state, top, count, from_w, tt, tc, even_arr(tc), 0.0, now);
}
} else if b >= COLLAPSE_HI {
if let Some((tt, tc)) = op_r2(top, count) {
begin_anim(state, top, count, from_w, tt, tc, even_arr(tc), 0.0, now);
}
} else {
let to_w = nearest_preset(&from_w, count);
begin_anim(state, top, count, from_w, top, count, to_w, 0.0, now);
} }
} }
/// Switch the visible window and reset to even pane sizing. /// Given the two surviving panes' (unnormalized) sizes after a 3→2 collapse, snap to the nearest
fn set_window(state: &mut MobileState, top: usize, count: usize) { /// 2-pane preset by the first pane's proportion.
state.window_top = top; fn collapse_2pane(a: f32, b: f32) -> [f32; 3] {
state.window_count = count; let total = (a + b).max(1e-4);
state.weights = [1.0, 1.0, 1.0]; let p0 = a / total;
state.snap_anim = None; nearest_preset(&[p0, 1.0 - p0, 0.0], 2)
}
/// Switch to a new window config, easing from `from_*` to the new state over the snap duration.
/// `start_p` back-dates the animation so it can continue an in-progress drag.
fn begin_anim(
state: &mut MobileState,
from_top: usize,
from_count: usize,
from_w: [f32; 3],
to_top: usize,
to_count: usize,
to_w: [f32; 3],
start_p: f32,
now: f64,
) {
state.window_top = to_top;
state.window_count = to_count;
state.weights = to_w;
state.anim = Some(LayoutAnim {
from_top,
from_count,
from_w,
to_top,
to_count,
to_w,
start: now - start_p as f64 * SNAP_ANIM_SECS,
});
}
/// Switch the visible window and reset to even pane sizing, easing the transition.
fn set_window(state: &mut MobileState, top: usize, count: usize, now: f64) {
let from_w = to_arr(&nweights(&state.weights, state.window_count));
begin_anim(state, state.window_top, state.window_count, from_w, top, count, even_arr(count), 0.0, now);
} }