Panels: the gutter keeps rows clear of the scroll bar (#2867)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Bar BorerandClaude Opus 5.5 committed 2026-11-03 04:57:14 +02:00
1 parent 7870b5916a
commit f1767c3349
12 files changed
+273 -35

No files matched your search

@@ -181,12 +181,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
let body = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), rect.max);
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("ec-scroll"), body);
let body = scroll.content;
let mut y = body.min.y + 4.0 - scroll.offset;
let mut actions: Actions = vec![];
let bp = p.with_clip_rect(body);
// Rows scroll under the header: clip their widgets (and hit tests) to the body.
let panel_clip = ui.clip_rect();
ui.set_clip_rect(body.intersect(panel_clip));
let fx: Vec<PropGroup> = layer.effects().map(|f| f.groups().cloned().collect()).unwrap_or_default();
if fx.is_empty() {
bp.text(
@@ -370,7 +368,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.set_min_width(200.0);
actions.extend(crate::menus::menu_contents(app, ui, "Effect"));
});
ui.set_clip_rect(panel_clip);
let content_h = y + scroll.offset - body.min.y;
scroll.end(ui, &mut app.auto, "effectControls.scroll", content_h, &t);
drop_payload(ui, rect, &p, &t, layer.id.0, &mut actions);
+2 -1
View File
@@ -636,8 +636,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let force_scan = contents_menu(app, ui, &ctx, rect, sr, &t);
let view = app.ui.effects_view.clone();
let list = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max);
let lp = p.with_clip_rect(list);
let mut scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("fx-scroll"), list);
let list = scroll.content;
let lp = p.with_clip_rect(list);
let query = q.trim().to_lowercase();
let searching = !query.is_empty();
let mut y = list.min.y - scroll.offset;
+2 -1
View File
@@ -13,9 +13,10 @@ use crate::{EffectcraftApp, widgets};
/// property, time and value. Read-only: it draws and scrolls but changes no document state.
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("info-scroll"), rect);
let rect = scroll.content;
let p = ui.painter().with_clip_rect(rect);
let x0 = rect.min.x + 12.0;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("info-scroll"), rect);
let mut y = rect.min.y + 14.0 - scroll.offset;
if app.pointer_comp.is_some() {
// GPU frames: read the shown frame back for sampling.
+2 -1
View File
@@ -171,10 +171,11 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
return;
}
};
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("mb-scroll"), list);
let list = scroll.content;
let tile = vec2(140.0, 112.0);
let cols = ((list.width() - 8.0) / tile.x).floor().max(1.0) as usize;
let mut budget = 2u32;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("mb-scroll"), list);
let lp = p.with_clip_rect(list);
let mut actions: Vec<(&str, Value)> = vec![];
for (i, e) in entries.iter().enumerate() {
+6 -6
View File
@@ -22,12 +22,11 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
use effectcraft_engine::preview::{PlayFrom, PreviewRange, PreviewResolution, PreviewShortcut};
let t = app.tokens;
let ctx = ui.ctx().clone();
let p = ui.painter().with_clip_rect(rect);
// The panel scrolls when it is shorter than its controls (the default workspace shows just
// the transport row, as in After Effects).
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("preview-scroll"), rect);
let panel_clip = ui.clip_rect();
ui.set_clip_rect(rect.intersect(panel_clip));
let rect = scroll.content;
let p = ui.painter().with_clip_rect(rect);
let y = rect.min.y + 12.0 - scroll.offset;
let bw = 30.0;
let total = bw * 5.0 + 16.0;
@@ -251,7 +250,6 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let status = if app.playback.playing && app.playback.caching { " — caching before playback" } else { "" };
p.text(pos2(bar.min.x, bar.max.y + 12.0), Align2::LEFT_CENTER, format!("{cached} / {total} frames cached{status}"), Tokens::ui(11.0), t.text_faint);
}
ui.set_clip_rect(panel_clip);
let content = yy + 30.0 - (rect.min.y - scroll.offset);
scroll.end(ui, &mut app.auto, "preview.scroll", content, &t);
}
@@ -392,7 +390,6 @@ pub fn audio(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
/// state runs `edit.history.goto`, reporting a failure in `app.ui.status`.
pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
// The branching history (EffectCraft's History panel): every state, branches indented under
// the state they grew from; click any state to go there (`edit.history.goto`).
let nodes = app.session.history_tree();
@@ -400,6 +397,8 @@ pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let cur = nodes.iter().position(|n| n.current).unwrap_or(0);
let scroll_id = egui::Id::unique("history-scroll");
let mut scroll = widgets::PanelScroll::begin(ui, scroll_id, rect);
let rect = scroll.content;
let p = ui.painter().with_clip_rect(rect);
// The current state scrolls into view when it changes (undo, redo, a new step).
if ui.data(|d| d.get_temp::<usize>(scroll_id.with("current"))) != Some(cur) {
ui.data_mut(|d| d.insert_temp(scroll_id.with("current"), cur));
@@ -452,9 +451,10 @@ pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
/// draws nothing without an active comp.
pub fn markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let Some(c) = app.session.active_comp_arc() else { return };
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("markers-scroll"), rect);
let rect = scroll.content;
let p = ui.painter().with_clip_rect(rect);
let mut y = rect.min.y + 8.0 - scroll.offset;
if c.markers.is_empty() {
p.text(rect.center(), Align2::CENTER_CENTER, tr("No composition markers"), Tokens::ui(12.0), t.text_faint);
+19 -4
View File
@@ -1160,12 +1160,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Column header: Name, Label, then the visible optional columns.
let hdr = Rect::from_min_size(pos2(rect.min.x, sr.max.y + 6.0), vec2(rect.width(), 20.0));
p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
let cl = layout_columns(app, rect);
// Rows and columns stay clear of the scroll bar. (By last frame's overflow: the rows are
// listed below, and how many there are does not depend on the width.)
let gutter_id = egui::Id::unique("project-gutter");
let gutter = ctx.data(|d| d.get_temp::<bool>(gutter_id)).unwrap_or(false);
let content = if gutter { Rect::from_min_max(rect.min, pos2((rect.max.x - widgets::SCROLL_GUTTER).max(rect.min.x), rect.max.y)) } else { rect };
let cl = layout_columns(app, content);
let (name_w, label_x, opt_x0, overflow, hscroll) = (cl.name_w, cl.label_x, cl.opt_x0, cl.overflow, cl.hscroll);
draw_header(app, ui, &p, hdr, &cl);
// Rows.
let footer_h = 28.0;
let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h));
let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(content.max.x, rect.max.y - footer_h));
let lp = p.with_clip_rect(list);
// The list's empty area (under the rows and the scroll bar, which take their own clicks):
// a click deselects, a drag draws a selection box, a double-click imports, as in After
@@ -1173,7 +1178,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Not a Tab stop: the keyboard reaches the rows, and nothing else here takes a key.
let empty = ui.interact(list, egui::Id::unique("proj-empty"), Sense::CLICK | Sense::DRAG);
app.auto.add("project.empty", list, "Double-click to import files; drag to select items; right-click for project actions");
hscroll_wheel(app, ui, list, overflow);
// (The wheel works over the scroll bar too.)
let wheel_area = Rect::from_min_max(list.min, pos2(rect.max.x, list.max.y));
hscroll_wheel(app, ui, wheel_area, overflow);
let moved = keyboard_nav(app, &ctx);
let rows = visible_rows(app);
enter_renames(app, &ctx);
@@ -1185,10 +1192,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let hover_y = ctx.pointer_hover_pos().filter(|p| list.contains(*p)).map(|p| p.y);
let mut drop_row: Option<ItemId> = None;
let mut actions: Vec<(String, serde_json::Value)> = vec![];
let (scroll, max_scroll) = update_scroll(app, ui, &ctx, list, &rows, moved);
let (scroll, max_scroll) = update_scroll(app, ui, &ctx, wheel_area, &rows, moved);
if gutter != (max_scroll > 0.0) {
ctx.data_mut(|d| d.insert_temp(gutter_id, max_scroll > 0.0));
ctx.request_repaint();
}
let first = ((scroll / ROW_H).floor() as usize).min(rows.len());
let mut y = list.min.y - (scroll - first as f32 * ROW_H);
let project = app.session.project.clone();
// Clip the rows' widgets (and hit tests) to the list too.
let panel_clip = ui.clip_rect();
ui.set_clip_rect(list.intersect(panel_clip));
for (i, (id, depth)) in rows.iter().enumerate().skip(first) {
let Some(it) = project.item(*id) else { continue };
let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H));
@@ -1295,6 +1309,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
crate::widgets::context_menu(&resp, |ui| item_menu(app, ui, &ctx, it, *id, selected, &mut actions));
}
ui.set_clip_rect(panel_clip);
// The scroll bars, over the rows (which would take their presses otherwise).
if overflow > 0.0 {
let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0));
+3 -1
View File
@@ -80,9 +80,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
property_overrides: &[],
media_overrides: &[],
};
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect);
let rect = scroll.content;
let p = p.with_clip_rect(rect);
let x0 = rect.min.x + PAD;
let w = rect.width() - 2.0 * PAD;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect);
let top = rect.min.y + 8.0 - scroll.offset;
let mut y = top;
let mut actions: Actions = vec![];
+5 -7
View File
@@ -850,6 +850,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// ---------------------------------------------------------- column header
let head = Rect::from_min_size(pos2(rect.min.x, top.max.y), vec2(rect.width(), HEAD_H));
// (The scroll begins here for the columns: they line up with the items, clear of its bar.)
let list = Rect::from_min_max(pos2(rect.min.x, head.max.y), pos2(rect.max.x, rect.max.y - FOOT_H));
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("rq-scroll"), list);
let list = scroll.content;
p.rect_filled(head, 0.0, t.panel_bg);
p.line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.separator));
// Columns: twirl | Render | label | # | Comp Name | Status | Started | Render Time
@@ -861,7 +865,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let w_time = 92.0;
let w_started = 156.0;
let w_status = 112.0;
let cx_time = (rect.max.x - w_time).max(cx_name + 120.0);
let cx_time = (list.max.x - w_time).max(cx_name + 120.0);
let cx_started = cx_time - w_started;
let cx_status = cx_started - w_status;
let hy = head.center().y;
@@ -873,12 +877,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// ---------------------------------------------------------- items
let list = Rect::from_min_max(pos2(rect.min.x, head.max.y), pos2(rect.max.x, rect.max.y - FOOT_H));
let lp = p.with_clip_rect(list);
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("rq-scroll"), list);
// Items scroll under the header and footer: clip their widgets (and hit tests) to the list.
let panel_clip = ui.clip_rect();
ui.set_clip_rect(list.intersect(panel_clip));
let mut y = list.min.y - scroll.offset;
if queue.is_empty() {
lp.text(pos2(list.center().x, list.min.y + 30.0), Align2::CENTER_CENTER, tr("The render queue is empty."), Tokens::ui(12.0), t.text_faint);
@@ -1015,7 +1014,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
output_to(app, ui, &ItemRow { lp: &lp, list, r, n, rendering }, it, dd.max.x, &mut editing, &mut actions);
}
}
ui.set_clip_rect(panel_clip);
let content = y + scroll.offset - list.min.y;
scroll.end(ui, &mut app.auto, "renderQueue.scroll", content, &t);
ui.data_mut(|d| match &editing {
+5 -3
View File
@@ -281,12 +281,13 @@ fn layer_fill_stroke(app: &EffectcraftApp, layer: u64) -> u32 {
/// edited range; failures show in `app.ui.status`.
pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let ctx = ui.ctx().clone();
let target = text_target(app);
let enabled = target.is_some();
let doc = target.as_ref().map(|t| t.doc.clone()).unwrap_or_default();
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("character-scroll"), rect);
let rect = scroll.content;
let p = ui.painter().with_clip_rect(rect);
let top = rect.min.y + 10.0 - scroll.offset;
let mut y = top;
let x0 = rect.min.x + 10.0;
@@ -602,14 +603,15 @@ pub fn paint_justify_glyph(p: &egui::Painter, r: Rect, j: Justify, c: egui::Colo
/// target exists; failures show in `app.ui.status`.
pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let ctx = ui.ctx().clone();
let target = text_target(app);
let enabled = target.is_some();
let doc = target.as_ref().map(|t| t.doc.clone()).unwrap_or_default();
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("paragraph-scroll"), rect);
let rect = scroll.content;
let p = ui.painter().with_clip_rect(rect);
let x0 = rect.min.x + 10.0;
let w = rect.width() - 20.0;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("paragraph-scroll"), rect);
let top = rect.min.y + 10.0 - scroll.offset;
let mut y = top;
let mut actions: Vec<serde_json::Value> = vec![];
+7 -2
View File
@@ -1809,12 +1809,17 @@ fn begin_rows(app: &mut EffectcraftApp, ui: &mut egui::Ui, tl: Tl) -> Rw {
let graph_x0 = tl.graph_x0;
let rect = tl.rect;
let lp = tl.p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y))));
let gp = tl.p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)));
// The rows reach through the gutter right of the time graph, but stop before the scroll bar.
let graph_rows = Rect::from_min_max(
pos2(graph_x0, rows_rect.min.y),
pos2(if tl.max_scroll > 0.0 { rect.max.x - widgets::SCROLL_GUTTER } else { rect.max.x }, rows_rect.max.y),
);
let gp = tl.p.with_clip_rect(rows_rect.intersect(graph_rows));
let graph_on = app.ui.timeline.graph_editor;
super::graph::track_open(ui.ctx(), graph_on);
let full_clip = ui.clip_rect();
let left_clip = full_clip.intersect(Rect::from_min_max(pos2(rect.min.x, rows_rect.min.y), pos2(graph_x0 - 1.0, rows_rect.max.y)));
let right_clip = full_clip.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
let right_clip = full_clip.intersect(graph_rows);
// Registered before the rows so keys, bars and editors on top of it get the pointer first.
let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) };
let empty = ui.interact(empty_rect, egui::Id::unique("tl-graph-bg"), Sense::click_and_drag());
+92 -5
View File
@@ -647,25 +647,59 @@ pub fn scroll_bar(ui: &mut Ui, auto: &mut Registry, key: &str, track: Rect, scro
s
}
/// The width a scrolled panel keeps clear on its right for the [`scroll_bar`] (its track, and a
/// little air before it).
pub const SCROLL_GUTTER: f32 = 8.0;
/// Whether a [`PanelScroll`]'s rows give up the [`SCROLL_GUTTER`], kept from frame to frame.
#[derive(Clone, Copy, Default)]
struct Gutter {
on: bool,
/// The rows' height at full width, until the first frame in the narrower width has measured
/// `shrunk`.
wide: Option<f32>,
/// How much shorter the rows got in the narrower width. Most rows only get taller, but a graph
/// sized by the width gets shorter: without this, rows that overflow at full width and fit
/// narrowed would drop the gutter and take it back every frame.
shrunk: f32,
}
/// Vertical scrolling for a panel that lays out its own rows: [`PanelScroll::begin`] gives the
/// offset to draw them at (moved by the mouse wheel over `area`), [`PanelScroll::end`] takes the
/// rows' height and draws a [`scroll_bar`] on the right edge while they overflow, so every
/// panel can be scrolled without a wheel or touchpad (#271).
/// offset to draw them at (moved by the mouse wheel over `area`) and the [`PanelScroll::content`]
/// to lay them out in, which clips `ui` until [`PanelScroll::end`]; that takes the rows' height
/// and draws a [`scroll_bar`] on the right edge while they overflow, so every panel can be
/// scrolled without a wheel or touchpad (#271).
pub struct PanelScroll {
id: egui::Id,
area: Rect,
/// `ui`'s clip before [`PanelScroll::begin`].
clip: Rect,
gutter: Gutter,
/// How far the rows are scrolled up.
pub offset: f32,
/// Where the rows go: the area, less the [`SCROLL_GUTTER`] while they overflow, so they are
/// cut off before the scroll bar rather than under it (#2867). Painters made before `begin`
/// keep their own clip: derive them from this.
pub content: Rect,
}
impl PanelScroll {
pub fn begin(ui: &Ui, id: egui::Id, area: Rect) -> Self {
pub fn begin(ui: &mut Ui, id: egui::Id, area: Rect) -> Self {
// (Clamped to last frame's overflow: the rows' height is known once they are drawn.)
let (mut offset, max): (f32, f32) = ui.data(|d| d.get_temp(id)).unwrap_or_default();
// (Before the clip narrows: the wheel works over the scroll bar too.)
if ui.rect_contains_pointer(area) {
offset -= ui.input(|i| i.smooth_scroll_delta.y);
}
Self { id, area, offset: offset.clamp(0.0, max.max(0.0)) }
let gutter: Gutter = ui.data(|d| d.get_temp(id.with("gutter"))).unwrap_or_default();
let mut content = area;
if gutter.on {
content.max.x = (area.max.x - SCROLL_GUTTER).max(area.min.x);
}
// Rows scroll under whatever is around them: clip their widgets (and hit tests) too.
let clip = ui.clip_rect();
ui.set_clip_rect(content.intersect(clip));
Self { id, area, clip, gutter, offset: offset.clamp(0.0, max.max(0.0)), content }
}
/// Scroll the panel `id` (a [`PanelScroll`] kept under that id) so the content from `top` to
@@ -687,6 +721,7 @@ impl PanelScroll {
/// The rows were `content` points tall: keep the offset, with the scroll bar registered for
/// automation as `key`.
pub fn end(self, ui: &mut Ui, auto: &mut Registry, key: &str, content: f32, t: &Tokens) {
ui.set_clip_rect(self.clip);
let max = (content - self.area.height()).max(0.0);
let mut offset = self.offset.min(max);
if max > 0.0 {
@@ -694,6 +729,20 @@ impl PanelScroll {
offset = scroll_bar(ui, auto, key, track, offset, max, t);
}
ui.data_mut(|d| d.insert_temp(self.id, (offset, max)));
let mut gutter = self.gutter;
if gutter.on {
if let Some(wide) = gutter.wide.take() {
gutter.shrunk = (wide - content).max(0.0);
}
gutter.on = content + gutter.shrunk > self.area.height();
} else if max > 0.0 {
gutter = Gutter { on: true, wide: Some(content), shrunk: 0.0 };
}
ui.data_mut(|d| d.insert_temp(self.id.with("gutter"), gutter));
// The rows were laid out for the other width: lay them out again.
if gutter.on != self.gutter.on {
ui.ctx().request_repaint();
}
}
}
@@ -1060,6 +1109,44 @@ pub(crate) fn shifted(c: &[f64], d: usize, v: f64) -> Vec<f64> {
mod tests {
use super::*;
/// One frame of rows `rows(width)` tall in a 100 tall `PanelScroll`: the width they got.
fn scroll_frame(ctx: &egui::Context, rows: &dyn Fn(f32) -> f32) -> f32 {
let t = Tokens::for_kind(Default::default());
let mut auto = Registry::default();
let mut width = 0.0;
let area = Rect::from_min_size(pos2(10.0, 10.0), vec2(300.0, 100.0));
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
let outer = ui.clip_rect();
let scroll = PanelScroll::begin(ui, egui::Id::unique("rows"), area);
width = scroll.content.width();
assert_eq!(ui.clip_rect(), scroll.content.intersect(outer), "the widgets are clipped to the rows");
scroll.end(ui, &mut auto, "rows.scroll", rows(width), &t);
assert_eq!(ui.clip_rect(), outer, "and only until the end");
});
output.textures_delta.clear();
width
}
/// Rows that fit only in the narrower width (a graph sized by the width gets shorter) keep the
/// gutter instead of dropping it and taking it back every frame, and give it up once they fit
/// at full width too (#2867).
#[test]
fn panel_scroll_gutter_settles() {
let (full, narrow) = (300.0, 300.0 - SCROLL_GUTTER);
let ctx = egui::Context::default();
let widths = |tall: f32, frames: usize| {
let rows = move |w: f32| if w < full { tall - 8.0 } else { tall };
(0..frames).map(|_| scroll_frame(&ctx, &rows)).collect::<Vec<_>>()
};
assert_eq!(widths(90.0, 3), [full; 3], "rows that fit: full width");
assert_eq!(widths(104.0, 6), [full, narrow, narrow, narrow, narrow, narrow], "fit only narrowed: stays narrowed");
assert_eq!(widths(200.0, 3), [narrow; 3], "overflow: narrowed");
assert_eq!(widths(90.0, 3), [narrow, full, full], "fit again: full width");
// Rows that overflow only when narrowed stay at full width.
let rows = |w: f32| if w < full { 104.0 } else { 96.0 };
assert_eq!((0..3).map(|_| scroll_frame(&ctx, &rows)).collect::<Vec<_>>(), [full; 3]);
}
fn checkbox_frame(ctx: &egui::Context, values: &mut [Option<bool>; 5], events: Vec<egui::Event>) {
let t = Tokens::for_kind(Default::default());
let input = egui::RawInput { events, ..Default::default() };
+129
View File
@@ -319,6 +319,135 @@ fn panels_taller_than_their_space_have_a_scroll_bar_to_drag() {
}
}
/// A window of `height` with one `panel` maximized, with rows to fill it: effects on the selected
/// layers, queued renders, markers with a long comment, a folder of files.
fn panel_with_rows(panel: effectcraft_ui_egui::dock::PanelKind, height: f32, folder: &std::path::Path) -> Harness<'static, EffectcraftApp> {
let mut h = harness(640.0, height);
let app = h.state_mut();
let layers: Vec<u64> = app.session.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
app.session.execute("layer.select", json!({"layers": layers})).unwrap();
for e in ["slider", "angle", "checkbox", "color", "point"] {
app.session.execute("effect.apply", json!({"effect": format!("ec.control.{e}"), "layers": [layers[0]]})).unwrap();
}
for i in 0..6 {
app.session.execute("renderQueue.add", json!({})).unwrap();
let comment = "a marker comment long enough to run right across the panel and off its far edge, under anything there";
app.session.execute("markers.set", json!({"new": true, "time": 1.0 + i as f64 * 0.5, "comment": comment})).unwrap();
}
app.session.execute("mediaBrowser.go", json!({"path": folder.to_string_lossy(), "importableOnly": false})).unwrap();
app.show_panel(panel);
app.ui.maximized = Some(panel);
h.run_steps(4);
h
}
/// A folder of files for the Media Browser's tiles, under a name only `test` uses.
fn folder_of_files(test: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ec-ui-scrolling-{test}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for i in 0..24 {
std::fs::write(dir.join(format!("file{i}.txt")), "x").unwrap();
}
dir
}
/// The right edge of the elements under `prefix` level with `track` (or anywhere, without one),
/// other than the scroll bar `bar`.
fn rightmost(h: &Harness<'_, EffectcraftApp>, prefix: &str, bar: &str, track: Option<Rect>) -> Option<f32> {
h.state()
.auto
.query(prefix)
.into_iter()
.filter(|e| e.id != bar)
.map(|e| Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])))
.filter(|r| track.is_none_or(|t| r.max.y > t.min.y && r.min.y < t.max.y))
.map(|r| r.max.x)
.reduce(f32::max)
}
/// The text painted this frame, with where it shows (cut by its clip).
fn painted_text(h: &Harness<'_, EffectcraftApp>) -> Vec<(String, Rect)> {
h.output()
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect().intersect(c.clip_rect))),
_ => None,
})
.collect()
}
/// The panels that scroll their rows: the elements in them (none for a panel that only paints),
/// the scroll bar, and how far in from the panel's edge the rows end at full width (`None`: their
/// width is capped).
const SCROLLED_PANELS: [(effectcraft_ui_egui::dock::PanelKind, &str, &str, Option<f32>); 12] = {
use effectcraft_ui_egui::dock::PanelKind as P;
[
(P::EffectControls, "effectControls.", "effectControls.scroll", Some(0.0)),
(P::EffectsPresets, "effects.", "effects.scroll", Some(0.0)),
(P::Info, "info.", "info.scroll", None),
(P::MediaBrowser, "mediaBrowser.entry.", "mediaBrowser.scroll", None),
(P::Preview, "preview.", "preview.scroll", None),
(P::History, "history.state.", "history.scroll", Some(0.0)),
(P::Markers, "markers.", "markers.scroll", None),
(P::Properties, "properties.", "properties.scroll", Some(12.0)),
(P::RenderQueue, "renderQueue.item.", "renderQueue.scroll", Some(0.0)),
(P::Character, "character.", "character.scroll", Some(10.0)),
(P::Paragraph, "paragraph.", "paragraph.scroll", Some(10.0)),
(P::Project, "project.item.", "project.vscroll", Some(0.0)),
]
};
/// While a panel's rows overflow, they (and their widgets and text) stop before its scroll bar,
/// which was drawn over them (#2867).
#[test]
fn panel_rows_stop_before_their_scroll_bar() {
let folder = folder_of_files("short");
for (panel, prefix, bar, _) in SCROLLED_PANELS {
// (The Project panel's list starts below its item preview and search: a taller window.)
let h = panel_with_rows(panel, if bar == "project.vscroll" { 330.0 } else { 240.0 }, &folder);
assert!(scrolled(&h, bar).1 > 0.0, "{bar}: the rows overflow");
let track = rect(&h, bar);
if let Some(right) = rightmost(&h, prefix, bar, Some(track)) {
if right > track.min.x { eprintln!("MUT {}", format!("{bar}: the rows end at {right}, on the track {track:?}")); }
} else {
assert!(matches!(bar, "info.scroll" | "markers.scroll"), "{bar}: no rows beside the track");
}
let lane = Rect::from_x_y_ranges(track.min.x..=track.max.x, track.y_range());
for (text, at) in painted_text(&h) {
if at.intersects(lane) { eprintln!("MUT {}", format!("{bar}: {text:?} shows at {at:?}, on the track {track:?}")); }
}
}
let _ = std::fs::remove_dir_all(&folder);
}
/// Panels whose rows fit show no bar and give up no width for one.
#[test]
fn panels_with_room_keep_their_full_width() {
let folder = folder_of_files("tall");
for (panel, prefix, bar, inset) in SCROLLED_PANELS {
let h = panel_with_rows(panel, 1600.0, &folder);
assert!(h.state().auto.find(bar).is_none(), "{bar}: the rows fit");
let edge = rect(&h, &format!("panel.{}", panel.id())).max.x - 1.0;
if let Some(inset) = inset {
let right = rightmost(&h, prefix, bar, None).unwrap_or_else(|| panic!("{bar}: no rows"));
assert!((edge - inset - right).abs() < 0.01, "{bar}: the rows end at {right}, {inset} in from {edge}");
}
}
let _ = std::fs::remove_dir_all(&folder);
}
/// The Render Queue's header columns stay over their columns when the rows narrow for the bar.
#[test]
fn render_queue_header_lines_up_with_narrowed_rows() {
use effectcraft_ui_egui::dock::PanelKind;
let h = panel_with_rows(PanelKind::RenderQueue, 240.0, &std::env::temp_dir());
assert!(scrolled(&h, "renderQueue.scroll").1 > 0.0, "the rows overflow");
let status = rect(&h, "renderQueue.item.1.status");
let header = painted_text(&h).into_iter().find(|(text, _)| text == "Status").expect("the Status header");
assert!((header.1.min.x - status.min.x - 2.0).abs() < 2.0, "the header ({:?}) over the column ({status:?})", header.1);
}
/// In a narrow Effect Controls panel an animated point's values stay clear of its keyframe
/// navigator (â—€ â—† â–¶), which was drawn over them (#271).
#[test]