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.
869 lines
28 KiB
Rust
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(>k::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(>k::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(>k::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(>k::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(>k::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(>k::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
|
|
}
|