Fix stack overflow at list ends; full-height drag headers

- Fix panic "attempt to subtract with overflow" when sliding to the end:
  the R-ops used then_some(), which eagerly evaluates top-1 even when the
  guard is false. Switch to lazy .then(|| ...).
- Replace the thin divider/edge grab bars with full-width header bars: each
  band has a labeled header (top of band) that is its drag handle, plus a
  reserved bottom footer for the BottomEdge "pull up for more" handle. Band 0's
  header = TopEdge, band i's header = the divider above it, footer = BottomEdge.
  The Node/Instrument ⇄ toggle lives in that band's header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Skyler Lehmkuhl 2026-06-30 00:13:08 -04:00
parent 79f063d00c
commit 0c59ed8b4b
1 changed files with 125 additions and 85 deletions

View File

@ -23,13 +23,16 @@ use crate::RenderContext;
const N: usize = STACK.len(); const N: usize = STACK.len();
const EDGE_GRAB_H: f32 = 16.0; /// Height of each band's drag header (and the bottom-edge footer). The whole header is the grab
const DIV_GRAB_H: f32 = 18.0; /// target — there's no thin divider bar.
const HEADER_H: f32 = 26.0;
const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49); const C_LINE: egui::Color32 = egui::Color32::from_rgb(0x36, 0x3d, 0x49);
const C_AMBER: egui::Color32 = egui::Color32::from_rgb(0xf4, 0xa3, 0x40); const C_AMBER: egui::Color32 = egui::Color32::from_rgb(0xf4, 0xa3, 0x40);
const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x7c, 0x86, 0x93); const C_DIM: egui::Color32 = egui::Color32::from_rgb(0x7c, 0x86, 0x93);
const C_CHIP_BG: egui::Color32 = egui::Color32::from_rgba_premultiplied(0x1b, 0x1f, 0x27, 0xcc); const C_BRIGHT: egui::Color32 = egui::Color32::from_rgb(0xea, 0xee, 0xf3);
const C_HEADER: egui::Color32 = egui::Color32::from_rgb(0x1f, 0x24, 0x2c);
const C_HEADER_HOT: egui::Color32 = egui::Color32::from_rgb(0x27, 0x2d, 0x37);
/// A draggable boundary of the stack. /// A draggable boundary of the stack.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -43,16 +46,16 @@ pub enum Handle {
// --- R1..R6 as pure ops on (top, count) -> Option<(top, count)> --- // --- R1..R6 as pure ops on (top, count) -> Option<(top, count)> ---
fn op_r1(top: usize, count: usize) -> Option<(usize, usize)> { fn op_r1(top: usize, count: usize) -> Option<(usize, usize)> {
(top + count < N).then_some((top + 1, count)) // slide down (top + count < N).then(|| (top + 1, count)) // slide down
} }
fn op_r2(top: usize, count: usize) -> Option<(usize, usize)> { fn op_r2(top: usize, count: usize) -> Option<(usize, usize)> {
(top > 0).then_some((top - 1, count)) // slide up (top > 0).then(|| (top - 1, count)) // slide up
} }
fn op_r3(top: usize, count: usize) -> Option<(usize, usize)> { fn op_r3(top: usize, count: usize) -> Option<(usize, usize)> {
(count == 3).then_some((top + 1, 2)) // drop top (count == 3).then(|| (top + 1, 2)) // drop top
} }
fn op_r4(top: usize, count: usize) -> Option<(usize, usize)> { fn op_r4(top: usize, count: usize) -> Option<(usize, usize)> {
(count == 3).then_some((top, 2)) // drop bottom (count == 3).then(|| (top, 2)) // drop bottom
} }
fn op_r5(top: usize, count: usize) -> Option<(usize, usize)> { fn op_r5(top: usize, count: usize) -> Option<(usize, usize)> {
if count < 3 && top + count < N { if count < 3 && top + count < N {
@ -164,79 +167,112 @@ fn interp_rects(
// --- rendering --- // --- 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) { pub fn render(ui: &mut egui::Ui, rect: egui::Rect, rc: &mut RenderContext, state: &mut MobileState) {
let top = state.window_top; let top = state.window_top;
let count = state.window_count; let count = state.window_count;
let pane_h = rect.height() / count as f32;
// Layout from the (previous frame's) drag, if any has a valid op; else the resting config. // Reserve a footer bar at the very bottom for the BottomEdge handle.
let layout = state let footer_rect = egui::Rect::from_min_max(
egui::pos2(rect.left(), rect.bottom() - HEADER_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;
// Resting band rects (used for interaction so drags don't chase the animated layout) and the
// possibly-animated draw layout.
let rest_bands = config_rects(top, count, content_area);
let draw_layout = state
.drag .drag
.and_then(|d| resolve(d.handle, d.offset, top, count, pane_h)) .and_then(|d| resolve(d.handle, d.offset, top, count, pane_h))
.map(|(tt, tc, t)| interp_rects((top, count), (tt, tc), t, rect)) .map(|(tt, tc, t)| interp_rects((top, count), (tt, tc), t, content_area))
.unwrap_or_else(|| config_rects(top, count, rect)); .unwrap_or_else(|| rest_bands.clone());
// 1) Pane content (top to bottom). // 1) Pane content, carving the header off the top of each band.
for (slot, prect) in &layout { for (slot, brect) in &draw_layout {
if prect.height() < 1.0 { let header_h = HEADER_H.min(brect.height());
continue; let content_rect = egui::Rect::from_min_max(
} egui::pos2(brect.left(), brect.top() + header_h),
let sp = STACK[*slot]; brect.max,
super::surface::render_surface_fullbleed(
ui,
*prect,
&slot_path(*slot),
sp.pane_type(state.show_instruments),
rc,
); );
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) Per-band label chips + the Node/Instrument toggle (drawn over content). // 2) Header visuals (animated positions).
for (slot, prect) in &layout { for (slot, brect) in &draw_layout {
if prect.height() < 24.0 { if brect.height() < 6.0 {
continue; continue;
} }
draw_band_chip(ui, *prect, STACK[*slot], state); 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, false);
} }
draw_footer(ui, footer_rect, top + count >= N);
// 3) Handles (interacted last so they win the initial press on their thin strips). // 3) Interactions on the resting header/footer rects (added last → they win the press).
handle_interactions(ui, rect, state); handle_interactions(ui, &rest_bands, footer_rect, state);
} }
fn draw_band_chip(ui: &mut egui::Ui, prect: egui::Rect, sp: StackPane, state: &mut MobileState) { fn draw_header(ui: &egui::Ui, hr: egui::Rect, sp: StackPane, show_instruments: bool, hot: bool) {
let label = sp.label(state.show_instruments); let p = ui.painter();
let pos = prect.left_top() + egui::vec2(8.0, 6.0); p.rect_filled(hr, 0.0, if hot { C_HEADER_HOT } else { C_HEADER });
let galley = ui.painter().layout_no_wrap( p.hline(hr.x_range(), hr.bottom(), egui::Stroke::new(1.0, C_LINE));
label.to_string(), // Grip dots on the left.
egui::FontId::proportional(11.0), let cy = hr.center().y;
C_DIM, for i in 0..2 {
let x = hr.left() + 9.0 + i as f32 * 4.0;
p.circle_filled(egui::pos2(x, cy), 1.3, C_DIM);
}
p.text(
egui::pos2(hr.left() + 22.0, cy),
egui::Align2::LEFT_CENTER,
sp.label(show_instruments),
egui::FontId::proportional(12.0),
C_BRIGHT,
); );
let chip = egui::Rect::from_min_size(pos, galley.size() + egui::vec2(12.0, 5.0)); // Node/Instrument toggle marker (its hit area is wired in handle_interactions).
ui.painter().rect_filled(chip, 4.0, C_CHIP_BG);
ui.painter()
.galley(chip.min + egui::vec2(6.0, 2.0), galley, C_DIM);
// Node/Instrument toggle sits just to the right of the label chip.
if sp == StackPane::NodeInstrument { if sp == StackPane::NodeInstrument {
let tog = egui::Rect::from_min_size( p.text(
egui::pos2(chip.right() + 6.0, chip.top()), egui::pos2(hr.right() - 14.0, cy),
egui::vec2(24.0, chip.height()),
);
let resp = ui.interact(tog, ui.id().with("mobile_node_toggle"), egui::Sense::click());
ui.painter().rect_filled(tog, 4.0, C_CHIP_BG);
ui.painter().text(
tog.center(),
egui::Align2::CENTER_CENTER, egui::Align2::CENTER_CENTER,
"", "",
egui::FontId::proportional(13.0), egui::FontId::proportional(14.0),
if resp.hovered() { C_AMBER } else { C_DIM }, C_AMBER,
); );
if resp.clicked() {
state.show_instruments = !state.show_instruments;
}
} }
} }
fn draw_footer(ui: &egui::Ui, fr: egui::Rect, at_end: bool) {
let p = ui.painter();
p.rect_filled(fr, 0.0, C_HEADER);
p.hline(fr.x_range(), fr.top(), egui::Stroke::new(1.0, C_LINE));
let col = if at_end { C_LINE } else { C_DIM };
p.text(
fr.center(),
egui::Align2::CENTER_CENTER,
"▲ pull up for more",
egui::FontId::proportional(11.0),
col,
);
}
fn handle_key(h: Handle) -> (usize, usize) { fn handle_key(h: Handle) -> (usize, usize) {
match h { match h {
Handle::TopEdge => (0, 0), Handle::TopEdge => (0, 0),
@ -245,39 +281,43 @@ fn handle_key(h: Handle) -> (usize, usize) {
} }
} }
fn handle_interactions(ui: &mut egui::Ui, rect: egui::Rect, state: &mut MobileState) { fn handle_interactions(
let count = state.window_count; ui: &mut egui::Ui,
let pane_h = rect.height() / count as f32; rest_bands: &[(usize, egui::Rect)],
footer_rect: egui::Rect,
state: &mut MobileState,
) {
let pane_h = rest_bands.first().map(|(_, r)| r.height()).unwrap_or(1.0);
let mut handles: Vec<(Handle, egui::Rect)> = Vec::new(); // (handle, header_rect, optional node-toggle slot)
handles.push(( let mut handles: Vec<(Handle, egui::Rect, bool)> = Vec::new();
Handle::TopEdge, for (i, (slot, brect)) in rest_bands.iter().enumerate() {
egui::Rect::from_min_max(rect.left_top(), egui::pos2(rect.right(), rect.top() + EDGE_GRAB_H)), let hr = egui::Rect::from_min_max(
)); brect.left_top(),
for k in 0..count.saturating_sub(1) { egui::pos2(brect.right(), brect.top() + HEADER_H.min(brect.height())),
let y = rect.top() + (k + 1) as f32 * pane_h; );
handles.push(( let handle = if i == 0 { Handle::TopEdge } else { Handle::Divider(i - 1) };
Handle::Divider(k), let is_node = STACK[*slot] == StackPane::NodeInstrument;
egui::Rect::from_min_max( handles.push((handle, hr, is_node));
egui::pos2(rect.left(), y - DIV_GRAB_H * 0.5),
egui::pos2(rect.right(), y + DIV_GRAB_H * 0.5),
),
));
} }
handles.push(( handles.push((Handle::BottomEdge, footer_rect, false));
Handle::BottomEdge,
egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - EDGE_GRAB_H), rect.max),
));
for (handle, hrect) in handles { for (handle, hrect, is_node) in handles {
let id = ui.id().with(("mobile_stack_handle", handle_key(handle))); let id = ui.id().with(("mobile_stack_handle", handle_key(handle)));
let resp = ui.interact(hrect, id, egui::Sense::drag()); let resp = ui.interact(hrect, id, egui::Sense::click_and_drag());
let active = state.drag.map(|d| d.handle == handle).unwrap_or(false); // Node/Instrument toggle: a small hit area on the right of the header, interacted AFTER the
// Grab pill. // header (so it's on top and wins clicks there); the rest of the header drives the drag.
let pill = egui::Rect::from_center_size(hrect.center(), egui::vec2(40.0, 4.0)); if is_node {
let pill_col = if resp.hovered() || active { C_AMBER } else { C_LINE }; let tog = egui::Rect::from_min_max(
ui.painter().rect_filled(pill, 2.0, pill_col); egui::pos2(hrect.right() - 28.0, hrect.top()),
hrect.max,
);
let tresp = ui.interact(tog, ui.id().with("mobile_node_toggle"), egui::Sense::click());
if tresp.clicked() {
state.show_instruments = !state.show_instruments;
}
}
if resp.drag_started() { if resp.drag_started() {
state.drag = Some(StackDrag { handle, offset: 0.0 }); state.drag = Some(StackDrag { handle, offset: 0.0 });
@ -294,7 +334,7 @@ fn handle_interactions(ui: &mut egui::Ui, rect: egui::Rect, state: &mut MobileSt
commit_drag(d, state, pane_h); commit_drag(d, state, pane_h);
} }
} }
if resp.hovered() || active { if resp.hovered() || state.drag.map(|d| d.handle == handle).unwrap_or(false) {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
} }
} }