diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eba6ba2e..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). @@ -1543,6 +1548,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 +1604,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/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 dfe0458b..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 { @@ -268,28 +255,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 @@ -389,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()); @@ -417,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})) { @@ -429,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); @@ -443,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 de4f6c93..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); } @@ -544,51 +553,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..26ec3435 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(); + } } - } + }) }); } }); @@ -2438,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()); @@ -2958,12 +2954,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..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 @@ -293,46 +367,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_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] diff --git a/crates/ui-egui/tests/ui_scrolling.rs b/crates/ui-egui/tests/ui_scrolling.rs new file mode 100644 index 00000000..8368f083 --- /dev/null +++ b/crates/ui-egui/tests/ui_scrolling.rs @@ -0,0 +1,145 @@ +//! 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"); +} + +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"); +}