UI: dock columns scroll when their panels don't fit the window

A dock column's groups scroll under its top strip, each open panel at
least 160 points tall, instead of running off the bottom of the window.
Drop zones in a column only use the part of a group in view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
This commit is contained in:
redrun6767 2026-10-09 19:10:29 +00:00 committed by bflatastic
parent a7279231cb
commit b26612142f
1 changed files with 59 additions and 8 deletions

View File

@ -14,7 +14,8 @@
//! and move together by the top group's title bar, whose × docks them all; dropped on its tabs (lit
//! up), its panels join that group as tabs. A group dragged by its tab strip (a lone panel's
//! tab, or the strip right of the tabs) leaves its set and floats on its own. A double-click on a
//! tab collapses a group to its tabs, and another expands it.
//! tab collapses a group to its tabs, and another expands it. A dock column whose panels don't fit
//! the window scrolls.
//!
//! `window.panel.float` (`onto`, `below`, `above`, `collapsed`) / `window.panel.dock` do the same
//! for agents. The groups, their sets and the Tools panel's position are kept with the preferences
@ -34,6 +35,8 @@ pub const TITLE: f32 = 14.0;
pub const STRIP: f32 = 26.0;
/// Height of the bar between two groups of a set.
pub const GAP: f32 = 3.0;
/// The least height of an open panel in a dock column: more panels than fit scroll.
const MIN_TALL: f32 = 160.0;
/// How far from a group's top or bottom edge a drop stacks it above or below the group.
const EDGE: f32 = 7.0;
/// Where `window.panel.float` floats a group given no position; each further group 24 points
@ -321,9 +324,17 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2
// Another group (floating or docked): its tabs (and a set's title bar) stack the moved groups
// with it as tabs; the upper half of the group (and just above a set) puts them in its set
// right above it, the lower half (and just below it) right below it.
let columns = ctx.data(|d| d.get_temp::<Vec<Rect>>(columns_id())).unwrap_or_default();
for (i, g) in app.ui.floating_panels.iter().enumerate() {
let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| !moving.contains(&i)) else { continue };
let Some(r) = ctx.data(|d| d.get_temp::<Rect>(group_rect_id(first))) else { continue };
let Some(mut r) = ctx.data(|d| d.get_temp::<Rect>(group_rect_id(first))) else { continue };
// In a dock column that scrolls, only the part in view: a group scrolled away takes nothing.
if let Some(col) = g.docked.and_then(|d| columns.get(usize::try_from(d).ok()?.checked_sub(1)?)) {
r = r.intersect(*col);
if r.height() <= 0.0 || r.width() <= 0.0 {
continue;
}
}
let line = |y: f32| Rect::from_x_y_ranges(r.x_range(), (y - 2.0)..=(y + 2.0));
let lead = leads(&app.ui, i);
let head = Rect::from_min_size(r.min, vec2(r.width(), if lead { 1.0 + TITLE + STRIP } else { GAP + STRIP }));
@ -337,7 +348,6 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2
}
}
// A dock column's room under its groups puts them at its bottom.
let columns = ctx.data(|d| d.get_temp::<Vec<Rect>>(columns_id())).unwrap_or_default();
for ((_, set), col) in docked_sets(&app.ui).into_iter().zip(columns) {
if !col.contains(at) || set.iter().any(|i| moving.contains(i)) {
continue;
@ -345,7 +355,7 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2
let Some(last) = set.last().and_then(|&i| app.ui.floating_panels.get(i)).and_then(|g| g.panels.first()).and_then(|p| panel(p)) else {
continue;
};
let y = ctx.data(|d| d.get_temp::<Rect>(group_rect_id(last.0))).map_or(col.bottom(), |r| r.bottom());
let y = ctx.data(|d| d.get_temp::<Rect>(group_rect_id(last.0))).map_or(col.bottom(), |r| r.bottom()).min(col.bottom() - 2.0);
return Some((Drop::Below(last.0), Rect::from_x_y_ranges(col.x_range(), (y - 2.0)..=(y + 2.0))));
}
let dock = zone(dock_rect_id()).filter(|_| app.ui.dock && app.ui.screen_mode < 3)?;
@ -633,8 +643,15 @@ pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec
}
let n = groups.len() as f32;
let open = groups.iter().filter(|g| !g.collapsed).count().max(1) as f32;
let tall = ((ui.available_height() - n * STRIP - (n - 1.0) * GAP - 24.0 * open) / open).max(80.0);
draw_groups(app, ui, &groups, width, tall, bar.top(), None, &mut action);
// The open panels share the column's height, each at least `MIN_TALL`: more than fit
// scroll, with the column.
let tall = ((ui.available_height() - n * STRIP - (n - 1.0) * GAP - 24.0 * open) / open).max(MIN_TALL);
egui::ScrollArea::vertical().id_salt(("dock-column-scroll", first)).auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing = Vec2::ZERO;
let width = ui.available_width();
let top = ui.cursor().top();
draw_groups(app, ui, &groups, width, tall, top, None, &mut action);
});
});
rects.push(panel.response.rect);
}
@ -1537,7 +1554,11 @@ mod tests {
h.release(upper);
assert_eq!(order(&h), ["color", "gradient", "swatches", "stroke"]);
assert!(h.app.ui.floating_panels.iter().all(|g| g.docked == Some(1)), "all in the column");
// Dropped on Stroke's tabs, Transparency joins Stroke's group as a tab.
// Dropped on Stroke's tabs, Transparency joins Stroke's group as a tab (Color and Gradient
// collapsed, so Stroke is in view in the column, which scrolls).
for g in h.app.ui.floating_panels.iter_mut().take(2) {
g.collapsed = true;
}
h.app.run("window.panel.float", json!({"panel": "transparency", "x": 300, "y": 200})).unwrap();
h.settle();
let r = h.temp_rect(group_rect_id("stroke"));
@ -1546,7 +1567,11 @@ mod tests {
assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("transparency"), tabs).map(|t| t.0), Some(Drop::Stack("stroke")));
h.release(tabs);
assert_eq!(order(&h), ["color", "gradient", "swatches", "stroke+transparency"]);
// Dropped low in the column, Align goes at its bottom.
// Dropped in the room under the groups (three collapsed to make some), Align goes at the
// column's bottom.
for g in h.app.ui.floating_panels.iter_mut().take(3) {
g.collapsed = true;
}
h.app.run("window.panel.float", json!({"panel": "align", "x": 300, "y": 200})).unwrap();
h.settle();
let col = columns(&h)[0];
@ -1555,6 +1580,32 @@ mod tests {
assert!(h.app.ui.floating_panels.iter().all(|g| g.docked == Some(1)));
}
#[test]
fn a_dock_column_too_tall_for_the_window_scrolls() {
let mut h = Harness::new();
h.app.run("window.panel.dock", json!({"panel": "layers", "column": true})).unwrap();
let mut above = "layers";
for p in ["properties", "color", "swatches", "stroke", "gradient", "transparency", "align"] {
h.app.run("window.panel.float", json!({"panel": p, "below": above})).unwrap();
above = p;
}
h.settle();
let col = columns(&h)[0];
assert!(col.bottom() <= SCREEN.y + 1.0, "the column stays in the window: {col:?}");
let last = h.temp_rect(group_rect_id("align"));
assert!(last.top() > col.bottom(), "Align starts below the window: {last:?} {col:?}");
// The wheel over the column (Color's tabs, outside any panel's own scrolling) scrolls it,
// bringing Align up.
let color = h.temp_rect(group_rect_id("color"));
let over = pos2(color.left() + 150.0, color.top() + GAP + STRIP / 2.0);
for _ in 0..4 {
h.frame(crate::toolbar::tests::wheel(over, 300.0));
}
h.settle();
let moved = h.temp_rect(group_rect_id("align"));
assert!(moved.top() < last.top() - 100.0, "scrolled up: {moved:?} from {last:?}");
}
fn floating_pos(app: &VectorcraftApp, id: &str) -> [f32; 2] {
group_of(&app.ui, id).map(|i| app.ui.floating_panels[i].pos).unwrap()
}