diff --git a/src/app/mod.rs b/src/app/mod.rs index 872ad7d..35c69fc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -470,6 +470,19 @@ pub struct App { /// Set alongside `show_add_story_bible_item` so the name field takes /// focus on the frame the dialog opens. add_story_bible_focus: bool, + /// Whether the "rename/move Story Bible item" dialog is open. + show_rename_story_bible: bool, + /// The dialog's path field, pre-filled with the current path (no `.md`) + /// when opened, as both a rename and a move, same as the manuscript + /// panel's `rename_input`. + rename_story_bible_input: String, + /// The Story Bible item being renamed/moved: its full path (with `.md`), + /// set by the tree's right-click menu. There's no `selected` index to + /// recover this from the way the manuscript panel does. + rename_story_bible_target: Option, + /// Set alongside `show_rename_story_bible` so the path field takes focus + /// on the frame the dialog opens. + rename_story_bible_focus: bool, /// List of auto-snapshots for rollback. snapshots: Vec, /// Word count at the last auto-snapshot. @@ -612,6 +625,10 @@ impl App { add_story_bible_type: String::new(), add_story_bible_name: String::new(), add_story_bible_focus: false, + show_rename_story_bible: false, + rename_story_bible_input: String::new(), + rename_story_bible_target: None, + rename_story_bible_focus: false, snapshots: Vec::new(), last_snapshot_word_count: 0, show_snapshots: false, @@ -771,6 +788,10 @@ impl eframe::App for App { self.add_story_bible_item_window(ctx); } + if self.show_rename_story_bible { + self.rename_story_bible_item_window(ctx); + } + if self.show_snapshots { self.snapshots_window(ctx); } diff --git a/src/app/story_bible_editor.rs b/src/app/story_bible_editor.rs index ce31994..ed5f7b0 100644 --- a/src/app/story_bible_editor.rs +++ b/src/app/story_bible_editor.rs @@ -161,14 +161,16 @@ impl App { /// Render the Story Bible tree recursively. `add_requested` collects the /// directory path a right-click menu's "Add … entry…" was clicked for, - /// so the caller can open the add dialog once rendering is done rather - /// than mutating `self` from inside this `&self` walk. + /// and `rename_requested` the file path a "Rename / Move…" was clicked + /// for, so the caller can open the relevant dialog once rendering is + /// done rather than mutating `self` from inside this `&self` walk. fn render_bible_tree( &self, ui: &mut egui::Ui, nodes: &[BibleTreeNode], selected_path: &Option, add_requested: &mut Option, + rename_requested: &mut Option, ) -> Option { let mut clicked_path: Option = None; @@ -180,9 +182,13 @@ impl App { let header = egui::CollapsingHeader::new(name) .default_open(true) .show(ui, |ui| { - if let Some(child_clicked) = - self.render_bible_tree(ui, children, selected_path, add_requested) - { + if let Some(child_clicked) = self.render_bible_tree( + ui, + children, + selected_path, + add_requested, + rename_requested, + ) { clicked_path = Some(child_clicked); } }); @@ -198,12 +204,16 @@ impl App { BibleTreeNode::File { name, path } => { let is_selected = selected_path.as_ref().map_or(false, |p| p == path); - if ui - .selectable_label(is_selected, name) - .clicked() - { + let response = ui.selectable_label(is_selected, name); + if response.clicked() { clicked_path = Some(path.clone()); } + response.context_menu(|ui| { + if ui.button("📝 Rename / Move…").clicked() { + *rename_requested = Some(path.clone()); + ui.close_menu(); + } + }); } } } @@ -260,6 +270,9 @@ impl App { // Directory a right-click menu's "Add … entry…" was clicked for, if // any — applied after the tree finishes rendering, same reason. let mut add_requested: Option = None; + // File path a right-click menu's "Rename / Move…" was clicked for, + // if any — same deferred-handling reason as `add_requested`. + let mut rename_requested: Option = None; ui.horizontal(|ui| { // Left panel: Tree view (1/3 width). The width must be set on // this panel's own child `Ui`, not the shared horizontal one — @@ -293,7 +306,11 @@ impl App { } else { // Render tree if let Some(clicked_path) = self.render_bible_tree( - ui, tree, &self.story_bible_editor_path, &mut add_requested, + ui, + tree, + &self.story_bible_editor_path, + &mut add_requested, + &mut rename_requested, ) { // Load the clicked file self.load_story_bible_item(&clicked_path); @@ -370,6 +387,13 @@ impl App { self.show_add_story_bible_item = true; self.add_story_bible_focus = true; } + + if let Some(path) = rename_requested { + self.rename_story_bible_input = strip_md(&path).to_string(); + self.rename_story_bible_target = Some(path); + self.show_rename_story_bible = true; + self.rename_story_bible_focus = true; + } } /// Load a Story Bible item from the workspace. @@ -525,6 +549,107 @@ impl App { } } + /// The rename/move dialog opened from a file row's context menu. Renaming + /// and moving are the same operation here, same as the manuscript file + /// panel's `rename_window`: editing the leaf renames, editing the folder + /// part moves. + pub(super) fn rename_story_bible_item_window(&mut self, ctx: &egui::Context) { + if self.rename_story_bible_target.is_none() { + // The tree changed underneath us; nothing left to rename. + self.show_rename_story_bible = false; + return; + } + let mut open = self.show_rename_story_bible; + let mut submit = false; + let mut cancel = false; + egui::Window::new("Rename / Move Story Bible item") + .open(&mut open) + .resizable(false) + .collapsible(false) + .default_width(320.0) + .show(ctx, |ui| { + if let Some(ref current) = self.rename_story_bible_target { + ui.label(egui::RichText::new(current.as_str()).small().weak()); + } + ui.add_space(4.0); + let r = ui.add( + egui::TextEdit::singleline(&mut self.rename_story_bible_input) + .desired_width(f32::INFINITY) + .hint_text("name, or a path to move it"), + ); + if self.rename_story_bible_focus { + r.request_focus(); + self.rename_story_bible_focus = false; + } + submit |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.label( + egui::RichText::new( + "The path within the Story Bible folder — edit the folder part \ + to move it. The .md extension is added for you.", + ) + .small() + .weak(), + ); + ui.separator(); + ui.horizontal(|ui| { + submit |= ui.button("Rename").clicked(); + cancel |= ui.button("Cancel").clicked(); + }); + }); + if cancel || !open { + self.show_rename_story_bible = false; + return; + } + if submit { + if self.rename_selected_story_bible_item() { + self.show_rename_story_bible = false; + } else { + self.rename_story_bible_focus = true; + } + } + } + + /// Move/rename the Story Bible item named by `rename_story_bible_target` + /// to `rename_story_bible_input`. Returns `false` (leaving the dialog + /// open) when the input isn't usable or the destination is taken. + fn rename_selected_story_bible_item(&mut self) -> bool { + let Some(old_path) = self.rename_story_bible_target.clone() else { + return false; + }; + let Some(new_path) = sanitize_rel_path(&self.rename_story_bible_input) else { + self.status = "Enter a new name".to_string(); + return false; + }; + if new_path == old_path { + // Already what was asked for. + self.rename_story_bible_target = None; + return true; + } + let Some(base_dir) = self.find_story_bible_directory() else { + self.status = "Story Bible directory not found".to_string(); + return false; + }; + if base_dir.join(&new_path).exists() { + self.status = format!("{new_path} already exists"); + return false; + } + match workspace::move_manuscript_file(&base_dir, &old_path, &new_path) { + Ok(()) => { + if self.story_bible_editor_path.as_deref() == Some(old_path.as_str()) { + self.story_bible_editor_path = Some(new_path.clone()); + } + self.build_story_bible_index(); + self.status = format!("Renamed to {new_path}"); + self.rename_story_bible_target = None; + true + } + Err(e) => { + self.status = format!("Rename failed: {e}"); + false + } + } + } + /// Open the Story Bible editor. pub(super) fn open_story_bible_editor(&mut self) { self.show_story_bible_editor = true;