From edb3b5985e4acfc2f6dab5889a9311136a767f6b Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:03:00 +0100 Subject: [PATCH] UI: Composition Settings keeps Cancel and OK in place between tabs The button row followed each tab's content, so it jumped when switching between Basic, Advanced and 3D Renderer. The tabs not on show are laid out unseen each frame and the dialog pads to the tallest, so Cancel and OK stay put with any font, language or scale. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/comp_settings.rs | 76 +++++++++++++++------- crates/ui-egui/tests/it/ui_comp_presets.rs | 20 ++++++ 2 files changed, 73 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index f06a069e8..0d07afd94 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -246,36 +246,18 @@ fn matches(d: &CompDraft, w: u32, h: u32, par: f64, fps: f64) -> bool { w == d.width && h == d.height && (par - d.pixel_aspect).abs() < 1e-3 && (fps - d.fps).abs() < 1e-3 } -/// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel. `custom` -/// are the presets the user saved; saving or deleting one pushes its engine command to `cmds`. -pub fn show( +/// The controls of one tab. +fn tab_body( ui: &mut egui::Ui, + tab: Tab, d: &mut CompDraft, t: &Tokens, auto: &mut Registry, custom: &[CompPreset], cmds: &mut Vec<(&'static str, serde_json::Value)>, -) -> Option { - let mut out = None; - ui.horizontal(|ui| { - ui.label(tr("Composition Name:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); - crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); - auto.add("dialog.comp.name", r.rect, "Composition Name"); - }); - ui.add_space(8.0); - ui.horizontal(|ui| { - for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] { - let r = ui.selectable_label(d.tab == tab, label); - auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label); - if r.clicked() { - d.tab = tab; - } - } - }); - ui.separator(); +) { let rate = FrameRate::from_f64(d.fps); - match d.tab { + match tab { Tab::Basic => { egui::Grid::new("comp-basic").num_columns(2).spacing([12.0, 9.0]).show(ui, |ui| { ui.label(tr("Preset:")); @@ -485,6 +467,54 @@ pub fn show( }); } } +} + +/// The height `tab` would take at `top`, from laying it out unseen: it follows the font, the +/// language and the scale. Nothing it registers or changes is kept. +fn tab_height(ui: &mut egui::Ui, tab: Tab, d: &CompDraft, t: &Tokens, custom: &[CompPreset], top: f32) -> f32 { + let rect = egui::Rect::from_min_size(egui::pos2(ui.max_rect().left(), top), vec2(ui.max_rect().width(), 0.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).id_salt("comp-tab-measure").invisible()); + tab_body(&mut child, tab, &mut d.clone(), t, &mut Registry::default(), custom, &mut Vec::new()); + child.cursor().top() - top +} + +/// Draw the dialog. Returns `Some(params)` when OK is pressed, `Some(Null)` on Cancel. `custom` +/// are the presets the user saved; saving or deleting one pushes its engine command to `cmds`. +pub fn show( + ui: &mut egui::Ui, + d: &mut CompDraft, + t: &Tokens, + auto: &mut Registry, + custom: &[CompPreset], + cmds: &mut Vec<(&'static str, serde_json::Value)>, +) -> Option { + let mut out = None; + ui.horizontal(|ui| { + ui.label(tr("Composition Name:")); + let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); + crate::widgets::focus_on_open(ui.ctx(), &r, &d.name); + auto.add("dialog.comp.name", r.rect, "Composition Name"); + }); + ui.add_space(8.0); + ui.horizontal(|ui| { + for (tab, label, key) in [(Tab::Basic, "Basic", "basic"), (Tab::Advanced, "Advanced", "advanced"), (Tab::Renderer, "3D Renderer", "renderer")] { + let r = ui.selectable_label(d.tab == tab, label); + auto.add(&format!("dialog.comp.tab.{key}"), r.rect, label); + if r.clicked() { + d.tab = tab; + } + } + }); + ui.separator(); + // Every tab takes the height of the tallest one, so Cancel and OK stay put between tabs. + let body_top = ui.cursor().top(); + tab_body(ui, d.tab, d, t, auto, custom, cmds); + let shown = ui.cursor().top() - body_top; + let tallest = [Tab::Basic, Tab::Advanced, Tab::Renderer] + .into_iter() + .filter(|tab| *tab != d.tab) + .fold(shown, |h, tab| h.max(tab_height(ui, tab, d, t, custom, body_top))); + ui.add_space((tallest - shown).max(0.0)); ui.add_space(14.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/crates/ui-egui/tests/it/ui_comp_presets.rs b/crates/ui-egui/tests/it/ui_comp_presets.rs index 0b0b30720..9e71f8fd7 100644 --- a/crates/ui-egui/tests/it/ui_comp_presets.rs +++ b/crates/ui-egui/tests/it/ui_comp_presets.rs @@ -83,3 +83,23 @@ fn a_preset_name_cannot_be_a_built_in_one() { assert!(h.state().auto.find("dialog.comp.presetName").is_none()); assert!(preset_names(&h).is_empty()); } + +/// Cancel and OK stay where they are when switching between Basic, Advanced and 3D Renderer, +/// at any interface scale: the tabs' heights are measured, not assumed. +#[test] +fn composition_settings_buttons_stay_put_between_tabs() { + for zoom in [1.0, 1.3] { + let mut h = harness(); + h.ctx.set_zoom_factor(zoom); + h.run_steps(3); + open_settings(&mut h); + let rects = |h: &Harness<'_, EffectcraftApp>| { + ["dialog.comp.ok", "dialog.comp.cancel", "dialog.comp.name"].map(|id| h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).rect) + }; + let basic = rects(&h); + for tab in ["advanced", "renderer", "basic"] { + click(&mut h, &format!("dialog.comp.tab.{tab}")); + assert_eq!(rects(&h), basic, "{tab} at {zoom}x"); + } + } +}