Files
pixstrip/pixstrip-gtk/src/steps/step_rename.rs
lashman b432cc7431 Fix 26 bugs, edge cases, and consistency issues from fifth audit pass
Critical: undo toast now trashes only batch output files (not entire dir),
JPEG scanline write errors propagated, selective metadata write result returned.

High: zero-dimension guards in ResizeConfig/fit_within, negative aspect ratio
rejection, FM integration toggle infinite recursion guard, saturating counter
arithmetic in executor.

Medium: PNG compression level passed to oxipng, pct mode updates job_config,
external file loading updates step indicator, CLI undo removes history entries,
watch config write failures reported, fast-copy path reads image dimensions for
rename templates, discovery excludes unprocessable formats (heic/svg/ico/jxl),
CLI warns on invalid algorithm/overwrite values, resolve_collision trailing dot
fix, generation guards on all preview threads to cancel stale results, default
DPI aligned to 0, watermark text width uses char count not byte length.

Low: binary path escaped in Nautilus extension, file dialog filter aligned with
discovery, reset_wizard clears preset_mode and output_dir.
2026-03-07 19:47:23 +02:00

869 lines
28 KiB
Rust

use adw::prelude::*;
use std::cell::Cell;
use std::collections::HashMap;
use std::rc::Rc;
use crate::app::AppState;
pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
let cfg = state.job_config.borrow();
// === OUTER LAYOUT ===
let outer = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(0)
.vexpand(true)
.build();
// --- Enable toggle (full width) ---
let enable_group = adw::PreferencesGroup::builder()
.margin_start(12)
.margin_end(12)
.margin_top(12)
.build();
let enable_row = adw::SwitchRow::builder()
.title("Enable Rename")
.subtitle("Rename output files with prefix, suffix, or template")
.active(cfg.rename_enabled)
.build();
enable_group.add(&enable_row);
outer.append(&enable_group);
// === LEFT SIDE: Preview ===
let show_all: Rc<Cell<bool>> = Rc::new(Cell::new(false));
let preview_header = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(8)
.margin_bottom(4)
.build();
let showing_label = gtk::Label::builder()
.css_classes(["dim-label", "caption"])
.halign(gtk::Align::Start)
.hexpand(true)
.build();
let show_all_button = gtk::Button::builder()
.label("Show all")
.build();
show_all_button.add_css_class("pill");
show_all_button.add_css_class("caption");
preview_header.append(&showing_label);
preview_header.append(&show_all_button);
// Preview rows container
let preview_rows = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(2)
.build();
let preview_scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.child(&preview_rows)
.build();
// Conflict banner
let conflict_banner = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(8)
.margin_top(8)
.visible(false)
.build();
conflict_banner.add_css_class("card");
let conflict_icon = gtk::Image::builder()
.icon_name("dialog-warning-symbolic")
.margin_start(8)
.margin_top(4)
.margin_bottom(4)
.build();
conflict_icon.add_css_class("warning");
let conflict_label = gtk::Label::builder()
.css_classes(["caption"])
.halign(gtk::Align::Start)
.hexpand(true)
.wrap(true)
.margin_top(4)
.margin_bottom(4)
.margin_end(8)
.build();
conflict_banner.append(&conflict_icon);
conflict_banner.append(&conflict_label);
// Stats label
let stats_label = gtk::Label::builder()
.css_classes(["dim-label", "caption"])
.halign(gtk::Align::Start)
.wrap(true)
.margin_top(4)
.build();
let preview_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(4)
.hexpand(true)
.vexpand(true)
.build();
preview_box.append(&preview_header);
preview_box.append(&preview_scroll);
preview_box.append(&conflict_banner);
preview_box.append(&stats_label);
// === RIGHT SIDE: Controls (scrollable) ===
let controls = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(12)
.margin_start(12)
.build();
// Reset button at end of controls (added later)
let reset_button = gtk::Button::builder()
.label("Reset to defaults")
.halign(gtk::Align::Start)
.margin_top(4)
.build();
reset_button.add_css_class("pill");
// --- Simple Rename group ---
let simple_group = adw::PreferencesGroup::builder()
.title("Simple Rename")
.description("Add prefix, suffix, and text transformations")
.build();
let prefix_row = adw::EntryRow::builder()
.title("Prefix")
.text(&cfg.rename_prefix)
.build();
let suffix_row = adw::EntryRow::builder()
.title("Suffix")
.text(&cfg.rename_suffix)
.build();
let replace_spaces_row = adw::ComboRow::builder()
.title("Replace Spaces")
.subtitle("How to handle spaces in filenames")
.use_subtitle(true)
.build();
replace_spaces_row.set_model(Some(&gtk::StringList::new(&[
"No change",
"Underscores (_)",
"Hyphens (-)",
"Dots (.)",
"CamelCase",
"Remove spaces",
])));
replace_spaces_row.set_list_factory(Some(&super::full_text_list_factory()));
replace_spaces_row.set_selected(cfg.rename_replace_spaces);
let special_chars_row = adw::ComboRow::builder()
.title("Special Characters")
.subtitle("Filter non-standard characters from filenames")
.use_subtitle(true)
.build();
special_chars_row.set_model(Some(&gtk::StringList::new(&[
"Keep all",
"Filesystem-safe (remove / \\ : * ? \" < > |)",
"Web-safe (a-z, 0-9, -, _, .)",
"Hyphens + underscores (a-z, 0-9, -, _)",
"Hyphens only (a-z, 0-9, -)",
"Alphanumeric only (a-z, 0-9)",
])));
special_chars_row.set_list_factory(Some(&super::full_text_list_factory()));
special_chars_row.set_selected(cfg.rename_special_chars);
let case_row = adw::ComboRow::builder()
.title("Case Conversion")
.subtitle("Convert filename case")
.use_subtitle(true)
.build();
case_row.set_model(Some(&gtk::StringList::new(&["No change", "lowercase", "UPPERCASE", "Title Case"])));
case_row.set_list_factory(Some(&super::full_text_list_factory()));
case_row.set_selected(cfg.rename_case);
// Counter toggle + expandable sub-options
let counter_row = adw::ExpanderRow::builder()
.title("Add Sequential Counter")
.subtitle("Append a numbered sequence to filenames")
.show_enable_switch(true)
.enable_expansion(cfg.rename_counter_enabled)
.expanded(cfg.rename_counter_enabled)
.build();
let counter_position_row = adw::ComboRow::builder()
.title("Counter Position")
.subtitle("Where the counter number appears")
.use_subtitle(true)
.build();
counter_position_row.set_model(Some(&gtk::StringList::new(&[
"Before prefix",
"Before name",
"After name",
"After suffix",
"Replace name",
])));
counter_position_row.set_list_factory(Some(&super::full_text_list_factory()));
counter_position_row.set_selected(cfg.rename_counter_position);
let counter_start_row = adw::SpinRow::builder()
.title("Counter Start")
.subtitle("First number in sequence")
.adjustment(&gtk::Adjustment::new(cfg.rename_counter_start as f64, 0.0, 99999.0, 1.0, 10.0, 0.0))
.build();
let counter_padding_row = adw::SpinRow::builder()
.title("Counter Padding")
.subtitle("Minimum digits (e.g., 3 = 001, 002, 003)")
.adjustment(&gtk::Adjustment::new(cfg.rename_counter_padding as f64, 1.0, 10.0, 1.0, 1.0, 0.0))
.build();
counter_row.add_row(&counter_position_row);
counter_row.add_row(&counter_start_row);
counter_row.add_row(&counter_padding_row);
simple_group.add(&prefix_row);
simple_group.add(&suffix_row);
simple_group.add(&replace_spaces_row);
simple_group.add(&special_chars_row);
simple_group.add(&case_row);
simple_group.add(&counter_row);
controls.append(&simple_group);
// --- Advanced group ---
let advanced_group = adw::PreferencesGroup::builder()
.title("Advanced")
.build();
let advanced_expander = adw::ExpanderRow::builder()
.title("Template Engine and Find/Replace")
.subtitle("Advanced rename options with variables and regex")
.show_enable_switch(false)
.expanded(state.is_section_expanded("rename-advanced"))
.build();
{
let st = state.clone();
advanced_expander.connect_expanded_notify(move |row| {
st.set_section_expanded("rename-advanced", row.is_expanded());
});
}
let template_row = adw::EntryRow::builder()
.title("Template")
.text(&cfg.rename_template)
.build();
// Template preset chips
let preset_templates = [
("Date + Name", "{date}_{name}"),
("EXIF Date + Name", "{exif_date}_{name}"),
("Sequential", "{name}_{counter:4}"),
("Dimensions", "{name}_{width}x{height}"),
("Camera + Date", "{camera}_{exif_date}_{counter:3}"),
("Web-safe", "{name}_web"),
("Date + Counter", "{date}_{counter:4}"),
("Name + Size", "{name}_{width}x{height}_{counter:3}"),
];
let presets_box = gtk::FlowBox::builder()
.selection_mode(gtk::SelectionMode::None)
.max_children_per_line(6)
.min_children_per_line(2)
.row_spacing(4)
.column_spacing(4)
.margin_top(4)
.margin_bottom(4)
.margin_start(12)
.margin_end(12)
.homogeneous(false)
.build();
for (label, template) in &preset_templates {
let btn = gtk::Button::builder()
.label(*label)
.tooltip_text(*template)
.build();
btn.add_css_class("flat");
let tr = template_row.clone();
let tmpl = template.to_string();
btn.connect_clicked(move |_| {
tr.set_text(&tmpl);
tr.set_position(tmpl.chars().count() as i32);
});
presets_box.append(&btn);
}
let presets_list_row = gtk::ListBoxRow::builder()
.activatable(false)
.selectable(false)
.child(&presets_box)
.build();
// Variable chips (collapsible)
let variables_expander = adw::ExpanderRow::builder()
.title("Available Variables")
.subtitle("Click to insert at cursor position in template")
.show_enable_switch(false)
.expanded(state.is_section_expanded("rename-variables"))
.build();
{
let st = state.clone();
variables_expander.connect_expanded_notify(move |row| {
st.set_section_expanded("rename-variables", row.is_expanded());
});
}
let variables = [
("{name}", "Original filename (no extension)"),
("{ext}", "Output file extension"),
("{original_ext}", "Original file extension"),
("{counter}", "Sequential counter number"),
("{counter:3}", "Counter, zero-padded to 3 digits"),
("{counter:4}", "Counter, zero-padded to 4 digits"),
("{date}", "Today's date (YYYY-MM-DD)"),
("{exif_date}", "Date photo was taken (from EXIF)"),
("{camera}", "Camera model (from EXIF)"),
("{width}", "Output image width in pixels"),
("{height}", "Output image height in pixels"),
];
let vars_flow = gtk::FlowBox::builder()
.selection_mode(gtk::SelectionMode::None)
.max_children_per_line(3)
.min_children_per_line(1)
.row_spacing(4)
.column_spacing(4)
.margin_top(4)
.margin_bottom(4)
.margin_start(12)
.margin_end(12)
.homogeneous(false)
.build();
for (var_name, description) in &variables {
let chip_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(1)
.build();
let name_label = gtk::Label::builder()
.label(*var_name)
.css_classes(["monospace", "caption"])
.halign(gtk::Align::Start)
.build();
let desc_label = gtk::Label::builder()
.label(*description)
.css_classes(["dim-label"])
.halign(gtk::Align::Start)
.build();
desc_label.add_css_class("caption");
chip_box.append(&name_label);
chip_box.append(&desc_label);
let btn = gtk::Button::builder()
.child(&chip_box)
.has_frame(false)
.build();
let tr = template_row.clone();
let var_text = var_name.to_string();
btn.connect_clicked(move |_| {
let current = tr.text().to_string();
if current.is_empty() {
tr.set_text(&var_text);
tr.set_position(var_text.chars().count() as i32);
} else {
let pos = tr.position() as usize;
let byte_pos = current.char_indices()
.nth(pos)
.map(|(i, _)| i)
.unwrap_or(current.len());
let mut new_text = current.clone();
new_text.insert_str(byte_pos, &var_text);
tr.set_text(&new_text);
tr.set_position((pos + var_text.chars().count()) as i32);
}
});
vars_flow.append(&btn);
}
let vars_list_row = gtk::ListBoxRow::builder()
.activatable(false)
.selectable(false)
.child(&vars_flow)
.build();
variables_expander.add_row(&vars_list_row);
// Find/replace
let find_row = adw::EntryRow::builder()
.title("Find (regex)")
.text(&cfg.rename_find)
.build();
let replace_row = adw::EntryRow::builder()
.title("Replace with")
.text(&cfg.rename_replace)
.build();
advanced_expander.add_row(&template_row);
advanced_expander.add_row(&presets_list_row);
advanced_expander.add_row(&find_row);
advanced_expander.add_row(&replace_row);
advanced_group.add(&advanced_expander);
advanced_group.add(&variables_expander);
controls.append(&advanced_group);
controls.append(&reset_button);
// Scrollable controls
let controls_scrolled = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.width_request(420)
.child(&controls)
.build();
// === Main layout: 60/40 side-by-side ===
let main_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.vexpand(true)
.build();
preview_box.set_width_request(400);
main_box.append(&preview_box);
main_box.append(&controls_scrolled);
outer.append(&main_box);
drop(cfg);
// === Preview update closure ===
let update_preview = {
let files = state.loaded_files.clone();
let jc = state.job_config.clone();
let rows_box = preview_rows.clone();
let showing = showing_label.clone();
let show_all_state = show_all.clone();
let conflict_banner_c = conflict_banner.clone();
let conflict_label_c = conflict_label.clone();
let stats = stats_label.clone();
let show_btn = show_all_button.clone();
Rc::new(move || {
let loaded = files.borrow();
let cfg = jc.borrow();
let total = loaded.len();
// Clear existing rows
while let Some(child) = rows_box.first_child() {
rows_box.remove(&child);
}
if total == 0 {
showing.set_label("No images loaded");
stats.set_label("");
conflict_banner_c.set_visible(false);
show_btn.set_visible(false);
return;
}
let display_count = if show_all_state.get() { total } else { total.min(5) };
show_btn.set_visible(total > 5);
if show_all_state.get() {
show_btn.set_label("Show less");
showing.set_label(&format!("All {} files", total));
} else {
show_btn.set_label("Show all");
showing.set_label(&format!("Showing {} of {} files", display_count, total));
}
// Compute all renames for conflict detection
let mut all_results: Vec<String> = Vec::with_capacity(total);
let mut ext_counts: HashMap<String, usize> = HashMap::new();
let mut longest_name = 0usize;
for (i, path) in loaded.iter().enumerate() {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
*ext_counts.entry(ext.to_string()).or_insert(0) += 1;
let result = if !cfg.rename_template.is_empty() {
let counter = cfg.rename_counter_start + i as u32;
pixstrip_core::operations::rename::apply_template(
&cfg.rename_template, name, ext, counter, None,
)
} else {
let rename_cfg = pixstrip_core::operations::RenameConfig {
prefix: cfg.rename_prefix.clone(),
suffix: cfg.rename_suffix.clone(),
counter_start: cfg.rename_counter_start,
counter_padding: cfg.rename_counter_padding,
counter_enabled: cfg.rename_counter_enabled,
counter_position: cfg.rename_counter_position,
template: None,
case_mode: cfg.rename_case,
replace_spaces: cfg.rename_replace_spaces,
special_chars: cfg.rename_special_chars,
regex_find: cfg.rename_find.clone(),
regex_replace: cfg.rename_replace.clone(),
};
rename_cfg.apply_simple(name, ext, (i + 1) as u32)
};
if result.len() > longest_name {
longest_name = result.len();
}
all_results.push(result);
}
// Detect conflicts
let mut name_counts: HashMap<&str, usize> = HashMap::new();
for result in &all_results {
*name_counts.entry(result.as_str()).or_insert(0) += 1;
}
let conflicts: usize = name_counts.values().filter(|&&c| c > 1).map(|c| c).sum();
if conflicts > 0 {
conflict_banner_c.set_visible(true);
let dupes: Vec<&str> = name_counts.iter()
.filter(|(_, c)| **c > 1)
.take(3)
.map(|(n, _)| *n)
.collect();
let dupe_list = dupes.join(", ");
let msg = if name_counts.values().filter(|c| **c > 1).count() > 3 {
format!("{} files have duplicate names (e.g. {}, ...)", conflicts, dupe_list)
} else {
format!("{} files have duplicate names: {}", conflicts, dupe_list)
};
conflict_label_c.set_label(&msg);
} else {
conflict_banner_c.set_visible(false);
}
// Build preview rows (vertical: original on top, new name below)
for (i, path) in loaded.iter().take(display_count).enumerate() {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
let new_full = &all_results[i];
let row = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(1)
.margin_top(3)
.margin_bottom(3)
.build();
let orig_label = gtk::Label::builder()
.label(&format!("{}.{}", name, ext))
.css_classes(["monospace", "caption", "dim-label"])
.halign(gtk::Align::Start)
.ellipsize(gtk::pango::EllipsizeMode::Middle)
.max_width_chars(50)
.build();
let new_line = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.build();
let arrow_label = gtk::Label::builder()
.label("->")
.css_classes(["dim-label", "caption"])
.build();
let new_name_label = gtk::Label::builder()
.label(new_full.as_str())
.css_classes(["monospace", "caption"])
.halign(gtk::Align::Start)
.hexpand(true)
.ellipsize(gtk::pango::EllipsizeMode::Middle)
.max_width_chars(50)
.build();
// Highlight conflicts
if name_counts.get(new_full.as_str()).copied().unwrap_or(0) > 1 {
new_name_label.add_css_class("error");
}
new_line.append(&arrow_label);
new_line.append(&new_name_label);
row.append(&orig_label);
row.append(&new_line);
rows_box.append(&row);
}
// Stats
let mut ext_parts: Vec<String> = ext_counts
.iter()
.map(|(e, c)| format!("{} .{}", c, e))
.collect();
ext_parts.sort();
stats.set_label(&format!(
"{} files | {} conflicts | {} | Longest: {} chars",
total,
conflicts,
ext_parts.join(", "),
longest_name,
));
})
};
// Debounced wrapper for text entry handlers (150ms)
let debounce_source: Rc<Cell<Option<gtk::glib::SourceId>>> = Rc::new(Cell::new(None));
let debounced_preview = {
let up = update_preview.clone();
let ds = debounce_source.clone();
Rc::new(move || {
if let Some(id) = ds.take() {
id.remove();
}
let up2 = up.clone();
let id = gtk::glib::timeout_add_local_once(
std::time::Duration::from_millis(150),
move || { up2(); },
);
ds.set(Some(id));
})
};
// Call once for initial state
update_preview();
// === Wire signals ===
// Enable toggle
{
let jc = state.job_config.clone();
enable_row.connect_active_notify(move |row| {
jc.borrow_mut().rename_enabled = row.is_active();
});
}
// Show all toggle
{
let sa = show_all.clone();
let up = update_preview.clone();
show_all_button.connect_clicked(move |_| {
sa.set(!sa.get());
up();
});
}
// Reset button
{
let jc = state.job_config.clone();
let up = update_preview.clone();
let prefix_r = prefix_row.clone();
let suffix_r = suffix_row.clone();
let spaces_r = replace_spaces_row.clone();
let special_r = special_chars_row.clone();
let case_r = case_row.clone();
let counter_r = counter_row.clone();
let counter_pos_r = counter_position_row.clone();
let counter_start_r = counter_start_row.clone();
let counter_pad_r = counter_padding_row.clone();
let template_r = template_row.clone();
let find_r = find_row.clone();
let replace_r = replace_row.clone();
reset_button.connect_clicked(move |_| {
{
let mut cfg = jc.borrow_mut();
cfg.rename_prefix = String::new();
cfg.rename_suffix = String::new();
cfg.rename_counter_enabled = false;
cfg.rename_counter_start = 1;
cfg.rename_counter_padding = 3;
cfg.rename_counter_position = 3;
cfg.rename_replace_spaces = 0;
cfg.rename_special_chars = 0;
cfg.rename_case = 0;
cfg.rename_template = String::new();
cfg.rename_find = String::new();
cfg.rename_replace = String::new();
}
prefix_r.set_text("");
suffix_r.set_text("");
spaces_r.set_selected(0);
special_r.set_selected(0);
case_r.set_selected(0);
counter_r.set_enable_expansion(false);
counter_r.set_expanded(false);
counter_pos_r.set_selected(3);
counter_start_r.set_value(1.0);
counter_pad_r.set_value(3.0);
template_r.set_text("");
find_r.set_text("");
replace_r.set_text("");
up();
});
}
// Prefix
{
let jc = state.job_config.clone();
let up = debounced_preview.clone();
prefix_row.connect_changed(move |row| {
jc.borrow_mut().rename_prefix = row.text().to_string();
up();
});
}
// Suffix
{
let jc = state.job_config.clone();
let up = debounced_preview.clone();
suffix_row.connect_changed(move |row| {
jc.borrow_mut().rename_suffix = row.text().to_string();
up();
});
}
// Replace spaces
{
let jc = state.job_config.clone();
let up = update_preview.clone();
replace_spaces_row.connect_selected_notify(move |row| {
jc.borrow_mut().rename_replace_spaces = row.selected();
up();
});
}
// Special chars
{
let jc = state.job_config.clone();
let up = update_preview.clone();
special_chars_row.connect_selected_notify(move |row| {
jc.borrow_mut().rename_special_chars = row.selected();
up();
});
}
// Case conversion
{
let jc = state.job_config.clone();
let up = update_preview.clone();
case_row.connect_selected_notify(move |row| {
jc.borrow_mut().rename_case = row.selected();
up();
});
}
// Counter enable
{
let jc = state.job_config.clone();
let up = update_preview.clone();
counter_row.connect_enable_expansion_notify(move |row| {
jc.borrow_mut().rename_counter_enabled = row.enables_expansion();
up();
});
}
// Counter position
{
let jc = state.job_config.clone();
let up = update_preview.clone();
counter_position_row.connect_selected_notify(move |row| {
jc.borrow_mut().rename_counter_position = row.selected();
up();
});
}
// Counter start
{
let jc = state.job_config.clone();
let up = update_preview.clone();
counter_start_row.connect_value_notify(move |row| {
jc.borrow_mut().rename_counter_start = row.value() as u32;
up();
});
}
// Counter padding
{
let jc = state.job_config.clone();
let up = update_preview.clone();
counter_padding_row.connect_value_notify(move |row| {
jc.borrow_mut().rename_counter_padding = row.value() as u32;
up();
});
}
// Template
{
let jc = state.job_config.clone();
let up = debounced_preview.clone();
template_row.connect_changed(move |row| {
jc.borrow_mut().rename_template = row.text().to_string();
up();
});
}
// Find regex
{
let jc = state.job_config.clone();
let up = debounced_preview.clone();
find_row.connect_changed(move |row| {
let text = row.text().to_string();
if !text.is_empty() {
if regex::Regex::new(&text).is_err() {
row.add_css_class("error");
} else {
row.remove_css_class("error");
}
} else {
row.remove_css_class("error");
}
jc.borrow_mut().rename_find = text;
up();
});
}
// Replace
{
let jc = state.job_config.clone();
let up = debounced_preview.clone();
replace_row.connect_changed(move |row| {
jc.borrow_mut().rename_replace = row.text().to_string();
up();
});
}
let page = adw::NavigationPage::builder()
.title("Rename")
.tag("step-rename")
.child(&outer)
.build();
// Refresh preview when navigating to this page
{
let up = update_preview.clone();
page.connect_map(move |_| {
up();
});
}
page
}