diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 7f19d91b..2dbb9826 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -14,8 +14,8 @@ use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::json; use super::timeline::TMap; -use crate::EffectcraftApp; use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; type Actions = Vec<(String, serde_json::Value)>; @@ -162,10 +162,88 @@ fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, pl out } -fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x: &mut f32, y: f32, label: &str, on: bool, id: &str, tip: &str) -> bool { +/// The Graph Editor's button bar, laid out left to right. Buttons that would run past the right +/// edge (a Timeline narrower than the whole row, #457) go to the "»" menu at its end instead of +/// being clipped, so every one stays reachable. +struct ButtonBar { + x: f32, + /// Where the next button must end to stay on the bar (the "»" button's room is kept free). + limit: f32, + /// Buttons moved to the "»" menu: (automation id, label, on, tooltip). + hidden: Vec<(String, String, bool, String)>, + /// The id picked from the "»" menu last frame, run by its button this frame. + chosen: Option, +} + +/// The "»" menu's popup and the id it picked (read by the next frame's buttons). +const MORE_POPUP: &str = "graph-more"; + +impl ButtonBar { + const MORE_W: f32 = 24.0; + + fn new(ctx: &egui::Context, bar: Rect) -> Self { + let chosen = ctx.data_mut(|d| d.remove_temp::(egui::Id::new(MORE_POPUP).with("chosen"))); + ButtonBar { x: bar.min.x + 8.0, limit: bar.max.x - 8.0 - Self::MORE_W - 4.0, hidden: Vec::new(), chosen } + } + + /// The "»" button and its menu, when buttons did not fit. + fn more(&mut self, app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: Rect) { + if self.hidden.is_empty() { + return; + } + let t = app.tokens; + let r = Rect::from_min_size(pos2(bar.max.x - 8.0 - Self::MORE_W, bar.center().y - 9.0), vec2(Self::MORE_W, 18.0)); + let pid = egui::Id::new(MORE_POPUP); + let resp = ui.interact(r, pid.with("button"), Sense::click()).on_hover_text(crate::i18n::tr("More")); + p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { Color32::from_rgb(0x2c, 0x2c, 0x2c) }); + p.text(r.center(), Align2::CENTER_CENTER, "»", Tokens::ui(12.0), t.text); + app.auto.add("timeline.graph.more", r, "More"); + if resp.clicked() { + let open = widgets::popup_is_open(ui, pid); + ui.data_mut(|d| d.insert_temp(pid.with("open"), !open)); + } + if !widgets::popup_is_open(ui, pid) { + return; + } + let rows = self.hidden.len(); + let pos = r.left_top() - vec2(0.0, rows as f32 * 22.0 + 18.0); + let chosen = widgets::popup_list(ui, pid, pos, r, rows, 0, |ui| { + let mut chosen = None; + for (id, label, on, tip) in &self.hidden { + let item = ui.selectable_label(*on, label.as_str()).on_hover_text(tip.as_str()); + app.auto.add(&format!("timeline.graph.more.{id}"), item.rect, tip); + if item.clicked() { + chosen = Some(id.clone()); + } + } + chosen + }); + if let Some(id) = chosen { + ui.data_mut(|d| d.insert_temp(pid.with("chosen"), id)); + ui.ctx().request_repaint(); + } + } +} + +fn text_button( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + bar: &mut ButtonBar, + y: f32, + label: &str, + on: bool, + id: &str, + tip: &str, +) -> bool { let t = app.tokens; let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; - let r = Rect::from_min_size(pos2(*x, y - 9.0), vec2(w, 18.0)); + // Once one button overflows, the rest follow it into the menu (they keep their order). + if !bar.hidden.is_empty() || bar.x + w > bar.limit { + bar.hidden.push((id.to_string(), label.to_string(), on, tip.to_string())); + return bar.chosen.as_deref() == Some(id); + } + let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0)); let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(tip); p.rect_filled( r, @@ -180,7 +258,7 @@ fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x ); p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); app.auto.add(&format!("timeline.graph.{id}"), r, tip); - *x += w + 4.0; + bar.x += w + 4.0; resp.clicked() } @@ -446,14 +524,14 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte p.rect_filled(bar, 0.0, t.panel_bg); p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator)); let y = bar.center().y; - let mut x = bar.min.x + 8.0; + let mut buttons = ButtonBar::new(&ctx, bar); let tl = &app.ui.timeline; let (auto_type, show_sel, auto_on) = (tl.graph_mode == "auto", tl.graph_show_selected, tl.graph_auto_zoom); if text_button( app, ui, p, - &mut x, + &mut buttons, y, "Auto", auto_type, @@ -463,45 +541,45 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte app.ui.timeline.graph_mode = if auto_type { if speed { "speed" } else { "value" } } else { "auto" }.into(); } // Choosing a graph type turns Auto-Select off, as in After Effects. - if text_button(app, ui, p, &mut x, y, "Value", !speed, "valueGraph", "Edit Value Graph") { + if text_button(app, ui, p, &mut buttons, y, "Value", !speed, "valueGraph", "Edit Value Graph") { app.ui.timeline.graph_mode = "value".into(); } - if text_button(app, ui, p, &mut x, y, "Speed", speed, "speedGraph", "Edit Speed Graph") { + if text_button(app, ui, p, &mut buttons, y, "Speed", speed, "speedGraph", "Edit Speed Graph") { app.ui.timeline.graph_mode = "speed".into(); } - x += 6.0; - if text_button(app, ui, p, &mut x, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") { + buttons.x += 6.0; + if text_button(app, ui, p, &mut buttons, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") { app.ui.timeline.graph_show_selected = !show_sel; } - if text_button(app, ui, p, &mut x, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") { + if text_button(app, ui, p, &mut buttons, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") { app.ui.timeline.graph_auto_zoom = !auto_on; } - if text_button(app, ui, p, &mut x, y, "Fit", false, "fit", "Fit all graphs to view") { + if text_button(app, ui, p, &mut buttons, y, "Fit", false, "fit", "Fit all graphs to view") { app.ui.timeline.graph_auto_zoom = true; app.ui.timeline.pps = None; } - if text_button(app, ui, p, &mut x, y, "Fit Selection", false, "fitSelection", "Fit selection to view") { + if text_button(app, ui, p, &mut buttons, y, "Fit Selection", false, "fitSelection", "Fit selection to view") { fit_selection(app, &cs, tm, plot); } - x += 6.0; + buttons.x += 6.0; let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box); - if text_button(app, ui, p, &mut x, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") { + if text_button(app, ui, p, &mut buttons, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") { app.ui.timeline.graph_snap = !snap; } - if text_button(app, ui, p, &mut x, y, "Reference", reference, "reference", "Show Reference Graph") { + if text_button(app, ui, p, &mut buttons, y, "Reference", reference, "reference", "Show Reference Graph") { app.ui.timeline.graph_reference = !reference; } - if text_button(app, ui, p, &mut x, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") { + if text_button(app, ui, p, &mut buttons, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") { app.ui.timeline.graph_transform_box = !tbox; } - x += 6.0; + buttons.x += 6.0; let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| super::timeline::is_position(comp.layer(*l)?, *u).then_some(l.0)); - if text_button(app, ui, p, &mut x, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)") + if text_button(app, ui, p, &mut buttons, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)") && let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0)) { actions.push(("prop.separateDimensions".into(), json!({"layer": l}))); } - x += 6.0; + buttons.x += 6.0; for (label, cmd, params, tip) in [ ("Hold", "keys.interpolation", json!({"interpolation": "hold"}), "Convert selected keyframes to Hold"), ("Linear", "keys.interpolation", json!({"interpolation": "linear"}), "Convert selected keyframes to Linear"), @@ -511,10 +589,11 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte ("Ease Out", "keys.easyEaseOut", json!({}), "Easy Ease Out (Ctrl+Shift+F9)"), ] { let id = cmd.trim_start_matches("keys.").to_string() + label.split_whitespace().next().unwrap_or(""); - if text_button(app, ui, p, &mut x, y, label, false, &id, tip) { + if text_button(app, ui, p, &mut buttons, y, label, false, &id, tip) { actions.push((cmd.into(), params)); } } + buttons.more(app, ui, p, bar); } /// Zoom time and value to the selected keys (and their handles). diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 50ccf1ef..3ca85f8f 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -293,3 +293,58 @@ fn a_graph_handle_drag_moves_the_handles_of_every_selected_key() { assert_eq!(h.state().session.history.undo.len(), undo + 1, "one undo step"); assert!((handle(&h, 0).x - k0.x - px_per_s * 0.2).abs() < 2.0, "key 0's handle moved on screen"); } + +/// #457: on a Timeline narrower than the Graph Editor's whole button row, the buttons that don't +/// fit go to a "»" menu at the end of the bar instead of being cut off; each stays reachable and +/// works from there. +#[test] +fn graph_editor_buttons_that_do_not_fit_move_to_the_more_menu() { + let (mut h, _, _) = harness(); + h.set_size(egui::vec2(1280.0, 800.0)); + h.state_mut().ui.timeline.graph_editor = true; + h.run_steps(4); + let ids = [ + "autoSelectGraphType", + "valueGraph", + "speedGraph", + "showSelected", + "autoZoom", + "fit", + "fitSelection", + "snap", + "reference", + "transformBox", + "separateDimensions", + "interpolationHold", + "interpolationLinear", + "interpolationAuto", + "easyEaseEasy", + "easyEaseInEase", + "easyEaseOutEase", + ]; + let graph = h.state().auto.find("timeline.graph").expect("the Graph Editor is shown").rect; + let right = graph[0] + graph[2]; + let shown: Vec<&str> = ids.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.graph.{id}")).is_some()).collect(); + for id in &shown { + let r = h.state().auto.find(&format!("timeline.graph.{id}")).unwrap().rect; + assert!(r[0] + r[2] <= right, "{id} is drawn past the bar's right edge"); + } + let more = h.state().auto.find("timeline.graph.more").expect("the buttons that don't fit are in a menu").rect; + assert!(more[0] + more[2] <= right); + let p = pos2(more[0] + more[2] / 2.0, more[1] + more[3] / 2.0); + let click = |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(3); + }; + click(&mut h, p); + let hidden: Vec<&str> = ids.iter().copied().filter(|id| h.state().auto.find(&format!("timeline.graph.more.{id}")).is_some()).collect(); + assert_eq!(shown.len() + hidden.len(), ids.len(), "every button is on the bar or in the menu: {shown:?} + {hidden:?}"); + assert!(hidden.contains(&"transformBox"), "{hidden:?}"); + let before = h.state().ui.timeline.graph_transform_box; + let item = h.state().auto.find("timeline.graph.more.transformBox").unwrap().rect; + click(&mut h, pos2(item[0] + item[2] / 2.0, item[1] + item[3] / 2.0)); + assert_eq!(h.state().ui.timeline.graph_transform_box, !before, "the menu item runs the button"); +}