From 6d2ef5a83096e328bbfda24765490cf1cb879cb9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 18:04:32 +0700 Subject: [PATCH 1/3] Menus: a long dropdown scrolls instead of running off the window (#269) The menu bar's own menus (Edit has 30 entries) had no scroll area, only their submenus did, so on a short screen Edit ran past the bottom of the window. The font menu was capped at 420 points and never moved up, the Preview panel's and the viewer's popup lists neither scrolled nor moved, and the Timeline's Blending Mode and Expression Language submenus didn't scroll. egui's scroll bars also only appeared while the pointer was over the list, so a list could only be scrolled with a wheel or touchpad. - widgets::popup_list: the one popup list behind popup_menu, the font menu, the Preview panel's dropdowns and the viewer's popups: as tall as the window allows, scrolling beyond, moved up to fit the window. - widgets::menu_scroll: the same for menus and submenus (the menu bar's, Blending Mode, the Expression Language menu, the viewer's Select menu). - Scroll bars always show (solid scroll style), so they can be dragged. - A first window that doesn't fit the screen is maximized: the 1680 x 1020 default on a smaller screen is only shrunk to the monitor's size, so with its title bar it ran under the taskbar and cut menus off at the bottom of the screen even when they fitted the window. Test: ui_scrolling a_menu_taller_than_the_window_scrolls (Edit in a 520-point window scrolls its last entry into view and it takes the click); overflows_screen unit test. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 34 +++++++++++ crates/ui-egui/src/menus.rs | 9 +-- crates/ui-egui/src/panels/misc.rs | 31 ++++------ crates/ui-egui/src/panels/text_panels.rs | 69 ++++++++++------------- crates/ui-egui/src/panels/timeline.rs | 24 ++++---- crates/ui-egui/src/panels/viewer.rs | 12 ++-- crates/ui-egui/src/panels/viewer_tools.rs | 41 ++++++-------- crates/ui-egui/src/theme.rs | 3 + crates/ui-egui/src/widgets.rs | 64 +++++++++++++-------- crates/ui-egui/tests/ui_scrolling.rs | 60 ++++++++++++++++++++ 10 files changed, 219 insertions(+), 128 deletions(-) create mode 100644 crates/ui-egui/tests/ui_scrolling.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eba6ba2e..53bf34ac 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1543,6 +1543,7 @@ impl eframe::App for EffectcraftApp { self.apply_gpu_failure(ctx); if !self.styled { theme::install(ctx, &self.tokens); + fit_window(ctx); self.styled = true; ctx.request_repaint(); } else { @@ -1598,6 +1599,39 @@ pub(crate) fn tick_playback(app: &mut EffectcraftApp, ctx: &egui::Context, scale app.advance_playback(ctx, scale); } +/// Maximize a first window that doesn't fit the screen, which fits it to the space beside the +/// taskbar or dock. The 1680 × 1020 window on a smaller screen is only shrunk to the monitor's +/// size, so with its title bar it ran under the taskbar and cut menus off at the bottom of the +/// screen (#269). +fn fit_window(ctx: &egui::Context) { + let (monitor, window) = ctx.input(|i| (i.viewport().monitor_size, i.viewport().outer_rect.or(i.viewport().inner_rect))); + if let (Some(monitor), Some(window)) = (monitor, window) + && overflows_screen(window.size(), monitor) + { + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true)); + } +} + +/// Whether a window `size` leaves no room on a `monitor` for a taskbar or dock (points). +fn overflows_screen(size: egui::Vec2, monitor: egui::Vec2) -> bool { + const TASKBAR: f32 = 64.0; + size.x > monitor.x || size.y > monitor.y - TASKBAR +} + +#[cfg(test)] +mod fit_window_tests { + use super::overflows_screen; + use egui::vec2; + + #[test] + fn a_window_taller_than_the_screen_beside_its_taskbar_overflows() { + // The default window on a 1366 × 768 laptop (shrunk to the monitor) and on 1080p. + assert!(overflows_screen(vec2(1366.0, 799.0), vec2(1366.0, 768.0))); + assert!(overflows_screen(vec2(1680.0, 1051.0), vec2(1920.0, 1080.0))); + assert!(!overflows_screen(vec2(1680.0, 1051.0), vec2(2560.0, 1440.0))); + } +} + #[cfg(test)] mod gpu_failure_tests { use super::*; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e181398e..1cef8b57 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1537,7 +1537,7 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { if let MenuNode::Submenu { label, children } = node { let r = ui.menu_button(crate::i18n::label(app, "", label), |ui| { ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 }); - menu_nodes(app, ui, children, &mut clicked); + crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, &mut clicked)); }); app.auto.add(&format!("menu.{label}"), r.response.rect, label); } @@ -1604,12 +1604,7 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c let shown = crate::i18n::submenu(app, label, effectcraft_engine::menus::submenu_label(&app.session, label, &dyn_ctx(&ws, &[]))); ui.menu_button((gutter(false), shown.as_str()), |ui| { ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 }); - // Long submenus (Blending Mode, effect categories) scroll instead of running - // off the screen. Others show whole: egui sizes a new submenu from a default - // 400 pt area, so without a minimum a longer one (Window ▸ Workspace) got - // stuck at that height with its last entries scrolled out of view (#191). - let max_h = ui.ctx().content_rect().height() - 40.0; - egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, |ui| menu_nodes(app, ui, children, clicked)); + crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, clicked)); }); } MenuNode::Item(e) => { diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index dfe0458b..ab2591a9 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -268,28 +268,21 @@ fn dropdown_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, key: &str, ui.data_mut(|d| d.insert_temp(pid.with("open"), !open)); } app.auto.add(&format!("preview.{key}"), r, text); - let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false)); - if !open { + if !widgets::popup_is_open(ui, pid) { return None; } - let mut chosen = None; - let area = egui::Area::new(pid.with("area")).order(egui::Order::Foreground).fixed_pos(r.left_bottom() + vec2(0.0, 2.0)).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(r.width().max(140.0)); - for (i, label) in items.iter().enumerate() { - let resp = ui.selectable_label(current == Some(i), label.as_str()); - app.auto.add(&format!("preview.{key}.{i}"), resp.rect, label); - if resp.clicked() { - chosen = Some(i); - } + widgets::popup_list(ui, pid, r.left_bottom() + vec2(0.0, 2.0), r, items.len(), 0, |ui| { + ui.set_min_width(r.width().max(140.0)); + let mut chosen = None; + for (i, label) in items.iter().enumerate() { + let resp = ui.selectable_label(current == Some(i), label.as_str()); + app.auto.add(&format!("preview.{key}.{i}"), resp.rect, label); + if resp.clicked() { + chosen = Some(i); } - }); - }); - let outside = widgets::pressed_outside(ui.ctx(), &area.response) && !r.contains(ui.input(|i| i.pointer.interact_pos()).unwrap_or_default()); - if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.data_mut(|d| d.insert_temp(pid.with("open"), false)); - } - chosen + } + chosen + }) } /// Audio panel: L/R VU meters (dBFS, 0 to -48) with peak hold and clip indicators, fed by the diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index de4f6c93..4f575fb8 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -544,51 +544,44 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { /// The font menu: recent fonts first, names in English or the fonts' own language, and a /// "Sample" preview in each font (Settings ▸ Type). fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, current: &str) -> Option { - if !ui.data(|d| d.get_temp::(id.with("open")).unwrap_or(false)) { + if !widgets::popup_is_open(ui, id) { return None; } let rows = effectcraft_engine::font_menu(&app.session.prefs); let preview = app.session.prefs.type_.font_preview; let t = app.tokens; - let mut chosen = None; - let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(if preview { 300.0 } else { 180.0 }); - egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { - for r in &rows { - if r.family.is_empty() { - ui.separator(); - continue; - } - let resp = ui.selectable_label(r.family == current, &r.display); - if preview && ui.is_rect_visible(resp.rect) { - let key = egui::Id::new(("font-preview", &r.family)); - let lines: std::sync::Arc>> = match ui.data(|d| d.get_temp(key)) { - Some(l) => l, - None => { - let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0)); - ui.data_mut(|d| d.insert_temp(key, l.clone())); - l - } - }; - let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0); - for poly in lines.iter() { - let pts: Vec = poly.iter().map(|p| o + vec2(p[0], p[1])).collect(); - ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); - } - } - if resp.clicked() { - chosen = Some(r.family.clone()); + let seps = rows.iter().filter(|r| r.family.is_empty()).count(); + widgets::popup_list(ui, id, pos, Rect::NOTHING, rows.len().saturating_sub(seps), seps, |ui| { + ui.set_min_width(if preview { 300.0 } else { 180.0 }); + let mut chosen = None; + for r in &rows { + if r.family.is_empty() { + ui.separator(); + continue; + } + let resp = ui.selectable_label(r.family == current, &r.display); + if preview && ui.is_rect_visible(resp.rect) { + let key = egui::Id::new(("font-preview", &r.family)); + let lines: std::sync::Arc>> = match ui.data(|d| d.get_temp(key)) { + Some(l) => l, + None => { + let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0)); + ui.data_mut(|d| d.insert_temp(key, l.clone())); + l } + }; + let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0); + for poly in lines.iter() { + let pts: Vec = poly.iter().map(|p| o + vec2(p[0], p[1])).collect(); + ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); } - }); - }); - }); - let outside = widgets::pressed_outside(ui.ctx(), &area.response); - if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.data_mut(|d| d.insert_temp(id.with("open"), false)); - } - chosen + } + if resp.clicked() { + chosen = Some(r.family.clone()); + } + } + chosen + }) } /// Align panel: Align Layers to Selection / Composition, the six align buttons and the six diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 2730391b..d3dcf62e 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1934,12 +1934,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::Popup::menu(&lang).show(|ui| { for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() { ui.menu_button(cat, |ui| { - for (label, text) in items { - if ui.button(label).clicked() { - picked = Some(text); - ui.close(); + widgets::menu_scroll(ui, |ui| { + for (label, text) in items { + if ui.button(label).clicked() { + picked = Some(text); + ui.close(); + } } - } + }) }); } }); @@ -2958,12 +2960,14 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], actions: &mut Vec<(S item(ui, "Freeze on Last Frame", "layer.freezeOnLastFrame", json!({"layers": layers})); }); ui.menu_button("Blending Mode", |ui| { - for m in BlendMode::ALL { - item(ui, m.label(), "layer.setBlendMode", json!({"layers": layers, "mode": m.label()})); - if m.ends_group() { - ui.separator(); + widgets::menu_scroll(ui, |ui| { + for m in BlendMode::ALL { + item(ui, m.label(), "layer.setBlendMode", json!({"layers": layers, "mode": m.label()})); + if m.ends_group() { + ui.separator(); + } } - } + }) }); ui.menu_button("Arrange", |ui| { item(ui, "Bring Layer to Front", "layer.arrange", json!({"layers": layers, "to": "front"})); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index b35c8f5d..4d75b8b1 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -962,12 +962,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let hits = ui.data(|d| d.get_temp::>(menu_hits_id)).unwrap_or_default(); if !hits.is_empty() { ui.menu_button("Select", |ui| { - for (id, name) in &hits { - if ui.button(name).clicked() { - context_actions.push(("layer.select".to_owned(), serde_json::json!({"layers":[id]}))); - ui.close(); + crate::widgets::menu_scroll(ui, |ui| { + for (id, name) in &hits { + if ui.button(name).clicked() { + context_actions.push(("layer.select".to_owned(), serde_json::json!({"layers":[id]}))); + ui.close(); + } } - } + }) }); ui.separator(); } diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index d1ffc4db..63f23356 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -455,34 +455,27 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: /// `viewer..` automation ids. fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, items: &[(String, bool)], auto: &str) -> Option { let pid = egui::Id::new(id); - let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false)); - if !open { + if !widgets::popup_is_open(ui, pid) { return None; } let h: f32 = items.iter().map(|(l, _)| if l == "-" { 9.0 } else { 21.0 }).sum::() + 14.0; - let pos = anchor.left_top() - vec2(0.0, h + 4.0); - let mut chosen = None; - let area = egui::Area::new(pid.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(170.0); - for (i, (label, on)) in items.iter().enumerate() { - if label == "-" { - ui.separator(); - continue; - } - let r = ui.selectable_label(*on, label.as_str()); - app.auto.add(&format!("viewer.{auto}.{i}"), r.rect, label); - if r.clicked() { - chosen = Some(i); - } + let seps = items.iter().filter(|(l, _)| l == "-").count(); + widgets::popup_list(ui, pid, anchor.left_top() - vec2(0.0, h + 4.0), anchor, items.len().saturating_sub(seps), seps, |ui| { + ui.set_min_width(170.0); + let mut chosen = None; + for (i, (label, on)) in items.iter().enumerate() { + if label == "-" { + ui.separator(); + continue; } - }); - }); - let outside = widgets::pressed_outside(ui.ctx(), &area.response) && !anchor.contains(ui.input(|i| i.pointer.interact_pos()).unwrap_or_default()); - if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.data_mut(|d| d.insert_temp(pid.with("open"), false)); - } - chosen + let r = ui.selectable_label(*on, label.as_str()); + app.auto.add(&format!("viewer.{auto}.{i}"), r.rect, label); + if r.clicked() { + chosen = Some(i); + } + } + chosen + }) } fn toggle_popup(ui: &egui::Ui, id: &str) { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0fcdb7eb..1a9e1a1a 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -345,6 +345,9 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { s.spacing.button_padding = egui::vec2(8.0, 3.0); s.spacing.interact_size.y = 22.0; s.spacing.menu_margin = egui::Margin::same(5); + // Scroll bars always show (egui's float in only over the list), so long menus and lists + // can be dragged without a mouse wheel or touchpad (#269). + s.spacing.scroll = egui::style::ScrollStyle::solid(); s.text_styles.insert(TextStyle::Body, FontId::new(12.0, FontFamily::Proportional)); s.text_styles.insert(TextStyle::Button, FontId::new(12.0, FontFamily::Proportional)); s.text_styles.insert(TextStyle::Small, FontId::new(11.0, FontFamily::Proportional)); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 30b7c60f..523c1ab5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -293,46 +293,60 @@ pub fn check_label(on: bool, label: &str) -> String { /// Show a popup menu anchored at `pos` with string options; returns the chosen index. pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String], current: Option) -> Option { - let mut chosen = None; - let open_id = id.with("open"); - let open: bool = ui.data(|d| d.get_temp(open_id).unwrap_or(false)); - if !open { + if !popup_is_open(ui, id) { return None; } - // As tall as the window allows (a shape layer's Add menu has 20 entries), scrolling beyond, - // and moved up when it would run past the bottom of the window (the Timeline's menus). + let seps = options.iter().filter(|o| *o == "-").count(); + popup_list(ui, id, pos, Rect::NOTHING, options.len().saturating_sub(seps), seps, |ui| { + ui.set_min_width(160.0); + let mut chosen = None; + for (i, o) in options.iter().enumerate() { + if o == "-" { + ui.separator(); + } else if ui.selectable_label(current == Some(i), o).clicked() { + chosen = Some(i); + } + } + chosen + }) +} + +/// The open popup list `id` at `pos`, drawn by `body` (`rows` entries and `seps` separators, +/// for its height): as tall as the window allows, scrolling beyond with a scroll bar (a shape +/// layer's Add menu has 20 entries, the blend modes 40, the fonts hundreds), and moved up when +/// it would run past the bottom of the window (the Timeline's menus, #269). Closes when `body` +/// returns a choice, on Escape, or on a press outside it and outside `anchor` (a control that +/// toggles the list itself). Returns the choice. +pub fn popup_list(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, rows: usize, seps: usize, body: impl FnOnce(&mut Ui) -> Option) -> Option { let screen = ui.ctx().content_rect(); let max_h = (screen.height() - 24.0).max(120.0); let sp = ui.spacing(); - let seps = options.iter().filter(|o| *o == "-").count() as f32; - let rows = options.len() as f32 - seps; - let est = (rows * (sp.interact_size.y + sp.item_spacing.y + 4.0) + seps * (2.0 * sp.item_spacing.y + 2.0) + 16.0).min(max_h + 12.0); + let est = (rows as f32 * (sp.interact_size.y + sp.item_spacing.y + 4.0) + seps as f32 * (2.0 * sp.item_spacing.y + 2.0) + 16.0).min(max_h + 12.0); let pos = egui::pos2(pos.x, pos.y.min(screen.bottom() - est).max(screen.top())); + let mut chosen = None; let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(160.0); // (An area's content is laid out in last frame's size: ask for the estimate.) - egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| { - for (i, o) in options.iter().enumerate() { - if o == "-" { - ui.separator(); - continue; - } - let sel = current == Some(i); - if ui.selectable_label(sel, o).clicked() { - chosen = Some(i); - } - } - }); + egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| chosen = body(ui)); }); }); - let clicked_outside = pressed_outside(ui.ctx(), &area.response); - if chosen.is_some() || clicked_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.data_mut(|d| d.insert_temp(open_id, false)); + let outside = pressed_outside(ui.ctx(), &area.response) && !ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| anchor.contains(p)); + if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.insert_temp(id.with("open"), false)); } chosen } +/// A menu's (or submenu's) entries, scrolling with a scroll bar when they are taller than the +/// window instead of running off it (Edit on a short screen, #269; Blending Mode). Others show +/// whole: egui sizes a new menu from a default 400 pt area, so without a minimum a longer one +/// (Window ▸ Workspace) got stuck at that height with its last entries scrolled out of view +/// (#191). +pub fn menu_scroll(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { + let max_h = ui.ctx().content_rect().height() - 40.0; + egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, add).inner +} + /// Whether a [`popup_menu`] is open (build its options only then: they can be long). pub fn popup_is_open(ui: &Ui, id: egui::Id) -> bool { ui.data(|d| d.get_temp(id.with("open")).unwrap_or(false)) diff --git a/crates/ui-egui/tests/ui_scrolling.rs b/crates/ui-egui/tests/ui_scrolling.rs new file mode 100644 index 00000000..2b4cf331 --- /dev/null +++ b/crates/ui-egui/tests/ui_scrolling.rs @@ -0,0 +1,60 @@ +//! Long menus and lists scroll instead of running off the window, with a scroll bar to drag +//! (egui_kittest, UI logic only). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +fn harness(w: f32, h: f32) -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let mut h = Harness::builder().with_size(vec2(w, h)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +fn wheel(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, dy: f32) { + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, dy), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + }); + h.run_steps(20); +} + +/// Edit is taller than a short window: it scrolls (with the wheel, and a scroll bar that always +/// shows) so its last entries can be reached and clicked, instead of running off the bottom of +/// the window (#269). +#[test] +fn a_menu_taller_than_the_window_scrolls() { + let mut h = harness(1200.0, 520.0); + assert!(!h.ctx.global_style().spacing.scroll.floating, "scroll bars show without hovering the list"); + let edit = rect(&h, "menu.Edit"); + click_at(&mut h, edit.center()); + let last = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu is open").rect(); + assert!(last.max.y > 520.0, "Edit is taller than the window ({last:?})"); + let first = h.query_by_label_contains("Quick Apply").expect("Quick Apply").rect(); + wheel(&mut h, first.center(), -2000.0); + let last = h.query_by_label_contains("Keyboard Shortcuts").expect("still open").rect(); + assert!(last.min.y >= 0.0 && last.max.y <= 520.0, "scrolled into the window ({last:?})"); + click_at(&mut h, last.center()); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "the entry takes the click"); +} From 11f37360254c174a68b993eb3aeec907642917c4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 18:30:10 +0700 Subject: [PATCH 2/3] Panels: every scrolling panel has a scroll bar to drag (#271) Most panels lay out their own rows and scrolled only with the mouse wheel: Effect Controls, Effects & Presets, the Media Browser, Ease Presets and the Timeline's layer rows had no scroll bar, Properties and Preview a thin indicator that couldn't be dragged, and Render Queue, History, Markers, Character, Paragraph and Info didn't scroll at all, so a panel shorter than its content cut it off. Without a wheel or a working touchpad there was no way down. The Project panel had the only real scroll bars, hand-rolled, and its vertical one was hit-tested under the rows, so it only dragged where no row was. - widgets::scroll_bar: the Project panel's bars generalised (horizontal or vertical, drag the thumb or press the track to move it there, automation id with "scroll/max"); the Project panel's two bars and the Timeline's column bar use it, drawn over the rows. - widgets::PanelScroll: the wheel offset, its clamping and the bar for a panel's own rows, used by Effect Controls, Properties, Effects & Presets, Media Browser, Ease Presets, Preview, Render Queue, History (which still follows the current state), Markers, Character, Paragraph and Info. The Timeline's layer rows get a bar on the right edge. - Effect Controls: an animated vector's values (a puppet pin's Position) ran under the keyframe navigator in a narrow panel. They now end before it, moving left over the name, which is cut short. Test: ui_scrolling panels_taller_than_their_space_have_a_scroll_bar_to_drag (eleven panels in a short window: a press on the track and a thumb drag scroll them), effect_values_stay_clear_of_the_keyframe_navigator. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/ease_presets.rs | 12 +-- crates/ui-egui/src/panels/effect_controls.rs | 44 +++++++--- crates/ui-egui/src/panels/effects_presets.rs | 15 ++-- crates/ui-egui/src/panels/info.rs | 9 ++- crates/ui-egui/src/panels/media_panels.rs | 14 +--- crates/ui-egui/src/panels/misc.rs | 52 ++++++------ crates/ui-egui/src/panels/project.rs | 38 +++------ crates/ui-egui/src/panels/properties.rs | 20 +---- crates/ui-egui/src/panels/render_queue.rs | 9 ++- crates/ui-egui/src/panels/text_panels.rs | 13 ++- crates/ui-egui/src/panels/timeline.rs | 18 ++--- crates/ui-egui/src/widgets.rs | 74 +++++++++++++++++ crates/ui-egui/tests/ui_scrolling.rs | 85 ++++++++++++++++++++ 13 files changed, 275 insertions(+), 128 deletions(-) diff --git a/crates/ui-egui/src/panels/ease_presets.rs b/crates/ui-egui/src/panels/ease_presets.rs index e08bcc3b..cb069238 100644 --- a/crates/ui-egui/src/panels/ease_presets.rs +++ b/crates/ui-egui/src/panels/ease_presets.rs @@ -198,16 +198,12 @@ fn graph(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: Rect, curve: &mut EaseC fn grid(app: &mut EffectcraftApp, ui: &mut egui::Ui, list: Rect, selected: Option<&str>) -> Option<(String, EaseCurve)> { let t = app.tokens; let p = ui.painter().with_clip_rect(list); - let scroll_id = egui::Id::new("easePresets.scroll"); - let mut scroll: f32 = ui.ctx().data(|d| d.get_temp(scroll_id).unwrap_or(0.0)); - if ui.rect_contains_pointer(list) { - scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0); - } + let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("ease-presets-scroll"), list); let cols = (((list.width() + GAP) / (TILE.x + GAP)).floor() as usize).max(1); let (mut clicked, mut count) = (None, 0); for (n, (name, curve, built_in)) in ease_presets::presets(&app.session).enumerate() { count = n + 1; - let r = Rect::from_min_size(list.min + vec2((n % cols) as f32 * (TILE.x + GAP), (n / cols) as f32 * (TILE.y + GAP) - scroll), TILE); + let r = Rect::from_min_size(list.min + vec2((n % cols) as f32 * (TILE.x + GAP), (n / cols) as f32 * (TILE.y + GAP) - scroll.offset), TILE); if !r.intersects(list) { continue; } @@ -242,8 +238,6 @@ fn grid(app: &mut EffectcraftApp, ui: &mut egui::Ui, list: Rect, selected: Optio clicked = Some((name.to_string(), curve)); } } - let content = count.div_ceil(cols) as f32 * (TILE.y + GAP); - scroll = scroll.min((content - list.height()).max(0.0)); - ui.ctx().data_mut(|d| d.insert_temp(scroll_id, scroll)); + scroll.end(ui, &mut app.auto, "easePresets.scroll", count.div_ceil(cols) as f32 * (TILE.y + GAP), &t); clicked } diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index bbd6ce47..b9711579 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -182,10 +182,27 @@ fn prop_row( if prop.is_animated() { key_navigator(app, ui, p, layer, prop, ectx, r, actions); } - let name_clip = Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2((r.min.x + r.width() * 0.48).max(swr.max.x + 124.0) - 4.0, r.max.y)); - p.with_clip_rect(name_clip.intersect(p.clip_rect())).text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text); - let vx = (r.min.x + r.width() * 0.48).max(swr.max.x + 130.0); let value = ectx.value(layer, prop); + let vx = (r.min.x + r.width() * 0.48).max(swr.max.x + 130.0); + // A vector's values end before the keyframe navigator: in a narrow panel they move left over + // the name (cut short) instead of running under the navigator (#271). + let vx = match &value { + Value::Vec2(_) | Value::Vec3(_) => { + let point = if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) { 22.0 } else { 0.0 }; + let fields: f32 = value + .components() + .iter() + .take(shown_dims(layer, prop, &value)) + .map(|v| p.layout_no_wrap(format!("{v:.1}"), Tokens::ui(12.0), t.hot_text).size().x + 12.0) + .sum(); + let right = r.max.x - if prop.is_animated() { 52.0 } else { 8.0 }; + vx.min(right - point - fields + 8.0).max(swr.max.x + 60.0) + } + _ => vx, + }; + let name_clip = + Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2(((r.min.x + r.width() * 0.48).max(swr.max.x + 124.0) - 4.0).min(vx - 6.0), r.max.y)); + p.with_clip_rect(name_clip.intersect(p.clip_rect())).text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text); let merge = format!("ec-{uid}"); let set = |actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge}))); @@ -264,7 +281,7 @@ fn prop_row( } Value::Vec2(_) | Value::Vec3(_) => { let c = value.components(); - let n = if prop.shown_dims > 0 && !(layer.is_3d() && c.len() == 3) { prop.shown_dims as usize } else { c.len() }; + let n = shown_dims(layer, prop, &value); let mut x = vx; if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) { let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0)); @@ -380,6 +397,12 @@ fn prop_row( } } +/// How many of a vector parameter's values show (a 3D point's Z only on a 3D layer). +fn shown_dims(layer: &Layer, prop: &Property, value: &Value) -> usize { + let n = value.components().len(); + if prop.shown_dims > 0 && !(layer.is_3d() && n == 3) { prop.shown_dims as usize } else { n } +} + /// Colour of a gradient at `f` (colour stops only). fn gradient_at(g: &effectcraft_engine::keyframe::Gradient, f: f64) -> Color32 { let stops = &g.colors; @@ -906,12 +929,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim); 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_id = egui::Id::new("ec-scroll"); - let mut scroll: f32 = ctx.data(|d| d.get_temp(scroll_id).unwrap_or(0.0)); - if ui.rect_contains_pointer(body) { - scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0); - } - let mut y = body.min.y + 4.0 - scroll; + let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("ec-scroll"), body); + 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. @@ -1074,9 +1093,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.extend(crate::menus::menu_contents(app, ui, "Effect")); }); ui.set_clip_rect(panel_clip); - let content_h = y + scroll - body.min.y; - scroll = scroll.min((content_h - body.height()).max(0.0)); - ctx.data_mut(|d| d.insert_temp(scroll_id, scroll)); + let content_h = y + scroll.offset - body.min.y; + scroll.end(ui, &mut app.auto, "effectControls.scroll", content_h, &t); // Drop effects here. if let Some(payload) = egui::DragAndDrop::payload::(&ctx) && ui.rect_contains_pointer(rect) diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 77df1d08..c5d48095 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -313,14 +313,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 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 scroll_id = egui::Id::new("fx-scroll"); - let mut scroll: f32 = ctx.data(|d| d.get_temp(scroll_id).unwrap_or(0.0)); - if ui.rect_contains_pointer(list) { - scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0); - } + let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("fx-scroll"), list); let query = q.trim().to_lowercase(); let searching = !query.is_empty(); - let mut y = list.min.y - scroll; + let mut y = list.min.y - scroll.offset; let mut apply: Option = None; let reg = effectcraft_engine::effects::all(); let keep = @@ -397,12 +393,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - if y == list.min.y - scroll && searching { + if y == list.min.y - scroll.offset && searching { lp.text(pos2(list.center().x, list.min.y + 24.0), Align2::CENTER_CENTER, "No matching effects or presets", Tokens::ui(12.0), t.text_faint); } - let content = y + scroll - list.min.y; - scroll = scroll.min((content - list.height()).max(0.0)); - ctx.data_mut(|d| d.insert_temp(scroll_id, scroll)); + let content = y + scroll.offset - list.min.y; + scroll.end(ui, &mut app.auto, "effects.scroll", content, &t); if let Some(DragPayload::Effect(id)) = egui::DragAndDrop::payload::(&ctx).as_deref() && let Some(pos) = ctx.pointer_hover_pos() { diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index 4fad6666..37d6a8a7 100644 --- a/crates/ui-egui/src/panels/info.rs +++ b/crates/ui-egui/src/panels/info.rs @@ -2,14 +2,15 @@ use egui::{Align2, Color32, Rect, pos2, vec2}; -use crate::EffectcraftApp; use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().with_clip_rect(rect); let x0 = rect.min.x + 12.0; - let mut y = rect.min.y + 14.0; + let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("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. app.viewer_pixels(); @@ -60,6 +61,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { y += 82.0; p.line_segment([pos2(rect.min.x + 8.0, y), pos2(rect.max.x - 8.0, y)], egui::Stroke::new(1.0, t.separator)); y += 14.0; + let mut bottom = y; let comp = app.session.active_comp_arc(); if let Some(c) = comp { let sel: Vec = app.session.state.selected_layers.iter().filter_map(|id| c.layer(*id)).map(|l| l.name.clone()).collect(); @@ -121,5 +123,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let line = one.unwrap_or_else(|| format!("{} keyframes selected", sel.len())); p.text(pos2(x0, ly + 18.0), Align2::LEFT_CENTER, line, Tokens::ui(11.5), t.text_dim); } + bottom = ly + 30.0; } + let content = bottom - (rect.min.y - scroll.offset); + scroll.end(ui, &mut app.auto, "info.scroll", content, &t); } diff --git a/crates/ui-egui/src/panels/media_panels.rs b/crates/ui-egui/src/panels/media_panels.rs index 8cc14dcc..f961b9de 100644 --- a/crates/ui-egui/src/panels/media_panels.rs +++ b/crates/ui-egui/src/panels/media_panels.rs @@ -17,8 +17,8 @@ use serde_json::{Value, json}; use super::DragPayload; use super::panel_kit as kit; -use crate::EffectcraftApp; use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; // ---------------------------------------------------------------- Media Browser @@ -159,19 +159,12 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 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_id = egui::Id::new("mb-scroll"); - let mut scroll: f32 = ctx.data(|d| d.get_temp(scroll_id).unwrap_or(0.0)); - if ui.rect_contains_pointer(list) { - scroll -= ctx.input(|i| i.smooth_scroll_delta.y); - } - let rows = entries.len().div_ceil(cols); - scroll = scroll.clamp(0.0, (rows as f32 * tile.y - list.height() + 8.0).max(0.0)); - ctx.data_mut(|d| d.insert_temp(scroll_id, scroll)); + let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("mb-scroll"), list); let lp = p.with_clip_rect(list); let mut actions: Vec<(&str, Value)> = vec![]; for (i, e) in entries.iter().enumerate() { let r = Rect::from_min_size( - pos2(list.min.x + 8.0 + (i % cols) as f32 * tile.x, list.min.y + 8.0 + (i / cols) as f32 * tile.y - scroll), + pos2(list.min.x + 8.0 + (i % cols) as f32 * tile.x, list.min.y + 8.0 + (i / cols) as f32 * tile.y - scroll.offset), tile - vec2(8.0, 8.0), ); if !r.intersects(list) { @@ -232,6 +225,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if entries.is_empty() { lp.text(list.center(), Align2::CENTER_CENTER, "Empty folder", Tokens::ui(12.0), t.text_faint); } + scroll.end(ui, &mut app.auto, "mediaBrowser.scroll", entries.len().div_ceil(cols) as f32 * tile.y + 8.0, &t); if budget == 0 { ctx.request_repaint(); } diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index ab2591a9..5f8ccfbb 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -23,14 +23,10 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 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_id = egui::Id::new("preview-scroll"); - let mut scroll: f32 = ctx.data(|d| d.get_temp(scroll_id).unwrap_or(0.0)); - if ui.rect_contains_pointer(rect) { - scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0); - } + let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("preview-scroll"), rect); let panel_clip = ui.clip_rect(); ui.set_clip_rect(rect.intersect(panel_clip)); - let y = rect.min.y + 12.0 - scroll; + let y = rect.min.y + 12.0 - scroll.offset; let bw = 30.0; let total = bw * 5.0 + 16.0; let mut x = rect.center().x - total / 2.0; @@ -231,17 +227,8 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 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); - let max = (content - rect.height()).max(0.0); - scroll = scroll.min(max); - ctx.data_mut(|d| d.insert_temp(scroll_id, scroll)); - if max > 0.0 { - // A thin scroll indicator on the right edge. - let h = (rect.height() * rect.height() / content).max(12.0); - let top = rect.min.y + (rect.height() - h) * (scroll / max); - p.rect_filled(Rect::from_min_size(pos2(rect.max.x - 5.0, top), vec2(3.0, h)), 1.5, t.text_faint.gamma_multiply(0.6)); - app.auto.add("preview.scroll", Rect::from_min_size(pos2(rect.max.x - 6.0, rect.min.y), vec2(6.0, rect.height())), "Preview scroll"); - } + let content = yy + 30.0 - (rect.min.y - scroll.offset); + scroll.end(ui, &mut app.auto, "preview.scroll", content, &t); } fn fmt_rate(r: f64) -> String { @@ -382,12 +369,24 @@ pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let nodes = app.session.history_tree(); let row_h = 22.0; let cur = nodes.iter().position(|n| n.current).unwrap_or(0); - // Keep the current state in view. - let visible = ((rect.height() - 12.0) / row_h).max(1.0) as usize; - let first = (cur + 2).saturating_sub(visible).min(nodes.len().saturating_sub(visible)); - let mut y = rect.min.y + 6.0; + let scroll_id = egui::Id::new("history-scroll"); + let mut scroll = widgets::PanelScroll::begin(ui, scroll_id, 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)); + let top = cur as f32 * row_h; + if top < scroll.offset { + scroll.offset = top; + } else if top + row_h + 12.0 > scroll.offset + rect.height() { + scroll.offset = top + row_h + 12.0 - rect.height(); + } + } + // Only the rows in view are drawn. + let first = ((scroll.offset / row_h) as usize).min(nodes.len()); + let mut y = rect.min.y + 6.0 - (scroll.offset - first as f32 * row_h); + let visible = (rect.height() / row_h) as usize + 2; let mut jump: Option = None; - for n in nodes.iter().skip(first).take(visible + 1) { + for n in nodes.iter().skip(first).take(visible) { let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), row_h)); y += row_h; let resp = ui.interact(r, egui::Id::new(("hist", n.id.as_str())), Sense::click()); @@ -410,6 +409,7 @@ pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { jump = Some(n.index); } } + scroll.end(ui, &mut app.auto, "history.scroll", nodes.len() as f32 * row_h + 12.0, &t); if let Some(i) = jump { let ctx = ui.ctx().clone(); if let Err(e) = crate::menus::invoke(app, &ctx, "edit.history.goto", serde_json::json!({"index": i})) { @@ -422,13 +422,17 @@ 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 mut y = rect.min.y + 8.0; + let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("markers-scroll"), rect); + let mut y = rect.min.y + 8.0 - scroll.offset; if c.markers.is_empty() { p.text(rect.center(), Align2::CENTER_CENTER, "No composition markers", Tokens::ui(12.0), t.text_faint); } for m in &c.markers { let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), 22.0)); y += 22.0; + if !r.intersects(rect) { + continue; + } let tc = crate::panels::timecode(&app.session, &c, m.time); p.text(pos2(r.min.x + 12.0, r.center().y), Align2::LEFT_CENTER, tc, Tokens::mono(11.5), t.timecode); p.text(pos2(r.min.x + 120.0, r.center().y), Align2::LEFT_CENTER, &m.comment, Tokens::ui(12.0), t.text); @@ -436,4 +440,6 @@ pub fn markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.session.set_time(m.time); } } + let content = y + 8.0 - (rect.min.y - scroll.offset); + scroll.end(ui, &mut app.auto, "markers.scroll", content, &t); } diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index d46b6508..014da7d0 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -175,12 +175,6 @@ fn fit(r: Rect, w: f32, h: f32) -> Rect { Rect::from_center_size(r.center(), vec2(w * s, h * s)) } -/// A scroll bar thumb's length on a `track` long: the share of `content` in view, at least 16 -/// points but never longer than the track (a panel only a few points tall still draws, #231). -fn thumb_len(track: f32, visible: f32, content: f32) -> f32 { - (track * visible / content).max(16.0).min(track).max(0.0) -} - /// An inline text edit in progress: (item, field `name`|`comment`, text). type Editing = (u64, String, String); fn edit_id() -> egui::Id { @@ -345,18 +339,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }; app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow); } - 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)); - let tw = thumb_len(track.width(), track.width(), track.width() + overflow); - let thumb = Rect::from_min_size(pos2(track.min.x + (track.width() - tw) * (hscroll / overflow), track.min.y), vec2(tw, track.height())); - let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("proj-hscroll"), Sense::drag()); - p.rect_filled(track, 2.0, t.field_bg); - p.rect_filled(thumb, 2.0, if sresp.hovered() || sresp.dragged() { t.text_dim } else { t.text_faint }); - app.auto.add("project.hscroll", track, &format!("{hscroll}/{overflow}")); - if sresp.dragged() { - app.ui.project_hscroll = (hscroll + sresp.drag_delta().x * overflow / (track.width() - tw).max(1.0)).clamp(0.0, overflow); - } - } let rows = visible_rows(app); // Enter renames the selected item (Project panel focused, not typing). if app.ui.focused == crate::dock::PanelKind::Project @@ -401,17 +383,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.ui.project_scroll = app.ui.project_scroll.clamp(0.0, max_scroll); let scroll = app.ui.project_scroll; - if max_scroll > 0.0 { - let track = Rect::from_min_max(pos2(rect.max.x - 6.0, list.min.y + 1.0), pos2(rect.max.x - 2.0, list.max.y - 7.0)); - let th = thumb_len(track.height(), list.height(), content_h); - let thumb = Rect::from_min_size(pos2(track.min.x, track.min.y + (track.height() - th) * (scroll / max_scroll)), vec2(track.width(), th)); - let vresp = ui.interact(track.expand2(vec2(2.0, 0.0)), egui::Id::new("proj-vscroll"), Sense::drag()); - p.rect_filled(thumb, 2.0, if vresp.hovered() || vresp.dragged() { t.text_dim } else { t.text_faint }); - app.auto.add("project.vscroll", track, &format!("{scroll}/{max_scroll}")); - if vresp.dragged() { - app.ui.project_scroll = (scroll + vresp.drag_delta().y * max_scroll / (track.height() - th).max(1.0)).clamp(0.0, max_scroll); - } - } 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(); @@ -635,6 +606,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } }); } + // 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)); + app.ui.project_hscroll = widgets::scroll_bar(ui, &mut app.auto, "project.hscroll", track, hscroll, overflow, &t); + } + if max_scroll > 0.0 { + let track = Rect::from_min_max(pos2(rect.max.x - 6.0, list.min.y + 1.0), pos2(rect.max.x - 2.0, list.max.y - 7.0)); + app.ui.project_scroll = widgets::scroll_bar(ui, &mut app.auto, "project.vscroll", track, scroll, max_scroll, &t); + } // Dropping a dragged item on the list: into the folder under the pointer (or the folder of // the item under it), or the project root below the rows. if let Some(iid) = dragging diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 9cc65461..990b0068 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -66,14 +66,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref(), footage: None }; let x0 = rect.min.x + PAD; let w = rect.width() - 2.0 * PAD; - // Vertical scrolling: content height is known after drawing, so clamp with last frame's. - let scroll_id = egui::Id::new("props-scroll"); - let (mut scroll, content_h): (f32, f32) = ui.data(|d| d.get_temp(scroll_id)).unwrap_or((0.0, 0.0)); - if ui.rect_contains_pointer(rect) { - scroll -= ui.input(|i| i.smooth_scroll_delta.y); - } - scroll = scroll.clamp(0.0, (content_h - rect.height()).max(0.0)); - let top = rect.min.y + 8.0 - scroll; + let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("props-scroll"), rect); + let top = rect.min.y + 8.0 - scroll.offset; let mut y = top; let mut actions: Actions = vec![]; @@ -153,15 +147,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } y += 40.0; - let content_h = y - top; - ui.data_mut(|d| d.insert_temp(scroll_id, (scroll, content_h))); - if content_h > rect.height() { - // Thin scroll indicator. - let frac = rect.height() / content_h; - let bar_h = (rect.height() * frac).max(24.0); - let by = rect.min.y + (rect.height() - bar_h) * (scroll / (content_h - rect.height()).max(1.0)); - p.rect_filled(Rect::from_min_size(pos2(rect.max.x - 5.0, by), vec2(3.0, bar_h)), 1.5, t.text_faint.gamma_multiply(0.6)); - } + scroll.end(ui, &mut app.auto, "properties.scroll", y - top, &t); for (id, params) in actions { let params = match &text_target { Some(tt) if id == "layer.setText" => tt.params(params), diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 0bd97ce0..c791d214 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -527,7 +527,11 @@ 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 mut y = list.min.y; + let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("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, "The render queue is empty.", Tokens::ui(12.0), t.text_faint); } @@ -746,6 +750,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } + 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 { Some(e) => { d.insert_temp(editing_id, e.clone()); diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 4f575fb8..06b62673 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -86,7 +86,9 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let target = text_target(app); let enabled = target.is_some(); let doc = target.as_ref().map(|t| t.doc.clone()).unwrap_or_default(); - let mut y = rect.min.y + 10.0; + let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("character-scroll"), rect); + let top = rect.min.y + 10.0 - scroll.offset; + let mut y = top; let x0 = rect.min.x + 10.0; let w = rect.width() - 20.0; let mut actions: Vec = vec![]; @@ -279,7 +281,10 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(json!({"variations": {tag.clone(): nv}, "merge": format!("char-axis-{tag}")})); } } + y += 20.0 + axes.len().div_ceil(2) as f32 * 24.0; } + // (Room for the status line at the bottom.) + scroll.end(ui, &mut app.auto, "character.scroll", y + 44.0 - top, &t); match &target { None => { p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint); @@ -441,7 +446,9 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let doc = target.as_ref().map(|t| t.doc.clone()).unwrap_or_default(); let x0 = rect.min.x + 10.0; let w = rect.width() - 20.0; - let mut y = rect.min.y + 10.0; + let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("paragraph-scroll"), rect); + let top = rect.min.y + 10.0 - scroll.offset; + let mut y = top; let mut actions: Vec = vec![]; let bw = ((w - 6.0 * 3.0) / 7.0).clamp(20.0, 28.0); for (i, (j, key)) in ALIGNS.into_iter().enumerate() { @@ -530,6 +537,8 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add("paragraph.hangingPunctuation", hr, "Roman Hanging Punctuation"); p.text(pos2(x0 + 20.0, y + 7.0), Align2::LEFT_CENTER, "Roman Hanging Punctuation", Tokens::ui(11.5), t.text_dim); + // (Room for the status line at the bottom.) + scroll.end(ui, &mut app.auto, "paragraph.scroll", y + 44.0 - top, &t); if !enabled { p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint); } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d3dcf62e..26ec3435 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2440,18 +2440,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if overflow > 0.0 { // Outline scroll bar along the top of the footer, under the columns. let track = Rect::from_min_max(pos2(rect.min.x + 2.0, footer.min.y + 1.0), pos2(graph_x0 - 3.0, footer.min.y + 5.0)); - let frac = left_w / natural_w; - let tw = (track.width() * frac).max(20.0); - let tx = track.min.x + (track.width() - tw) * (oscroll / overflow); - let thumb = Rect::from_min_size(pos2(tx, track.min.y), vec2(tw, track.height())); - p.rect_filled(track, 2.0, t.field_bg); - let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("tl-outline-scroll"), Sense::drag()); - p.rect_filled(thumb, 2.0, if sresp.dragged() || sresp.hovered() { t.text_dim } else { t.text_faint }); - app.auto.add("timeline.outlineScroll", track, &format!("{oscroll}/{overflow}")); - if sresp.dragged() { - let k = overflow / (track.width() - tw).max(1.0); - app.ui.timeline.outline_scroll = (oscroll + sresp.drag_delta().x * k).clamp(0.0, overflow); - } + app.ui.timeline.outline_scroll = widgets::scroll_bar(ui, &mut app.auto, "timeline.outlineScroll", track, oscroll, overflow, &t); + } + if max_scroll > 0.0 { + // The layer rows' scroll bar, on the right edge beside the time graph. + let track = Rect::from_min_max(pos2(rect.max.x - 6.0, rows_rect.min.y + 1.0), pos2(rect.max.x - 2.0, rows_rect.max.y - 1.0)); + app.ui.timeline.scroll_y = widgets::scroll_bar(ui, &mut app.auto, "timeline.scroll", track, app.ui.timeline.scroll_y, max_scroll, &t); } let tgl = Rect::from_min_size(pos2(footer.min.x + 10.0, footer.min.y + 3.0), vec2(150.0, 18.0)); let tresp = ui.interact(tgl, egui::Id::new("tl-toggle-modes"), Sense::click()); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 523c1ab5..54a3bfe3 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -2,6 +2,7 @@ use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use crate::automation::Registry; use crate::icons::{self, Icon}; use crate::theme::Tokens; @@ -277,6 +278,79 @@ pub fn checkbox(ui: &mut Ui, rect: Rect, on: bool, t: &Tokens, id: egui::Id) -> resp } +/// A scroll bar along `track` (vertical when it is taller than wide) for content scrolled +/// `scroll` of `max`, registered for automation as `key` ("scroll/max"): drag the thumb, or +/// press the track beside it to centre the thumb there. The thumb's length is the share in view +/// (the view is about as long as its track), at least 16 points but never longer than the track +/// (a panel only a few points tall still draws, #231). Returns the new scroll. +pub fn scroll_bar(ui: &mut Ui, auto: &mut Registry, key: &str, track: Rect, scroll: f32, max: f32, t: &Tokens) -> f32 { + if !track.is_positive() { + // (A panel too short for its bar.) + return scroll.clamp(0.0, max.max(0.0)); + } + let vertical = track.height() >= track.width(); + let a = usize::from(vertical); + let len = track.size()[a]; + let thumb_len = (len * len / (len + max.max(0.0)).max(1.0)).max(16.0).min(len).max(0.0); + let travel = (len - thumb_len).max(1.0); + let thumb_at = |s: f32| track.min[a] + (len - thumb_len) * if max > 0.0 { (s / max).clamp(0.0, 1.0) } else { 0.0 }; + let resp = ui.interact(track.expand2(if vertical { vec2(3.0, 0.0) } else { vec2(0.0, 3.0) }), egui::Id::new(key), Sense::drag()); + let pressed = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.any_pressed()); + let mut s = scroll; + if let Some(p) = resp.interact_pointer_pos().filter(|p| pressed && !(thumb_at(scroll)..=thumb_at(scroll) + thumb_len).contains(&p[a])) { + s = (p[a] - thumb_len / 2.0 - track.min[a]) * max / travel; + } else if resp.dragged() { + s += resp.drag_delta()[a] * max / travel; + } + let s = s.clamp(0.0, max.max(0.0)); + let thumb = if vertical { + Rect::from_min_size(pos2(track.min.x, thumb_at(s)), vec2(track.width(), thumb_len)) + } else { + Rect::from_min_size(pos2(thumb_at(s), track.min.y), vec2(thumb_len, track.height())) + }; + ui.painter().rect_filled(track, 2.0, t.field_bg); + ui.painter().rect_filled(thumb, 2.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_faint }); + auto.add(key, track, &format!("{s}/{max}")); + if s != scroll { + ui.ctx().request_repaint(); + } + s +} + +/// 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). +pub struct PanelScroll { + id: egui::Id, + area: Rect, + /// How far the rows are scrolled up. + pub offset: f32, +} + +impl PanelScroll { + pub fn begin(ui: &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(); + 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)) } + } + + /// 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) { + let max = (content - self.area.height()).max(0.0); + let mut offset = self.offset.min(max); + if max > 0.0 { + let track = Rect::from_min_max(pos2(self.area.max.x - 6.0, self.area.min.y + 1.0), pos2(self.area.max.x - 2.0, self.area.max.y - 1.0)); + offset = scroll_bar(ui, auto, key, track, offset, max, t); + } + ui.data_mut(|d| d.insert_temp(self.id, (offset, max))); + } +} + /// A pointer press this frame landed outside a popup `Area` (its `show` response). egui /// hit-tests a fixed-position area where it was asked to go, not where it was moved to fit the /// window, so `contains_pointer` / `hovered` are false over a menu shifted up from the bottom of diff --git a/crates/ui-egui/tests/ui_scrolling.rs b/crates/ui-egui/tests/ui_scrolling.rs index 2b4cf331..8368f083 100644 --- a/crates/ui-egui/tests/ui_scrolling.rs +++ b/crates/ui-egui/tests/ui_scrolling.rs @@ -58,3 +58,88 @@ fn a_menu_taller_than_the_window_scrolls() { click_at(&mut h, last.center()); assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "the entry takes the click"); } + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +/// A scroll bar's "scroll/max" automation label. +fn scrolled(h: &Harness<'_, EffectcraftApp>, id: &str) -> (f32, f32) { + let label = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).label.clone(); + let (s, max) = label.split_once('/').unwrap_or_else(|| panic!("{id}: {label}")); + (s.parse().unwrap(), max.parse().unwrap()) +} + +/// Every panel that scrolls its own rows shows a scroll bar while they overflow, and dragging it +/// scrolls them: without a mouse wheel or touchpad there was no way down most panels (#271). +#[test] +fn panels_taller_than_their_space_have_a_scroll_bar_to_drag() { + use effectcraft_ui_egui::dock::PanelKind; + // (The Project panel's list starts below its item preview and search: a taller window.) + for (panel, id, height) in [ + (PanelKind::EffectControls, "effectControls.scroll", 240.0), + (PanelKind::Properties, "properties.scroll", 240.0), + (PanelKind::EffectsPresets, "effects.scroll", 240.0), + (PanelKind::Project, "project.vscroll", 330.0), + (PanelKind::Timeline, "timeline.scroll", 240.0), + (PanelKind::RenderQueue, "renderQueue.scroll", 240.0), + (PanelKind::History, "history.scroll", 240.0), + (PanelKind::Preview, "preview.scroll", 240.0), + (PanelKind::Character, "character.scroll", 240.0), + (PanelKind::Paragraph, "paragraph.scroll", 240.0), + (PanelKind::Info, "info.scroll", 240.0), + ] { + let mut h = harness(1200.0, height); + let app = h.state_mut(); + let lid = app.session.active_comp().unwrap().layers[0].id.0; + app.session.execute("layer.select", json!({"layers": [lid]})).unwrap(); + for e in ["slider", "angle", "checkbox", "color", "point"] { + app.session.execute("effect.apply", json!({"effect": format!("ec.control.{e}"), "layers": [lid]})).unwrap(); + } + for _ in 0..6 { + app.session.execute("renderQueue.add", json!({})).unwrap(); + } + app.show_panel(panel); + app.ui.maximized = Some(panel); + h.run_steps(3); + let (_, max) = scrolled(&h, id); + assert!(max > 0.0, "{id}: the rows overflow"); + let bar = rect(&h, id); + // A press on the track moves the thumb there (History starts at its current state, the + // last); then the thumb drags to the end. + click_at(&mut h, pos2(bar.center().x, bar.min.y + 2.0)); + assert_eq!(scrolled(&h, id).0, 0.0, "{id}: at the top"); + drag(&mut h, pos2(bar.center().x, bar.min.y + 6.0), pos2(bar.center().x, bar.max.y + 40.0)); + assert_eq!(scrolled(&h, id).0, max, "{id}: dragged to the end"); + } +} + +/// In a narrow Effect Controls panel an animated point's values stay clear of its keyframe +/// navigator (â—€ â—† â–¶), which was drawn over them (#271). +#[test] +fn effect_values_stay_clear_of_the_keyframe_navigator() { + use effectcraft_ui_egui::dock::PanelKind; + let mut h = harness(1600.0, 1000.0); + let app = h.state_mut(); + let lid = app.session.active_comp().unwrap().layers[0].id.0; + app.session.execute("layer.select", json!({"layers": [lid]})).unwrap(); + app.session.execute("effect.apply", json!({"effect": "ec.control.point", "layers": [lid]})).unwrap(); + let path = "effects/#1/point"; + app.session.execute("prop.toggleAnimation", json!({"layer": lid, "path": path})).unwrap(); + app.session.execute("prop.set", json!({"layer": lid, "path": path, "value": [123456.7, 123456.7]})).unwrap(); + let uid = app.session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(lid)).unwrap().props.prop(path).unwrap().uid; + assert!(app.edit_layout(|l| l.float(PanelKind::EffectControls, [300.0, 150.0, 330.0, 500.0]))); + h.run_steps(3); + let value = rect(&h, &format!("effectControls.prop.{uid}.value.1")); + let nav = rect(&h, &format!("effectControls.prop.{uid}.prevKey")); + assert!(value.max.x <= nav.min.x, "the value ({value:?}) ends before the navigator ({nav:?})"); + assert!(value.min.x > rect(&h, &format!("effectControls.prop.{uid}.stopwatch")).max.x, "and after the stopwatch"); +} From 02c75fcaf1165ac0f48d68c3cfbb7e0025b5f9f8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 18:30:11 +0700 Subject: [PATCH 3/3] Workspaces: leaving Learn closes its tutorials (#272) The Learn workspace opens the Home screen on its Learn tab, but no other workspace closed it, so after Learn the tutorials stayed over Default, Review and the rest until a project was created or Home was toggled. It wasn't about clicking quickly: any switch away from Learn kept them. Leaving Learn for another workspace now closes the Home screen it opened. Test: ui_panels leaving_the_learn_workspace_closes_its_tutorials (clicks through the workspace tabs: the Home screen and the tutorials show only on Learn). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 15 ++++++++++----- crates/ui-egui/tests/ui_panels.rs | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 53bf34ac..d35630b8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -383,16 +383,21 @@ impl EffectcraftApp { } pub fn set_workspace(&mut self, name: &str) { - self.ui.workspace = name.to_string(); - self.ui.dock = self.ui.saved_workspaces.get(name).cloned().unwrap_or_else(|| dock::workspace(name)); - self.ui.floating = self.ui.saved_floating.get(name).cloned().unwrap_or_else(|| dock::workspace_floating(name)); - self.ui.maximized = None; - // Learn: the Home screen's Learn tab (tutorials) in the Composition panel. + // Learn: the Home screen's Learn tab (tutorials) in the Composition panel. Leaving it for + // another workspace closes the Home screen again: it stayed over every workspace until a + // project was created (#272). if name == "Learn" { self.ui.start_screen = true; self.ui.home_learn = true; self.ui.home_templates = false; + } else if self.ui.workspace == "Learn" { + self.ui.start_screen = false; + self.ui.home_learn = false; } + self.ui.workspace = name.to_string(); + self.ui.dock = self.ui.saved_workspaces.get(name).cloned().unwrap_or_else(|| dock::workspace(name)); + self.ui.floating = self.ui.saved_floating.get(name).cloned().unwrap_or_else(|| dock::workspace_floating(name)); + self.ui.maximized = None; } /// Built-in workspaces followed by the saved ones (Window ▸ Workspace ▸ Save as New Workspace). diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index 471e47bf..6b8bd4a5 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -153,6 +153,20 @@ fn saved_workspace_is_listed_in_the_workspace_menu() { assert_eq!(h.state().ui.dock, saved, "its layout came back"); } +/// Learn opens the Home screen's tutorials; clicking another workspace tab closes them again, +/// however quickly the tabs are clicked (they stayed over every workspace, #272). +#[test] +fn leaving_the_learn_workspace_closes_its_tutorials() { + let mut h = harness(); + for name in ["Learn", "Default", "Learn", "Review", "Learn", "Learn", "Small Screen", "Standard", "Learn", "Default"] { + click(&mut h, &format!("header.workspace.{name}")); + assert_eq!(h.state().ui.workspace, name); + let learn = name == "Learn"; + assert_eq!(h.state().ui.start_screen, learn, "{name}: the Home screen shows only for Learn"); + assert_eq!(!h.state().auto.query("home.learn.").is_empty(), learn, "{name}: the tutorials show only for Learn"); + } +} + /// Headless look at the panels (wgpu offscreen; needs a GPU adapter). Run with /// `PANELS_SNAPSHOT=/abs/dir cargo test -p effectcraft-ui-egui --test ui_panels -- --ignored`. #[test]