Lightningbeam/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs

727 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The vertical sliding-window stack engine.
//!
//! A window of 23 consecutive panes (from [`super::STACK`]) is visible. Dragging the dividers
//! between visible panes and the top/bottom screen edges grows, shrinks, or slides the window via
//! the operations below (verified against the user's transition table):
//!
//! ```text
//! R1 upper divider, UP -> slide window DOWN one (top+=1)
//! R4 upper divider, DOWN -> drop BOTTOM pane (count 3->2)
//! R3 lower divider, UP -> drop TOP pane (top+=1, count 3->2)
//! R2 lower divider, DOWN -> slide window UP one (top-=1)
//! R5 bottom edge, UP -> grow at bottom; if already 3, slide down
//! R6 top edge, DOWN -> grow at top; if already 3, slide up
//! ```
//!
//! At count==2 the single divider is "upper" when dragged up (R1) and "lower" when dragged
//! down (R2). The window content interpolates continuously during a drag and snaps on release.
use eframe::egui;
use super::{icons, slot_path, LayoutAnim, MobileState, Palette, StackDrag, StackPane, STACK};
use crate::RenderContext;
const N: usize = STACK.len();
/// Height of each band's drag header (and the bottom-edge footer). The whole header is the grab
/// target — there's no thin divider bar.
const HEADER_H: f32 = 52.0;
/// Corner radius (px) for the rounded top of each header.
const HEADER_RADIUS: u8 = 9;
const FOOTER_H: f32 = 28.0;
/// Width of a header right-side button (fullscreen, node toggle).
const BTN_W: f32 = 44.0;
/// Max pixels of drag to complete a window transition (so you don't drag half the screen).
const TRIGGER_MAX: f32 = 150.0;
/// A draggable boundary of the stack.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Handle {
TopEdge,
/// Divider below visible pane `k` (between visible pane `k` and `k+1`).
Divider(usize),
BottomEdge,
}
// --- R1..R6 as pure ops on (top, count) -> Option<(top, count)> ---
fn op_r1(top: usize, count: usize) -> Option<(usize, usize)> {
(top + count < N).then(|| (top + 1, count)) // slide down
}
fn op_r2(top: usize, count: usize) -> Option<(usize, usize)> {
(top > 0).then(|| (top - 1, count)) // slide up
}
fn op_r3(top: usize, count: usize) -> Option<(usize, usize)> {
(count == 3).then(|| (top + 1, 2)) // drop top
}
fn op_r4(top: usize, count: usize) -> Option<(usize, usize)> {
(count == 3).then(|| (top, 2)) // drop bottom
}
fn op_r5(top: usize, count: usize) -> Option<(usize, usize)> {
if count < 3 && top + count < N {
Some((top, count + 1)) // grow bottom
} else if count == 3 && top + count < N {
Some((top + 1, count)) // slide down
} else {
None
}
}
fn op_r6(top: usize, count: usize) -> Option<(usize, usize)> {
if count < 3 && top > 0 {
Some((top - 1, count + 1)) // grow top
} else if count == 3 && top > 0 {
Some((top - 1, count)) // slide up
} else {
None
}
}
/// Given a handle and signed drag offset, resolve the target window config and progress `t`.
/// Returns None if the drag direction has no valid operation (e.g. at a list boundary).
fn resolve(
handle: Handle,
offset: f32,
top: usize,
count: usize,
trigger: f32,
) -> Option<(usize, usize, f32)> {
let going_up = offset < 0.0;
let t = (offset.abs() / trigger.max(1.0)).clamp(0.0, 1.0);
let target = match handle {
Handle::TopEdge => (!going_up).then(|| op_r6(top, count)).flatten(),
Handle::BottomEdge => going_up.then(|| op_r5(top, count)).flatten(),
Handle::Divider(k) => {
if count == 2 {
if going_up { op_r1(top, count) } else { op_r2(top, count) }
} else if k == 0 {
// upper divider
if going_up { op_r1(top, count) } else { op_r4(top, count) }
} else {
// lower divider
if going_up { op_r3(top, count) } else { op_r2(top, count) }
}
}
};
target.map(|(tt, tc)| (tt, tc, t))
}
// --- rect layout ---
/// Below/above these boundary positions, a divider release collapses a group → membership change.
const COLLAPSE_LO: f32 = 0.12;
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.
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> {
let mut w: Vec<f32> = weights[..count].iter().map(|x| x.max(0.0001)).collect();
let s: f32 = w.iter().sum();
if s > 0.0 {
for x in &mut w {
*x /= s;
}
} else {
let e = 1.0 / count as f32;
w.iter_mut().for_each(|x| *x = e);
}
w
}
/// The bottom Y of the given stack slot's band within `region` (using the current window +
/// weights), or None if that slot isn't in the visible window. Used to decide whether the
/// inspector sheet would cover the selected pane.
#[allow(dead_code)] // kept for pane-coverage heuristics
pub fn pane_bottom_in(state: &MobileState, region: egui::Rect, slot: usize) -> Option<f32> {
let (top, count) = (state.window_top, state.window_count);
if slot < top || slot >= top + count {
return None;
}
let nw = nweights(&state.weights, count);
let cum: f32 = nw[..=(slot - top)].iter().sum();
Some(region.top() + cum * region.height())
}
/// Lay out `count` panes in `rect` using normalized weights `nw`.
fn config_rects(top: usize, count: usize, rect: egui::Rect, nw: &[f32]) -> Vec<(usize, egui::Rect)> {
let h = rect.height();
let mut out = Vec::with_capacity(count);
let mut y = rect.top();
for i in 0..count {
let ph = nw[i] * h;
out.push((
top + i,
egui::Rect::from_min_max(egui::pos2(rect.left(), y), egui::pos2(rect.right(), y + ph)),
));
y += ph;
}
out
}
fn lerp_f(a: f32, b: f32, t: f32) -> f32 {
a + (b - a) * t
}
fn lerp_rect(a: egui::Rect, b: egui::Rect, t: f32) -> egui::Rect {
egui::Rect::from_min_max(
egui::pos2(lerp_f(a.min.x, b.min.x, t), lerp_f(a.min.y, b.min.y, t)),
egui::pos2(lerp_f(a.max.x, b.max.x, t), lerp_f(a.max.y, b.max.y, t)),
)
}
fn collapsed(rect: egui::Rect, at_top: bool) -> egui::Rect {
let y = if at_top { rect.top() } else { rect.bottom() };
egui::Rect::from_min_max(egui::pos2(rect.left(), y), egui::pos2(rect.right(), y))
}
/// Allowed range of divider `k`'s boundary position (normalized) given MIN_FRAC per pane.
/// The boundary splits the window into the group above (panes 0..=k) and below (panes k+1..).
fn boundary_bounds(count: usize, k: usize) -> (f32, f32) {
let above = (k + 1) as f32;
let below = (count - 1 - k) as f32;
(above * MIN_FRAC, 1.0 - below * MIN_FRAC)
}
/// New normalized weights when divider `k` is moved to boundary position `b`: the panes above the
/// divider are scaled to fill `[0, b]` and those below to fill `[b, 1]`, each group keeping its
/// internal proportions. This makes the *other* dividers in the group move with the dragged one.
fn weights_for_boundary(nw: &[f32], k: usize, b: f32) -> Vec<f32> {
let count = nw.len();
let above_sum: f32 = nw[..=k].iter().sum();
let below_sum: f32 = nw[k + 1..].iter().sum();
let mut w = nw.to_vec();
if above_sum > 0.0 {
let s = b / above_sum;
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).
fn interp_layout(
c: &[(usize, egui::Rect)],
tt: &[(usize, egui::Rect)],
top_c: usize,
top_t: usize,
t: f32,
rect: egui::Rect,
) -> Vec<(usize, egui::Rect)> {
let find = |v: &[(usize, egui::Rect)], slot: usize| v.iter().find(|(s, _)| *s == slot).map(|(_, r)| *r);
let lo = c.first().map(|(s, _)| *s).unwrap_or(0).min(tt.first().map(|(s, _)| *s).unwrap_or(0));
let hi = c.last().map(|(s, _)| *s + 1).unwrap_or(0).max(tt.last().map(|(s, _)| *s + 1).unwrap_or(0));
let mut out = Vec::new();
for slot in lo..hi {
let r = match (find(c, slot), find(tt, slot)) {
(Some(rc), Some(rt)) => lerp_rect(rc, rt, t),
(Some(rc), None) => lerp_rect(rc, collapsed(rect, slot < top_t), t), // exiting
(None, Some(rt)) => lerp_rect(collapsed(rect, slot < top_c), rt, t), // entering
(None, None) => continue,
};
out.push((slot, r));
}
out
}
// --- rendering ---
/// The interaction (drag) target for a band's boundary is its full-width header bar. Bands are
/// laid out in the area above a reserved bottom footer (the BottomEdge handle). Band 0's header is
/// the TopEdge handle; band i's header (i≥1) is the divider above it; the footer is the BottomEdge.
pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state: &mut MobileState, pal: &Palette) {
let top = state.window_top;
let count = state.window_count;
// Reserve a footer bar at the very bottom for the BottomEdge handle.
let footer_rect = egui::Rect::from_min_max(
egui::pos2(rect.left(), rect.bottom() - FOOTER_H),
rect.max,
);
let content_area = egui::Rect::from_min_max(
rect.min,
egui::pos2(rect.right(), footer_rect.top()),
);
let pane_h = content_area.height() / count as f32;
let trigger = pane_h.min(TRIGGER_MAX);
let now = ui.input(|i| i.time);
// Resting band rects from the committed weights — used for interaction so handles sit at their
// final positions even while the visuals animate.
let nw = nweights(&state.weights, count);
let rest_bands = config_rects(top, count, content_area, &nw);
// The draw layout: a live drag, an in-flight layout ease, or the resting config.
let draw_layout = if let Some(d) = state.drag {
match d.handle {
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 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)
.map(|(tt, tc, t)| {
let target = config_rects(tt, tc, content_area, &nweights(&even_arr(tc), tc));
interp_layout(&rest_bands, &target, top, tt, t, content_area)
})
.unwrap_or_else(|| 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.
for (slot, brect) in &draw_layout {
let header_h = HEADER_H.min(brect.height());
let content_rect = egui::Rect::from_min_max(
egui::pos2(brect.left(), brect.top() + header_h),
brect.max,
);
if content_rect.height() > 1.0 {
let sp = STACK[*slot];
super::surface::render_surface_fullbleed(
ui,
content_rect,
&slot_path(*slot),
sp.pane_type(state.show_instruments),
rc,
);
}
}
// 2) Header visuals (animated positions). The fullscreen icon shows "restore" when a single
// pane fills the stack.
let fullscreen = count == 1;
for (slot, brect) in &draw_layout {
if brect.height() < 6.0 {
continue;
}
let hr = egui::Rect::from_min_max(
brect.left_top(),
egui::pos2(brect.right(), brect.top() + HEADER_H.min(brect.height())),
);
draw_header(ui, hr, STACK[*slot], state.show_instruments, fullscreen, pal);
}
draw_footer(ui, footer_rect, top + count >= N, pal);
// 3) Interactions on the resting header/footer rects (added last → they win the press).
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, pal: &Palette) {
let p = ui.painter();
// Rounded top corners so the header reads as a tab atop the pane; square at the bottom where
// it meets the pane content.
p.rect_filled(
hr,
egui::CornerRadius { nw: HEADER_RADIUS, ne: HEADER_RADIUS, sw: 0, se: 0 },
pal.header,
);
p.hline(hr.x_range(), hr.bottom(), egui::Stroke::new(1.0, pal.line));
let cy = hr.center().y;
// Grip glyph on the left (Lucide).
p.text(
egui::pos2(hr.left() + 15.0, cy),
egui::Align2::CENTER_CENTER,
icons::GRIP_HORIZONTAL,
icons::font(16.0),
pal.text_dim,
);
p.text(
egui::pos2(hr.left() + 32.0, cy),
egui::Align2::LEFT_CENTER,
sp.label(show_instruments),
egui::FontId::proportional(15.0),
pal.text,
);
// Right-side buttons: fullscreen / restore rightmost, Node/Instrument toggle just left of it.
p.text(
egui::pos2(hr.right() - BTN_W * 0.5, cy),
egui::Align2::CENTER_CENTER,
if fullscreen { icons::MINIMIZE } else { icons::MAXIMIZE },
icons::font(17.0),
pal.text_dim,
);
if sp == StackPane::NodeInstrument {
p.text(
egui::pos2(hr.right() - BTN_W * 1.5, cy),
egui::Align2::CENTER_CENTER,
icons::ARROW_LEFT_RIGHT,
icons::font(17.0),
pal.accent,
);
}
}
fn draw_footer(ui: &egui::Ui, fr: egui::Rect, at_end: bool, pal: &Palette) {
let p = ui.painter();
p.rect_filled(fr, 0.0, pal.header);
p.hline(fr.x_range(), fr.top(), egui::Stroke::new(1.0, pal.line));
let col = if at_end { pal.line } else { pal.text_dim };
let cy = fr.center().y;
let galley = p.layout_no_wrap(
"pull up for more".to_string(),
egui::FontId::proportional(11.0),
col,
);
let total_w = 22.0 + galley.size().x;
let start_x = fr.center().x - total_w * 0.5;
p.text(
egui::pos2(start_x + 8.0, cy),
egui::Align2::CENTER_CENTER,
icons::CHEVRONS_UP,
icons::font(14.0),
col,
);
let gy = cy - galley.size().y * 0.5;
p.galley(egui::pos2(start_x + 22.0, gy), galley, col);
}
fn handle_key(h: Handle) -> (usize, usize) {
match h {
Handle::TopEdge => (0, 0),
Handle::Divider(k) => (1, k + 1),
Handle::BottomEdge => (2, 0),
}
}
/// 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, now: f64) {
if state.window_count == 1 && state.window_top == slot {
// 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)) {
set_window(state, t, c, now);
}
} else {
set_window(state, slot, 1, now);
}
}
fn handle_interactions(
ui: &mut egui::Ui,
rest_bands: &[(usize, egui::Rect)],
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.
let mut handles: Vec<(Handle, egui::Rect, Option<usize>)> = Vec::new();
for (i, (slot, brect)) in rest_bands.iter().enumerate() {
let hr = egui::Rect::from_min_max(
brect.left_top(),
egui::pos2(brect.right(), brect.top() + HEADER_H.min(brect.height())),
);
let handle = if i == 0 { Handle::TopEdge } else { Handle::Divider(i - 1) };
handles.push((handle, hr, Some(*slot)));
}
handles.push((Handle::BottomEdge, footer_rect, None));
for (handle, hrect, slot_opt) in handles {
let id = ui.id().with(("mobile_stack_handle", handle_key(handle)));
let resp = ui.interact(hrect, id, egui::Sense::click_and_drag());
// Right-side header buttons are interacted AFTER the header (so they're on top and win the
// press there); the rest of the header drives the drag.
if let Some(slot) = slot_opt {
let fs = egui::Rect::from_min_max(
egui::pos2(hrect.right() - BTN_W, hrect.top()),
hrect.max,
);
let fsresp = ui.interact(fs, ui.id().with(("mobile_fs", slot)), egui::Sense::click());
if fsresp.clicked() {
toggle_fullscreen(state, slot, now);
}
if STACK[slot] == StackPane::NodeInstrument {
let nt = egui::Rect::from_min_max(
egui::pos2(hrect.right() - 2.0 * BTN_W, hrect.top()),
egui::pos2(hrect.right() - BTN_W, hrect.bottom()),
);
let ntresp = ui.interact(nt, ui.id().with("mobile_node_toggle"), egui::Sense::click());
if ntresp.clicked() {
state.show_instruments = !state.show_instruments;
}
}
}
if resp.drag_started() {
state.drag = Some(StackDrag { handle, offset: 0.0 });
state.anim = None; // a fresh drag cancels any in-flight ease
}
if resp.dragged() {
if let Some(d) = &mut state.drag {
if d.handle == handle {
d.offset += resp.drag_delta().y;
}
}
}
if resp.drag_stopped() {
if let Some(d) = state.drag.take() {
commit_drag(d, state, content_area, trigger, now);
}
}
if resp.hovered() || state.drag.map(|d| d.handle == handle).unwrap_or(false) {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
}
}
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 {
Handle::Divider(k) if k + 1 < state.window_count => {
commit_divider(state, k, d.offset / h, now);
}
_ => {
// Edges (and degenerate dividers): membership transition. Animate from the current
// config to the new one, continuing from where the drag's interp left off (~progress t).
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 {
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. 3-pane and 2-pane have different snap rules (see below). Either way the
/// result is eased in from whatever was on screen at release.
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);
let b0: f32 = nw[..=k].iter().sum();
let b = b0 + offset_frac; // unclamped boundary position of divider k
if count == 3 {
// `from` = what's on screen at release.
let from_w = live_weights3(k, b);
// Sizes of the two extreme panes after this drag, to test the "grown past 66%" rule.
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 k == 1 && pane_bot >= COLLAPSE_GROW {
// Bottom pane grown out → drop the top pane; surviving {1,2} divider snaps to drop.
if let Some((tt, tc)) = op_r3(top, 3) {
let to_w = collapse_2pane(from_w[1], from_w[2]);
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 {
// Snap both dividers together: round the path parameter to the nearest preset.
let (lo, hi) = path3_range(k);
let s = s_from_boundary(k, b.clamp(lo, hi)).round().clamp(0.0, 2.0);
let to_w = PRESETS_3[s as usize];
begin_anim(state, top, 3, from_w, top, 3, to_w, 0.0, now);
}
return;
}
// 2-pane: group resize, snap to nearest 2-pane preset, slide off at the extremes.
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);
}
}
/// Given the two surviving panes' (unnormalized) sizes after a 3→2 collapse, snap to the nearest
/// 2-pane preset by the first pane's proportion.
fn collapse_2pane(a: f32, b: f32) -> [f32; 3] {
let total = (a + b).max(1e-4);
let p0 = a / total;
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);
}