LR-EXP-DIALOG: export dialog no longer widens every frame

Rows with a trailing button (Folder / Choose…, watermark Graphic /
Choose…, Save preset / Save) sized their text field as the available
width minus a guessed button width. When the real button was wider
(e.g. Linux fonts, a folder picker present), the auto-sized dialog grew
a little every frame. These rows now lay out right to left: the button
first, then a field taking exactly the width that's left.

Test: export_dialog_keeps_its_width (grew 492 → 693 px in 60 frames
before; stable now).

Fixes #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 02:31:34 -04:00
parent f5a4bb609d
commit b8c2d16a06
2 changed files with 75 additions and 30 deletions

View File

@ -553,6 +553,41 @@ mod tests {
/// The Export dialog hands its batch to a worker thread: the UI keeps drawing frames, shows
/// progress, and reports the result when the files are written.
#[test]
fn export_dialog_keeps_its_width() {
// A dialog row sized as "available width − a guessed button width" made the auto-sized
// window grow a little every frame when the real button was wider (issue #8, Linux).
let mut h = demo([1400.0, 900.0]);
let t = Duration::from_secs(10);
h.app.services.pick_folder = Some(Box::new(|| None));
h.app.services.pick_files = Some(Box::new(Vec::new));
h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t);
// the widest variant: watermark graphic and every optional row
if let Some(crate::state::Dialog::Export { opts, .. }) = &mut h.app.ui.dialog {
let mut wm = lightcraft_engine::export::Watermark { image: "logo.png".into(), ..Default::default() };
wm.text.clear();
opts.watermark = Some(wm);
}
let width = |h: &mut Headless| {
let r = h.request("ui.widgets", json!({}), t);
r["result"]
.as_array()
.and_then(|a| a.iter().find(|w| w["id"] == "dialog:window"))
.map(|w| w["rect"][2].as_f64().unwrap())
.expect("dialog on screen")
};
for _ in 0..5 {
h.step();
}
let w0 = width(&mut h);
for _ in 0..60 {
h.step();
}
let w1 = width(&mut h);
assert!((w1 - w0).abs() < 0.5, "the export dialog grew from {w0} to {w1}");
assert!(w1 < 700.0, "{w1}");
}
#[test]
fn export_dialog_runs_in_the_background() {
let mut h = demo([1200.0, 900.0]);

View File

@ -528,15 +528,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
if graphic {
field(ui, "Graphic", |ui| {
let can_pick = app.services.pick_files.is_some();
let w = ui.available_width() - if can_pick { 76.0 } else { 0.0 };
ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(w));
if can_pick
&& crate::widgets::text_button(ui, "exportWmChoose", "Choose…", false).clicked()
&& let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next())
{
wm.image = f;
}
trailing_button_row(ui, |ui| {
if app.services.pick_files.is_some()
&& crate::widgets::text_button(ui, "exportWmChoose", "Choose…", false).clicked()
&& let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next())
{
wm.image = f;
}
ui.add(egui::TextEdit::singleline(&mut wm.image).hint_text("logo.png").desired_width(ui.available_width()));
});
});
let mut width = wm.image_width as f64 * 100.0;
if num(ui, &WM_IMAGE_WIDTH, &mut width) {
@ -586,16 +586,16 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
}
field(ui, "Folder", |ui| {
let can_pick = app.services.pick_folder.is_some();
let w = ui.available_width() - if can_pick { 76.0 } else { 0.0 };
ui.add(egui::TextEdit::singleline(dir).desired_width(w));
if can_pick
&& crate::widgets::text_button(ui, "exportChooseFolder", "Choose…", false).clicked()
&& let Some(pick) = app.services.pick_folder.as_mut()
&& let Some(d) = pick()
{
*dir = d;
}
trailing_button_row(ui, |ui| {
if app.services.pick_folder.is_some()
&& crate::widgets::text_button(ui, "exportChooseFolder", "Choose…", false).clicked()
&& let Some(pick) = app.services.pick_folder.as_mut()
&& let Some(d) = pick()
{
*dir = d;
}
ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width()));
});
});
field(ui, "Subfolder", |ui| {
ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text("none").desired_width(f32::INFINITY))
@ -610,19 +610,20 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
);
ui.add_space(4.0);
field(ui, "Save preset", |ui| {
let w = ui.available_width() - 60.0;
ui.add(egui::TextEdit::singleline(preset_name).hint_text("Preset name").desired_width(w));
let named = !preset_name.trim().is_empty();
if crate::widgets::text_button(ui, "exportSavePreset", "Save", false).clicked() && named {
let params = export_dialog_params(opts, *full_size, resize, *limit_kb);
match app.run("export.savePreset", json!({"name": preset_name.trim(), "params": params})) {
Ok(_) => {
app.toast(ui.ctx(), format!("Saved export preset “{}”", preset_name.trim()));
preset_name.clear();
trailing_button_row(ui, |ui| {
let named = !preset_name.trim().is_empty();
if crate::widgets::text_button(ui, "exportSavePreset", "Save", false).clicked() && named {
let params = export_dialog_params(opts, *full_size, resize, *limit_kb);
match app.run("export.savePreset", json!({"name": preset_name.trim(), "params": params})) {
Ok(_) => {
app.toast(ui.ctx(), format!("Saved export preset “{}”", preset_name.trim()));
preset_name.clear();
}
Err(e) => app.toast(ui.ctx(), e),
}
Err(e) => app.toast(ui.ctx(), e),
}
}
ui.add(egui::TextEdit::singleline(preset_name).hint_text("Preset name").desired_width(ui.available_width()));
});
});
}
Dialog::Merge { opts } => crate::merge::body(app, ui, opts),
@ -722,6 +723,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
});
if let Some(w) = shown {
ctx.move_to_top(w.response.layer_id);
crate::widgets::register(ctx, "dialog:window", w.response.rect);
}
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
close = true;
@ -983,6 +985,14 @@ fn field<R>(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R
.inner
}
/// The rest of a [`field`] row, laid out right to left: `add` places its trailing button(s) first,
/// then a text field taking exactly the width that's left. (Subtracting a guessed button width
/// instead made the auto-sized dialog grow every frame when the real button was wider, #8.)
fn trailing_button_row(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
let h = ui.spacing().interact_size.y.max(24.0);
ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), h), egui::Layout::right_to_left(egui::Align::Center), add);
}
/// A labelled row of mutually exclusive choice buttons (ids `button:{id}-{index}`).
fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, label: &str, id: &str, options: &[(V, &str)], value: &mut V) {
field(ui, label, |ui| {