diff --git a/Cargo.lock b/Cargo.lock index 9c87beb..1d120b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,6 +1011,7 @@ dependencies = [ name = "frext" version = "0.1.0" dependencies = [ + "arboard", "directories", "eframe", "egui", diff --git a/Cargo.toml b/Cargo.toml index ae48a65..6e38ae1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ keywords = ["editor", "text-editor", "gui", "egui"] categories = ["text-editors", "gui"] [workspace.dependencies] +arboard = { version = "3.6.1", default-features = false } directories = "6.0.0" eframe = { version = "0.35.0", default-features = false, features = ["default_fonts", "glow", "persistence", "wayland", "x11"] } egui = { version = "0.35.0", default-features = false } diff --git a/frext/Cargo.toml b/frext/Cargo.toml index 94f1895..f660af2 100644 --- a/frext/Cargo.toml +++ b/frext/Cargo.toml @@ -13,6 +13,7 @@ keywords.workspace = true categories.workspace = true [dependencies] +arboard = { workspace = true } directories = { workspace = true } eframe = { workspace = true } egui = { workspace = true } diff --git a/frext/src/app.rs b/frext/src/app.rs index a100cb7..ae17e05 100644 --- a/frext/src/app.rs +++ b/frext/src/app.rs @@ -444,6 +444,120 @@ impl FrextApp { } } + /// Apply a deferred editor context-menu action after the editor borrow has + /// ended. + /// + /// Clipboard text edits (cut/copy/paste) act on the captured char + /// `selection` over the active buffer, then store the resulting caret back + /// into the live `TextEdit` state under `editor_id` so the cursor follows + /// the edit. Find/replace and save reuse the existing deferred actions. + /// + /// Returns `true` when the buffer changed (so the caller can persist and + /// refresh matches), matching how the editor's own `changed()` is handled. + fn apply_editor_action( + &mut self, + ctx: &egui::Context, + action: EditorAction, + editor_id: Option, + selection: Option>, + ) -> bool { + let selection = selection.unwrap_or(0..0); + + match action { + EditorAction::None => false, + EditorAction::Copy => { + if let Some(tab) = self.tabs.get(self.active) { + let text = + crate::edit_ops::selected_text(&tab.text, selection.start, selection.end); + if !text.is_empty() { + crate::clipboard::write_text(text); + } + } + false + } + EditorAction::Cut => { + let Some(tab) = self.tabs.get_mut(self.active) else { + return false; + }; + let text = + crate::edit_ops::selected_text(&tab.text, selection.start, selection.end); + if text.is_empty() { + return false; + } + crate::clipboard::write_text(text); + let caret = + crate::edit_ops::delete_range(&mut tab.text, selection.start, selection.end); + Self::store_caret(ctx, editor_id, caret); + true + } + EditorAction::Paste => { + let Some(clip) = crate::clipboard::read_text() else { + return false; + }; + if clip.is_empty() { + return false; + } + let Some(tab) = self.tabs.get_mut(self.active) else { + return false; + }; + let caret = crate::edit_ops::replace_range( + &mut tab.text, + selection.start, + selection.end, + &clip, + ); + Self::store_caret(ctx, editor_id, caret); + true + } + EditorAction::SelectAll => { + if let (Some(tab), Some(id)) = (self.tabs.get(self.active), editor_id) { + let len = tab.text.chars().count(); + if let Some(mut state) = egui::text_edit::TextEditState::load(ctx, id) { + state + .cursor + .set_char_range(Some(egui::text::CCursorRange::two( + egui::text::CCursor::new(0), + egui::text::CCursor::new(len), + ))); + state.store(ctx, id); + } + ctx.memory_mut(|m| m.request_focus(id)); + } + false + } + EditorAction::Find => { + self.open_search(false); + false + } + EditorAction::FindReplace => { + self.open_search(true); + false + } + EditorAction::Save => { + self.save_active(); + false + } + } + } + + /// Store `caret` (a character index) as a collapsed selection into the + /// `TextEdit` state under `editor_id`, and refocus the editor, so the + /// cursor tracks a programmatic edit. A no-op when there is no id or no + /// stored state yet. + fn store_caret(ctx: &egui::Context, editor_id: Option, caret: usize) { + let Some(id) = editor_id else { + return; + }; + if let Some(mut state) = egui::text_edit::TextEditState::load(ctx, id) { + let cursor = egui::text::CCursor::new(caret); + state + .cursor + .set_char_range(Some(egui::text::CCursorRange::one(cursor))); + state.store(ctx, id); + } + ctx.memory_mut(|m| m.request_focus(id)); + } + /// Apply a deferred sidebar context-menu action after the tree borrow has /// ended. The mutating actions (rename, create, trash) open a modal or /// touch the filesystem and reconcile any affected open tabs. @@ -1226,6 +1340,52 @@ impl FrextApp { } } + /// The right-click menu for the editor surface. `has_selection` greys out + /// Cut/Copy when there is nothing selected. Choices are recorded in + /// `editor_action` and applied after the editor borrow ends. + fn editor_context_menu( + ui: &mut egui::Ui, + has_selection: bool, + editor_action: &mut EditorAction, + ) { + if ui + .add_enabled(has_selection, egui::Button::new("Cut")) + .clicked() + { + *editor_action = EditorAction::Cut; + ui.close(); + } + if ui + .add_enabled(has_selection, egui::Button::new("Copy")) + .clicked() + { + *editor_action = EditorAction::Copy; + ui.close(); + } + if ui.button("Paste").clicked() { + *editor_action = EditorAction::Paste; + ui.close(); + } + if ui.button("Select all").clicked() { + *editor_action = EditorAction::SelectAll; + ui.close(); + } + ui.separator(); + if ui.button("Find\u{2026}").clicked() { + *editor_action = EditorAction::Find; + ui.close(); + } + if ui.button("Find and replace\u{2026}").clicked() { + *editor_action = EditorAction::FindReplace; + ui.close(); + } + ui.separator(); + if ui.button("Save").clicked() { + *editor_action = EditorAction::Save; + ui.close(); + } + } + /// Draw the file-type icon for `stem` inline, sized to the current text /// row. A stem with no embedded artwork (which should not occur for stems /// produced by [`crate::file_icon`]) simply renders nothing, leaving the @@ -1461,6 +1621,12 @@ impl eframe::App for FrextApp { let scroll_to = self.search.scroll_to.take(); let mut new_selection: Option> = None; let mut changed = false; + // Editor context-menu plumbing: the chosen action plus what applying + // it needs (the editor's id and the live char selection), captured + // inside the closure and applied after the borrow ends. + let mut editor_action = EditorAction::None; + let mut editor_char_selection: Option> = None; + let mut editor_id_out: Option = None; egui::CentralPanel::default().show(ui, |ui| { if let Some(tab) = self.tabs.get_mut(self.active) { @@ -1526,12 +1692,32 @@ impl eframe::App for FrextApp { output.response.scroll_to_me(Some(egui::Align::Center)); } - // Capture the live selection (char -> byte) so a - // search-within-selection can scope to it. - new_selection = output.cursor_range.map(|range| { + // Capture the live selection as char and byte + // ranges: the byte range scopes a + // search-within-selection, the char range drives + // the cut/copy/paste menu (egui cursors are + // char-based). + if let Some(range) = output.cursor_range { let lo = range.primary.index.0.min(range.secondary.index.0); let hi = range.primary.index.0.max(range.secondary.index.0); - byte_index(&tab.text, lo)..byte_index(&tab.text, hi) + editor_char_selection = Some(lo..hi); + new_selection = + Some(byte_index(&tab.text, lo)..byte_index(&tab.text, hi)); + } else { + editor_char_selection = None; + new_selection = None; + } + editor_id_out = Some(editor_id); + + // Right-click menu for the editing surface. egui's + // `TextEdit` ships no context menu of its own, so + // this adds one; choices are deferred like the menu + // bar's and applied after the borrow ends. + let has_selection = editor_char_selection + .as_ref() + .is_some_and(|r| r.start != r.end); + output.response.context_menu(|ui| { + Self::editor_context_menu(ui, has_selection, &mut editor_action); }); output.response @@ -1544,6 +1730,16 @@ impl eframe::App for FrextApp { } }); + // Apply a deferred editor context-menu action (cut/copy/paste/select + // all/find/save). A clipboard edit that mutates the buffer is folded + // into `changed` so it persists and refreshes matches just like a + // typed edit. + if !matches!(editor_action, EditorAction::None) { + let edited = + self.apply_editor_action(&ctx, editor_action, editor_id_out, editor_char_selection); + changed = changed || edited; + } + if changed { self.persist_active_swap(); // The buffer was edited this frame: refresh matches and the count @@ -1692,6 +1888,26 @@ enum CloseChoice { Cancel, } +/// A deferred editor context-menu action, collected while the editor is +/// borrowed and applied after the borrow ends (mirroring [`MenuAction`]). +enum EditorAction { + None, + /// Copy the selection to the clipboard, then delete it from the buffer. + Cut, + /// Copy the selection to the clipboard. + Copy, + /// Replace the selection (or insert at the caret) with the clipboard text. + Paste, + /// Select the entire buffer. + SelectAll, + /// Open the find bar. + Find, + /// Open the find-and-replace bar. + FindReplace, + /// Save the active buffer. + Save, +} + /// A deferred sidebar context-menu action, collected while the file tree is /// borrowed and applied after the borrow ends (mirroring [`MenuAction`]). enum TreeAction { @@ -2332,4 +2548,67 @@ mod tests { fs::remove_dir_all(&dir).unwrap(); } + + #[test] + fn cut_deletes_the_selection_from_the_buffer() { + let dir = temp_dir("editor-cut"); + let mut app = app_in(&dir); + app.tabs[app.active].text = "hello world".to_owned(); + + // Cut "hello " (chars 0..6). The clipboard write may be unavailable in + // a headless test environment, but the buffer deletion is independent + // of that and must happen. + let ctx = egui::Context::default(); + let changed = app.apply_editor_action(&ctx, EditorAction::Cut, None, Some(0..6)); + + assert!(changed); + assert_eq!(app.tabs[app.active].text, "world"); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn copy_does_not_mutate_the_buffer() { + let dir = temp_dir("editor-copy"); + let mut app = app_in(&dir); + app.tabs[app.active].text = "keep me".to_owned(); + + let ctx = egui::Context::default(); + let changed = app.apply_editor_action(&ctx, EditorAction::Copy, None, Some(0..4)); + + assert!(!changed); + assert_eq!(app.tabs[app.active].text, "keep me"); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn find_action_opens_the_search_bar() { + let dir = temp_dir("editor-find"); + let mut app = app_in(&dir); + assert!(!app.search.open); + + let ctx = egui::Context::default(); + let changed = app.apply_editor_action(&ctx, EditorAction::Find, None, None); + + assert!(!changed); + assert!(app.search.open); + assert!(!app.search.replace_open); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn find_replace_action_opens_the_replace_row() { + let dir = temp_dir("editor-replace"); + let mut app = app_in(&dir); + + let ctx = egui::Context::default(); + app.apply_editor_action(&ctx, EditorAction::FindReplace, None, None); + + assert!(app.search.open); + assert!(app.search.replace_open); + + fs::remove_dir_all(&dir).unwrap(); + } } diff --git a/frext/src/clipboard.rs b/frext/src/clipboard.rs new file mode 100644 index 0000000..50117e9 --- /dev/null +++ b/frext/src/clipboard.rs @@ -0,0 +1,46 @@ +//! A thin wrapper over the OS clipboard for the editor's cut/copy/paste menu. +//! +//! egui can *write* the clipboard (via `Context::copy_text`) but exposes no +//! imperative *read*, which a menu-driven "Paste" needs. This module uses +//! [`arboard`] for both directions so the editor clipboard actions are +//! deterministic and testable, rather than depending on egui's event timing. +//! +//! Every operation is fallible and non-panicking: a clipboard that cannot be +//! opened (headless CI, no display server) yields `None`/`false` and logs, +//! leaving the editor usable. + +/// Read the current clipboard text, or `None` when the clipboard is +/// unavailable or holds no text. +#[must_use] +pub fn read_text() -> Option { + match arboard::Clipboard::new() { + Ok(mut clipboard) => match clipboard.get_text() { + Ok(text) => Some(text), + Err(err) => { + log::debug!("clipboard read failed: {err}"); + None + } + }, + Err(err) => { + log::debug!("clipboard unavailable: {err}"); + None + } + } +} + +/// Write `text` to the clipboard. Returns `true` on success. +pub fn write_text(text: String) -> bool { + match arboard::Clipboard::new() { + Ok(mut clipboard) => match clipboard.set_text(text) { + Ok(()) => true, + Err(err) => { + log::debug!("clipboard write failed: {err}"); + false + } + }, + Err(err) => { + log::debug!("clipboard unavailable: {err}"); + false + } + } +} diff --git a/frext/src/edit_ops.rs b/frext/src/edit_ops.rs new file mode 100644 index 0000000..4ec1670 --- /dev/null +++ b/frext/src/edit_ops.rs @@ -0,0 +1,134 @@ +//! Pure text-buffer edits backing the editor's cut/paste menu actions. +//! +//! egui text cursors are measured in characters, so these operate on a +//! character range `[start, end)` over `text` and return the new caret +//! position (also a character index). Keeping them free of egui and clipboard +//! I/O makes the splice logic straightforward to unit-test; the app layer +//! wires them to the live `TextEdit` state and the OS clipboard. + +/// The byte offset of character index `char_idx` within `text`, clamped to the +/// end of the string. Mirrors the cursor model egui uses (character indices). +fn byte_offset(text: &str, char_idx: usize) -> usize { + text.char_indices() + .nth(char_idx) + .map_or(text.len(), |(byte, _)| byte) +} + +/// Normalise a possibly-reversed selection to `(lo, hi)` character indices, +/// clamped to the buffer's character length. +fn normalize(text: &str, start: usize, end: usize) -> (usize, usize) { + let len = text.chars().count(); + let lo = start.min(end).min(len); + let hi = start.max(end).min(len); + (lo, hi) +} + +/// The substring covered by the character range `[start, end)`. +#[must_use] +pub fn selected_text(text: &str, start: usize, end: usize) -> String { + let (lo, hi) = normalize(text, start, end); + let lo_byte = byte_offset(text, lo); + let hi_byte = byte_offset(text, hi); + text.get(lo_byte..hi_byte).unwrap_or_default().to_owned() +} + +/// Delete the character range `[start, end)` from `text` in place. Returns the +/// new caret character index (the start of what was the selection). +/// +/// An empty selection (`start == end`) is a no-op and returns that position. +pub fn delete_range(text: &mut String, start: usize, end: usize) -> usize { + let (lo, hi) = normalize(text, start, end); + if lo == hi { + return lo; + } + let lo_byte = byte_offset(text, lo); + let hi_byte = byte_offset(text, hi); + text.replace_range(lo_byte..hi_byte, ""); + lo +} + +/// Replace the character range `[start, end)` of `text` with `insert`. Returns +/// the new caret character index (just past the inserted text). +/// +/// With an empty selection this inserts `insert` at the caret. +pub fn replace_range(text: &mut String, start: usize, end: usize, insert: &str) -> usize { + let (lo, hi) = normalize(text, start, end); + let lo_byte = byte_offset(text, lo); + let hi_byte = byte_offset(text, hi); + text.replace_range(lo_byte..hi_byte, insert); + lo + insert.chars().count() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selected_text_extracts_the_range() { + assert_eq!(selected_text("hello world", 0, 5), "hello"); + assert_eq!(selected_text("hello world", 6, 11), "world"); + // Reversed range is normalised. + assert_eq!(selected_text("hello world", 11, 6), "world"); + // Empty selection. + assert_eq!(selected_text("hello", 2, 2), ""); + } + + #[test] + fn selected_text_is_char_aware() { + // Multi-byte characters: indices are characters, not bytes. + let text = "café au lait"; + assert_eq!(selected_text(text, 0, 4), "café"); + assert_eq!(selected_text(text, 5, 7), "au"); + } + + #[test] + fn delete_range_removes_and_returns_caret() { + let mut text = "hello world".to_owned(); + let caret = delete_range(&mut text, 5, 11); + assert_eq!(text, "hello"); + assert_eq!(caret, 5); + } + + #[test] + fn delete_empty_range_is_a_noop() { + let mut text = "stable".to_owned(); + let caret = delete_range(&mut text, 3, 3); + assert_eq!(text, "stable"); + assert_eq!(caret, 3); + } + + #[test] + fn delete_range_is_char_aware() { + let mut text = "café crème".to_owned(); + // Delete "café " (chars 0..5), leaving "crème". + let caret = delete_range(&mut text, 0, 5); + assert_eq!(text, "crème"); + assert_eq!(caret, 0); + } + + #[test] + fn replace_range_inserts_over_selection() { + let mut text = "hello world".to_owned(); + let caret = replace_range(&mut text, 6, 11, "there"); + assert_eq!(text, "hello there"); + // Caret sits just past the inserted "there". + assert_eq!(caret, 11); + } + + #[test] + fn replace_empty_selection_inserts_at_caret() { + let mut text = "ac".to_owned(); + let caret = replace_range(&mut text, 1, 1, "b"); + assert_eq!(text, "abc"); + assert_eq!(caret, 2); + } + + #[test] + fn replace_range_is_char_aware() { + let mut text = "café".to_owned(); + // Replace the "é" (char index 3..4) with "e". + let caret = replace_range(&mut text, 3, 4, "e"); + assert_eq!(text, "cafe"); + assert_eq!(caret, 4); + } +} diff --git a/frext/src/lib.rs b/frext/src/lib.rs index 02f656e..dfd6d0b 100644 --- a/frext/src/lib.rs +++ b/frext/src/lib.rs @@ -5,6 +5,8 @@ //! backend. pub mod app; +pub mod clipboard; +pub mod edit_ops; pub mod error; pub mod file_icon; mod file_icon_bytes;