727 lines
27 KiB
Rust
727 lines
27 KiB
Rust
//! The vertical sliding-window stack engine.
|
||
//!
|
||
//! A window of 2–3 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);
|
||
}
|