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> = 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 = Vec::with_capacity(total); let mut ext_counts: HashMap = 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 = 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>> = 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 }