From 271bf01277cf8fd4a5c2d4189203c2ad617913bd Mon Sep 17 00:00:00 2001 From: BoxBuddy Contributor Date: Mon, 17 Aug 2026 22:37:48 +0200 Subject: [PATCH 1/3] feat: add GUI to author distrobox.ini files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'Create Assemble INI…' to the application menu (between 'Set Preferred Terminal' and 'About BoxBuddy'). The dialog exposes the most common keys - image, additional_packages, home, init, nvidia - with a live monospace preview of the file that will be written. Save opens a FileDialog in save mode with the *.ini filter pre-applied, defaulting to ~/Documents/
.ini. Resolves the 'Create Assemble ini files via GUI' item on the project's Roadmap. The shape of distrobox.ini is flat: one section per box, key=value per option. Anything beyond the six exposed fields has to be edited by hand; documented in the function-level comment. --- src/main.rs | 320 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 319 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index a20908a..bb0de8e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -279,11 +279,18 @@ fn set_window_actions(window: &ApplicationWindow) { }) .build(); + let action_create_assemble_ini = gio::ActionEntry::builder("create_assemble_ini") + .activate(|window: &ApplicationWindow, _, _| { + show_create_assemble_ini_dialog(window); + }) + .build(); + window.add_action_entries([ action_refresh, action_about, action_close, action_set_preferred_terminal, + action_create_assemble_ini, ]); } @@ -307,11 +314,20 @@ fn get_main_menu_model() -> gio::MenuModel { menu.insert_item( 2, //TRANSLATORS: Menu Item - &gio::MenuItem::new(Some(&gettext("About BoxBuddy")), Some("win.about")), + &gio::MenuItem::new( + //TRANSLATORS: Menu Item + Some(&gettext("Create Assemble INI…")), + Some("win.create_assemble_ini"), + ), ); menu.insert_item( 3, //TRANSLATORS: Menu Item + &gio::MenuItem::new(Some(&gettext("About BoxBuddy")), Some("win.about")), + ); + menu.insert_item( + 4, + //TRANSLATORS: Menu Item &gio::MenuItem::new(Some(&gettext("Quit")), Some("win.close")), ); @@ -623,6 +639,308 @@ fn make_box_tab(dbox: &DBox, window: &ApplicationWindow, tab_num: u32) -> gtk::B tab_box } +/// Show a small dialog that helps the user author a `distrobox.ini` file from +/// scratch. The file is written with a `.ini` extension into a user-chosen +/// directory; nothing about it is executed, so a user who only wants to +/// inspect a draft can do so without any risk of an unintended container +/// being built. +/// +/// We deliberately keep this dialog simple. The shape of `distrobox.ini` is +/// a flat INI with one section per box and one key per option, and the few +/// keys the user is most likely to want (`image`, `init`, `nvidia`, `home`, +/// `additional_packages`) cover the common cases. Anything beyond that has +/// to be edited by hand. +fn show_create_assemble_ini_dialog(window: &ApplicationWindow) { + let popup = gtk::Window::builder() + // TRANSLATORS: Popup Window Title + .title(gettext("Create Assemble INI")) + .transient_for(window) + .default_width(560) + .default_height(560) + .modal(true) + .build(); + + let titlebar = adw::HeaderBar::new(); + + let cancel_btn = gtk::Button::with_label(&gettext("Cancel")); + // TRANSLATORS: Button tooltip + cancel_btn.set_tooltip_text(Some(&gettext("Cancel"))); + let popup_clone = popup.clone(); + cancel_btn.connect_clicked(move |_btn| popup_clone.destroy()); + + let save_btn = gtk::Button::with_label(&gettext("Save")); + // TRANSLATORS: Button tooltip + save_btn.set_tooltip_text(Some(&gettext("Save INI file"))); + save_btn.add_css_class("suggested-action"); + + titlebar.pack_start(&cancel_btn); + titlebar.pack_end(&save_btn); + popup.set_titlebar(Some(&titlebar)); + + let main_box = gtk::Box::new(Orientation::Vertical, 10); + main_box.set_margin_start(10); + main_box.set_margin_end(10); + main_box.set_margin_top(10); + main_box.set_margin_bottom(10); + + let form = gtk::ListBox::new(); + form.set_selection_mode(gtk::SelectionMode::None); + form.add_css_class("boxed-list"); + + // TRANSLATORS: Entry Label - section name in the assemble .ini + let name_row = adw::EntryRow::new(); + name_row.set_title(&gettext("Section / Box name")); + name_row.set_text("my-box"); + + // TRANSLATORS: Entry Label - container image + let image_row = adw::EntryRow::new(); + image_row.set_title(&gettext("Image")); + image_row.set_text("ubuntu:24.04"); + + // TRANSLATORS: Entry Label - comma-separated extra packages + let packages_row = adw::EntryRow::new(); + packages_row.set_title(&gettext("Additional packages (comma-separated, optional)")); + + // TRANSLATORS: Entry Label - custom home directory + let home_row = adw::EntryRow::new(); + home_row.set_title(&gettext("Custom home directory (optional)")); + + let init_row = adw::SwitchRow::new(); + init_row.set_title(&gettext("Enable init system (systemd)")); + + let nvidia_row = adw::SwitchRow::new(); + nvidia_row.set_title(&gettext("Enable NVIDIA GPU support")); + + form.append(&name_row); + form.append(&image_row); + form.append(&packages_row); + form.append(&home_row); + form.append(&init_row); + form.append(&nvidia_row); + + let preview_label = gtk::Label::new(None); + preview_label.set_xalign(0.0); + preview_label.set_yalign(0.0); + preview_label.set_wrap(true); + preview_label.set_selectable(true); + preview_label.add_css_class("monospace"); + preview_label.add_css_class("dim-label"); + // TRANSLATORS: Preview heading for the .ini contents the user is composing + preview_label.set_markup(&gettext( + "Preview — fill in the form to see what will be saved.", + )); + + main_box.append(&form); + main_box.append(&preview_label); + + popup.set_child(Some(&main_box)); + popup.present(); + + // Helper that builds the preview body and toggles save availability. We + // pass the current field values explicitly rather than capturing the + // rows, so the same helper can be reused from every signal hook. + fn update_preview_inner( + section: &str, + image: &str, + packages: &str, + home: &str, + init: bool, + nvidia: bool, + preview_label: >k::Label, + save_btn: >k::Button, + ) { + if section.trim().is_empty() || image.trim().is_empty() { + preview_label.set_markup(&gettext( + "Preview — section name and image are required.", + )); + save_btn.set_sensitive(false); + return; + } + + let mut body = String::new(); + body.push_str(&format!("[{section}]\n")); + body.push_str(&format!("image={image}\n")); + if !packages.trim().is_empty() { + body.push_str(&format!("additional_packages=\"{packages}\"\n")); + } + if !home.trim().is_empty() { + body.push_str(&format!("home={home}\n")); + } + if init { + body.push_str("init=true\n"); + } + if nvidia { + body.push_str("nvidia=true\n"); + } + + preview_label.set_text(&body); + save_btn.set_sensitive(true); + } + + // GTK signal hooks each need a unique Fn closure; we wrap a call to the + // shared helper. Each row is captured into the wrapper separately so the + // signals stay independent. + let label_for_signal = preview_label.clone(); + let save_for_signal = save_btn.clone(); + name_row.connect_changed(clone!(@strong label_for_signal as preview_label, @strong save_for_signal as save_btn, @strong name_row, @strong image_row, @strong packages_row, @strong home_row, @strong init_row, @strong nvidia_row => move |_arg| { + update_preview_inner( + &name_row.text().to_string(), + &image_row.text().to_string(), + &packages_row.text().to_string(), + &home_row.text().to_string(), + init_row.is_active(), + nvidia_row.is_active(), + &preview_label, + &save_btn, + ); + })); + image_row.connect_changed(clone!(@strong label_for_signal as preview_label, @strong save_for_signal as save_btn, @strong name_row, @strong image_row, @strong packages_row, @strong home_row, @strong init_row, @strong nvidia_row => move |_arg| { + update_preview_inner( + &name_row.text().to_string(), + &image_row.text().to_string(), + &packages_row.text().to_string(), + &home_row.text().to_string(), + init_row.is_active(), + nvidia_row.is_active(), + &preview_label, + &save_btn, + ); + })); + packages_row.connect_changed(clone!(@strong label_for_signal as preview_label, @strong save_for_signal as save_btn, @strong name_row, @strong image_row, @strong packages_row, @strong home_row, @strong init_row, @strong nvidia_row => move |_arg| { + update_preview_inner( + &name_row.text().to_string(), + &image_row.text().to_string(), + &packages_row.text().to_string(), + &home_row.text().to_string(), + init_row.is_active(), + nvidia_row.is_active(), + &preview_label, + &save_btn, + ); + })); + home_row.connect_changed(clone!(@strong label_for_signal as preview_label, @strong save_for_signal as save_btn, @strong name_row, @strong image_row, @strong packages_row, @strong home_row, @strong init_row, @strong nvidia_row => move |_arg| { + update_preview_inner( + &name_row.text().to_string(), + &image_row.text().to_string(), + &packages_row.text().to_string(), + &home_row.text().to_string(), + init_row.is_active(), + nvidia_row.is_active(), + &preview_label, + &save_btn, + ); + })); + init_row.connect_notify_local( + Some("active"), + clone!(@strong label_for_signal as preview_label, @strong save_for_signal as save_btn, @strong name_row, @strong image_row, @strong packages_row, @strong home_row, @strong init_row, @strong nvidia_row => move |_arg, _pspec| { + update_preview_inner( + &name_row.text().to_string(), + &image_row.text().to_string(), + &packages_row.text().to_string(), + &home_row.text().to_string(), + init_row.is_active(), + nvidia_row.is_active(), + &preview_label, + &save_btn, + ); + }), + ); + nvidia_row.connect_notify_local( + Some("active"), + clone!(@strong label_for_signal as preview_label, @strong save_for_signal as save_btn, @strong name_row, @strong image_row, @strong packages_row, @strong home_row, @strong init_row, @strong nvidia_row => move |_arg, _pspec| { + update_preview_inner( + &name_row.text().to_string(), + &image_row.text().to_string(), + &packages_row.text().to_string(), + &home_row.text().to_string(), + init_row.is_active(), + nvidia_row.is_active(), + &preview_label, + &save_btn, + ); + }), + ); + + // Run once so the preview is populated before the user touches anything. + update_preview_inner( + &name_row.text().to_string(), + &image_row.text().to_string(), + &packages_row.text().to_string(), + &home_row.text().to_string(), + init_row.is_active(), + nvidia_row.is_active(), + &preview_label, + &save_btn, + ); + + // Picking a destination and writing the file is wired up to the Save + // button. We use the same FileDialog the assemble flow already uses for + // `.ini` selection, with save mode and the `.ini` filter pre-applied. + let popup_for_save = popup.clone(); + save_btn.connect_clicked(move |_btn| { + let section = name_row.text().to_string(); + let image = image_row.text().to_string(); + let packages = packages_row.text().to_string(); + let home = home_row.text().to_string(); + let init = init_row.is_active(); + let nvidia = nvidia_row.is_active(); + + if section.trim().is_empty() || image.trim().is_empty() { + return; + } + + let mut body = String::new(); + body.push_str(&format!("[{section}]\n")); + body.push_str(&format!("image={image}\n")); + if !packages.trim().is_empty() { + body.push_str(&format!("additional_packages=\"{packages}\"\n")); + } + if !home.trim().is_empty() { + body.push_str(&format!("home={home}\n")); + } + if init { + body.push_str("init=true\n"); + } + if nvidia { + body.push_str("nvidia=true\n"); + } + + // Default filename:
.ini in the user's Documents folder. + let default_dir = if let Ok(home) = std::env::var("HOME") { + std::path::PathBuf::from(home).join("Documents") + } else { + std::path::PathBuf::from(".") + }; + let default_path = default_dir.join(format!("{section}.ini")); + + let ini_filter = gtk::FileFilter::new(); + //TRANSLATORS: File type + ini_filter.set_name(Some(&gettext("INI-Files"))); + ini_filter.add_suffix("ini"); + + let file_dialog = FileDialog::builder() + .default_filter(&ini_filter) + .modal(true) + .build(); + file_dialog.set_initial_file(Some(&gio::File::for_path(default_path))); + + let body_clone = body.clone(); + let popup_clone2 = popup_for_save.clone(); + file_dialog.save( + Some(&popup_for_save), + None::<&gio::Cancellable>, + move |result| { + if let Ok(file) = result { + if let Some(path) = file.path() { + let _ = std::fs::write(&path, &body_clone); + popup_clone2.destroy(); + } + } + }, + ); + }); +} + fn assemble_new_distrobox(window: &ApplicationWindow, ini_file: String) { let assemble_box_popup = gtk::Window::builder() // TRANSLATORS: Popup Window Title From 4de3a02094c32ffbd1f0295d2b2750df34af6643 Mon Sep 17 00:00:00 2001 From: Kacper Paczos Date: Wed, 19 Aug 2026 19:36:11 +0200 Subject: [PATCH 2/3] refactor: build the assemble .ini in one place, and report save failures The .ini body was assembled twice by identical push_str blocks - once for the live preview, once at save - so the two could drift. Both now call a single build_assemble_ini in the handler, which comes with unit tests pinning the minimal, all-set and blank-optional shapes. The save itself was `let _ = std::fs::write(...)`, which threw away any error and destroyed the dialog as if it had worked. A failed write now raises a dialog naming the error and leaves the form open so the work isn't lost. --- src/distrobox_handler.rs | 68 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 62 +++++++++++++++--------------------- 2 files changed, 93 insertions(+), 37 deletions(-) diff --git a/src/distrobox_handler.rs b/src/distrobox_handler.rs index 22c6c82..5317b37 100644 --- a/src/distrobox_handler.rs +++ b/src/distrobox_handler.rs @@ -524,6 +524,37 @@ pub fn create_box_streaming( } } +/// Builds the body of a distrobox-assemble `.ini` file from the fields of the +/// generator form. Kept here, and pure, so both the live preview and the save +/// path render identical text from one place and it can be unit-tested. +/// `section` and `image` are assumed already validated as non-empty by the +/// caller. +pub fn build_assemble_ini( + section: &str, + image: &str, + packages: &str, + home: &str, + init: bool, + nvidia: bool, +) -> String { + let mut body = String::new(); + body.push_str(&format!("[{section}]\n")); + body.push_str(&format!("image={image}\n")); + if !packages.trim().is_empty() { + body.push_str(&format!("additional_packages=\"{packages}\"\n")); + } + if !home.trim().is_empty() { + body.push_str(&format!("home={home}\n")); + } + if init { + body.push_str("init=true\n"); + } + if nvidia { + body.push_str("nvidia=true\n"); + } + body +} + /// Runs `distrobox-assemble` with the provided file. pub fn assemble_box(ini_file: &str) -> String { let args = &["assemble", "create", "--file", ini_file]; @@ -937,3 +968,40 @@ mod stream_tests { assert!(exists, "streaming create did not produce a listable box"); } } + +#[cfg(test)] +mod assemble_tests { + use super::build_assemble_ini; + + #[test] + fn minimal_ini_has_only_section_and_image() { + assert_eq!( + build_assemble_ini( + "dev", + "docker.io/library/ubuntu:latest", + "", + "", + false, + false + ), + "[dev]\nimage=docker.io/library/ubuntu:latest\n" + ); + } + + #[test] + fn optional_fields_appear_only_when_set() { + let ini = build_assemble_ini("work", "img", "git, vim", "/home/me/work", true, true); + assert_eq!( + ini, + "[work]\nimage=img\nadditional_packages=\"git, vim\"\nhome=/home/me/work\ninit=true\nnvidia=true\n" + ); + } + + #[test] + fn blank_optionals_are_skipped() { + let ini = build_assemble_ini("d", "i", " ", " ", false, false); + assert!(!ini.contains("additional_packages")); + assert!(!ini.contains("home=")); + assert!(!ini.contains("init=")); + } +} diff --git a/src/main.rs b/src/main.rs index bb0de8e..72b4983 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,11 +17,12 @@ use gtk::{ mod distrobox_handler; use distrobox_handler::{ - assemble_box, clone_box, create_box, create_box_streaming, delete_box, export_app_from_box, get_all_distroboxes, - get_apps_in_box, get_available_images_with_distro_name, get_binaries_exported_from_box, - get_number_of_boxes, install_deb_in_box, install_rpm_in_box, open_terminal_in_box, - remove_app_from_host, remove_exported_binary_from_box, run_command_in_box, stop_box, - upgrade_all_boxes, upgrade_box, DBox, DBoxApp, + assemble_box, build_assemble_ini, clone_box, create_box, create_box_streaming, delete_box, + export_app_from_box, get_all_distroboxes, get_apps_in_box, + get_available_images_with_distro_name, get_binaries_exported_from_box, get_number_of_boxes, + install_deb_in_box, install_rpm_in_box, open_terminal_in_box, remove_app_from_host, + remove_exported_binary_from_box, run_command_in_box, stop_box, upgrade_all_boxes, upgrade_box, + DBox, DBoxApp, }; mod utils; @@ -757,21 +758,7 @@ fn show_create_assemble_ini_dialog(window: &ApplicationWindow) { return; } - let mut body = String::new(); - body.push_str(&format!("[{section}]\n")); - body.push_str(&format!("image={image}\n")); - if !packages.trim().is_empty() { - body.push_str(&format!("additional_packages=\"{packages}\"\n")); - } - if !home.trim().is_empty() { - body.push_str(&format!("home={home}\n")); - } - if init { - body.push_str("init=true\n"); - } - if nvidia { - body.push_str("nvidia=true\n"); - } + let body = build_assemble_ini(section, image, packages, home, init, nvidia); preview_label.set_text(&body); save_btn.set_sensitive(true); @@ -889,21 +876,7 @@ fn show_create_assemble_ini_dialog(window: &ApplicationWindow) { return; } - let mut body = String::new(); - body.push_str(&format!("[{section}]\n")); - body.push_str(&format!("image={image}\n")); - if !packages.trim().is_empty() { - body.push_str(&format!("additional_packages=\"{packages}\"\n")); - } - if !home.trim().is_empty() { - body.push_str(&format!("home={home}\n")); - } - if init { - body.push_str("init=true\n"); - } - if nvidia { - body.push_str("nvidia=true\n"); - } + let body = build_assemble_ini(§ion, &image, &packages, &home, init, nvidia); // Default filename:
.ini in the user's Documents folder. let default_dir = if let Ok(home) = std::env::var("HOME") { @@ -932,8 +905,23 @@ fn show_create_assemble_ini_dialog(window: &ApplicationWindow) { move |result| { if let Ok(file) = result { if let Some(path) = file.path() { - let _ = std::fs::write(&path, &body_clone); - popup_clone2.destroy(); + match std::fs::write(&path, &body_clone) { + Ok(()) => popup_clone2.destroy(), + Err(e) => { + // A failed save used to vanish without a + // trace; tell the user instead of pretending + // it worked. + let dialog = adw::MessageDialog::new( + Some(&popup_clone2), + //TRANSLATORS: Error dialog heading when the .ini file cannot be written + Some(&gettext("Could not save file")), + Some(&format!("{e}")), + ); + //TRANSLATORS: Dialog button + dialog.add_response("ok", &gettext("OK")); + dialog.present(); + } + } } } }, From 29c91a35c63e3d67fead2028fb5f63103bba889c Mon Sep 17 00:00:00 2001 From: Kacper Paczos Date: Sun, 23 Aug 2026 09:51:32 +0200 Subject: [PATCH 3/3] simplify: one preview closure shared by every field The six signal hooks each re-listed the same eight arguments for a nested helper. A single Rc closure that reads the rows itself now does the re-render; every changed/notify hook just calls it. --- src/main.rs | 157 +++++++++++++--------------------------------------- 1 file changed, 40 insertions(+), 117 deletions(-) diff --git a/src/main.rs b/src/main.rs index 72b4983..2bf8d0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -737,128 +737,51 @@ fn show_create_assemble_ini_dialog(window: &ApplicationWindow) { popup.set_child(Some(&main_box)); popup.present(); - // Helper that builds the preview body and toggles save availability. We - // pass the current field values explicitly rather than capturing the - // rows, so the same helper can be reused from every signal hook. - fn update_preview_inner( - section: &str, - image: &str, - packages: &str, - home: &str, - init: bool, - nvidia: bool, - preview_label: >k::Label, - save_btn: >k::Button, - ) { - if section.trim().is_empty() || image.trim().is_empty() { - preview_label.set_markup(&gettext( - "Preview — section name and image are required.", - )); - save_btn.set_sensitive(false); - return; - } - - let body = build_assemble_ini(section, image, packages, home, init, nvidia); - - preview_label.set_text(&body); - save_btn.set_sensitive(true); - } + // One closure re-renders the preview from the current field values and + // toggles Save. Every field change calls it, and it runs once up front so + // the preview is populated before the user touches anything. + let update_preview = { + let name_row = name_row.clone(); + let image_row = image_row.clone(); + let packages_row = packages_row.clone(); + let home_row = home_row.clone(); + let init_row = init_row.clone(); + let nvidia_row = nvidia_row.clone(); + let preview_label = preview_label.clone(); + let save_btn = save_btn.clone(); + std::rc::Rc::new(move || { + let section = name_row.text(); + let image = image_row.text(); + if section.trim().is_empty() || image.trim().is_empty() { + preview_label.set_markup(&gettext( + "Preview — section name and image are required.", + )); + save_btn.set_sensitive(false); + return; + } - // GTK signal hooks each need a unique Fn closure; we wrap a call to the - // shared helper. Each row is captured into the wrapper separately so the - // signals stay independent. - let label_for_signal = preview_label.clone(); - let save_for_signal = save_btn.clone(); - name_row.connect_changed(clone!(@strong label_for_signal as preview_label, @strong save_for_signal as save_btn, @strong name_row, @strong image_row, @strong packages_row, @strong home_row, @strong init_row, @strong nvidia_row => move |_arg| { - update_preview_inner( - &name_row.text().to_string(), - &image_row.text().to_string(), - &packages_row.text().to_string(), - &home_row.text().to_string(), - init_row.is_active(), - nvidia_row.is_active(), - &preview_label, - &save_btn, - ); - })); - image_row.connect_changed(clone!(@strong label_for_signal as preview_label, @strong save_for_signal as save_btn, @strong name_row, @strong image_row, @strong packages_row, @strong home_row, @strong init_row, @strong nvidia_row => move |_arg| { - update_preview_inner( - &name_row.text().to_string(), - &image_row.text().to_string(), - &packages_row.text().to_string(), - &home_row.text().to_string(), - init_row.is_active(), - nvidia_row.is_active(), - &preview_label, - &save_btn, - ); - })); - packages_row.connect_changed(clone!(@strong label_for_signal as preview_label, @strong save_for_signal as save_btn, @strong name_row, @strong image_row, @strong packages_row, @strong home_row, @strong init_row, @strong nvidia_row => move |_arg| { - update_preview_inner( - &name_row.text().to_string(), - &image_row.text().to_string(), - &packages_row.text().to_string(), - &home_row.text().to_string(), - init_row.is_active(), - nvidia_row.is_active(), - &preview_label, - &save_btn, - ); - })); - home_row.connect_changed(clone!(@strong label_for_signal as preview_label, @strong save_for_signal as save_btn, @strong name_row, @strong image_row, @strong packages_row, @strong home_row, @strong init_row, @strong nvidia_row => move |_arg| { - update_preview_inner( - &name_row.text().to_string(), - &image_row.text().to_string(), - &packages_row.text().to_string(), - &home_row.text().to_string(), - init_row.is_active(), - nvidia_row.is_active(), - &preview_label, - &save_btn, - ); - })); - init_row.connect_notify_local( - Some("active"), - clone!(@strong label_for_signal as preview_label, @strong save_for_signal as save_btn, @strong name_row, @strong image_row, @strong packages_row, @strong home_row, @strong init_row, @strong nvidia_row => move |_arg, _pspec| { - update_preview_inner( - &name_row.text().to_string(), - &image_row.text().to_string(), - &packages_row.text().to_string(), - &home_row.text().to_string(), - init_row.is_active(), - nvidia_row.is_active(), - &preview_label, - &save_btn, - ); - }), - ); - nvidia_row.connect_notify_local( - Some("active"), - clone!(@strong label_for_signal as preview_label, @strong save_for_signal as save_btn, @strong name_row, @strong image_row, @strong packages_row, @strong home_row, @strong init_row, @strong nvidia_row => move |_arg, _pspec| { - update_preview_inner( - &name_row.text().to_string(), - &image_row.text().to_string(), - &packages_row.text().to_string(), - &home_row.text().to_string(), + let body = build_assemble_ini( + §ion, + &image, + &packages_row.text(), + &home_row.text(), init_row.is_active(), nvidia_row.is_active(), - &preview_label, - &save_btn, ); - }), - ); + preview_label.set_text(&body); + save_btn.set_sensitive(true); + }) + }; - // Run once so the preview is populated before the user touches anything. - update_preview_inner( - &name_row.text().to_string(), - &image_row.text().to_string(), - &packages_row.text().to_string(), - &home_row.text().to_string(), - init_row.is_active(), - nvidia_row.is_active(), - &preview_label, - &save_btn, - ); + for row in [&name_row, &image_row, &packages_row, &home_row] { + let update = update_preview.clone(); + row.connect_changed(move |_row| update()); + } + for row in [&init_row, &nvidia_row] { + let update = update_preview.clone(); + row.connect_active_notify(move |_row| update()); + } + update_preview(); // Picking a destination and writing the file is wired up to the Save // button. We use the same FileDialog the assemble flow already uses for