Graph Editor: buttons that don't fit the bar move to a » menu instead of being cut off (#457)

The Graph Editor's bottom bar lays out 17 text buttons in one row
(about 1100 px) on a painter clipped to the graph area, so on a
normal-width Timeline the right-hand ones (keyframe interpolation and
easing) were cut off and unreachable. Buttons that would pass the bar's
right edge now go, in order, to a » menu at its end; picking one there
runs the same action on the next frame. Hidden buttons register as
timeline.graph.more.<id> while the menu is open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
Claude
2026-10-10 19:16:48 +00:00
parent e152dfd1d1
commit 69bf806ff5
2 changed files with 155 additions and 21 deletions
+100 -21
View File
@@ -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<String>,
}
/// 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::<String>(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).
+55
View File
@@ -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");
}