diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index c17038fdd..4b3306e3d 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -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 = layer.effects().map(|f| f.groups().cloned().collect()).unwrap_or_default(); if fx.is_empty() { bp.text( @@ -378,7 +376,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); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index db2e4d83e..e3c97b392 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -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; diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index 86a1e6444..a02134191 100644 --- a/crates/ui-egui/src/panels/info.rs +++ b/crates/ui-egui/src/panels/info.rs @@ -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. diff --git a/crates/ui-egui/src/panels/media_panels.rs b/crates/ui-egui/src/panels/media_panels.rs index ffe2cc1f7..756959871 100644 --- a/crates/ui-egui/src/panels/media_panels.rs +++ b/crates/ui-egui/src/panels/media_panels.rs @@ -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() { diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 73a0b3a1c..fb3b9063c 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -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::(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); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index f84a49d84..435103fb5 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -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::(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 = 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)); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 4ef57b1ab..d828f5fa8 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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![]; diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 21dc828ca..eee99f87b 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -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 { diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 0ab0f10ec..096b41cf2 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -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 = vec![]; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 474023cdc..e07aeb843 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1820,12 +1820,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()); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 344cee0cd..162a86c6a 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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, + /// 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 { 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::>() + }; + 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::>(), [full; 3]); + } + fn checkbox_frame(ctx: &egui::Context, values: &mut [Option; 5], events: Vec) { let t = Tokens::for_kind(Default::default()); let input = egui::RawInput { events, ..Default::default() }; diff --git a/crates/ui-egui/tests/it/ui_scrolling.rs b/crates/ui-egui/tests/it/ui_scrolling.rs index 2e0f32ac8..001eb4503 100644 --- a/crates/ui-egui/tests/it/ui_scrolling.rs +++ b/crates/ui-egui/tests/it/ui_scrolling.rs @@ -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 = 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) -> Option { + 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); 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]