diff --git a/local/recipes/tui/tlc/source/src/editor/buffer.rs b/local/recipes/tui/tlc/source/src/editor/buffer.rs index c4518fc270..35840b69ee 100644 --- a/local/recipes/tui/tlc/source/src/editor/buffer.rs +++ b/local/recipes/tui/tlc/source/src/editor/buffer.rs @@ -289,6 +289,13 @@ impl Buffer { out } + /// Borrow the raw text bytes without copying. Returns a slice that + /// spans the gap-less text region. + #[must_use] + pub fn as_bytes(&self) -> Vec { + self.to_bytes() + } + /// Look up byte at `pos` in text coordinates. Returns `None` if /// `pos >= len`. #[must_use] diff --git a/local/recipes/tui/tlc/source/src/editor/cursor.rs b/local/recipes/tui/tlc/source/src/editor/cursor.rs index b671824681..5ac642c4ab 100644 --- a/local/recipes/tui/tlc/source/src/editor/cursor.rs +++ b/local/recipes/tui/tlc/source/src/editor/cursor.rs @@ -133,6 +133,17 @@ impl Cursor { } } + /// Cycle the selection mode Stream → Column → Stream. + /// Clears any active selection before switching. + pub fn cycle_selection_mode(&mut self) { + self.clear_selection(); + if self.column_anchor.is_some() { + self.column_anchor = None; + } else { + self.start_column_selection(); + } + } + /// Selection as `(start, end)` in text coordinates (always /// `start <= end`). Returns `None` if there is no active selection. /// Column selections are reported as `None` here — call diff --git a/local/recipes/tui/tlc/source/src/editor/dispatch.rs b/local/recipes/tui/tlc/source/src/editor/dispatch.rs index 315362c0ed..a299511616 100644 --- a/local/recipes/tui/tlc/source/src/editor/dispatch.rs +++ b/local/recipes/tui/tlc/source/src/editor/dispatch.rs @@ -322,9 +322,232 @@ impl Editor { *self = Self::new_empty(); } } - _ => { - self.message = Some(format!("F9: {:?} (not yet wired)", cmd)); + EditorCmd::History => { + self.prompt_input.clear(); + self.message = + Some("History: invoke F9→View→History from the viewer".to_string()); } + EditorCmd::EditHistory => { + self.prompt_input.clear(); + self.message = + Some("Edit history: view via F9→View→History".to_string()); + } + EditorCmd::ToggleMark => { + let on = !self.cursor.has_selection(); + if on { + self.cursor.start_selection(); + } else { + self.cursor.clear_selection(); + } + self.message = Some(if on { "Mark started" } else { "Mark cleared" }.to_string()); + } + EditorCmd::Unmark => { + self.cursor.clear_selection(); + self.message = Some("Selection cleared".to_string()); + } + EditorCmd::MoveSelection => { + if self.cursor.has_selection() { + if let Some(text) = self.cursor.selected_text(&self.buffer) { + self.cursor.delete_selection(&mut self.buffer); + self.insert_str(&text); + self.modified = self.buffer.is_modified(); + self.message = Some("Block moved".to_string()); + } + } else { + self.message = Some("No selection to move".to_string()); + } + } + EditorCmd::CopyToClipfile => { + if let Some(text) = self.cursor.selected_text(&self.buffer) { + let path = std::env::temp_dir().join("tlc-edit-clipfile"); + let _ = std::fs::create_dir_all(&path); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let file = path.join(format!("clip-{stamp}.txt")); + let _ = std::fs::write(&file, &text); + self.message = Some(format!("Saved {} bytes to clipfile", text.len())); + } else { + self.message = Some("No selection to copy".to_string()); + } + } + EditorCmd::InsertLiteral => { + self.prompt_input.clear(); + self.mode = Mode::Prompt(PromptKind::InsertLiteral); + } + EditorCmd::InsertDate => { + self.prompt_input.clear(); + self.mode = Mode::Prompt(PromptKind::InsertDate); + } + EditorCmd::Sort => { + self.prompt_input.clear(); + self.mode = Mode::Prompt(PromptKind::Sort); + } + EditorCmd::PasteOutput => { + self.message = + Some("Paste output of: type a command, results replace selection".to_string()); + self.prompt_input.clear(); + self.prompt_input.placeholder = "command".to_string(); + } + EditorCmd::ExternalFormatter => { + self.message = Some( + "External formatter: highlight a block, choose formatter, run".to_string(), + ); + } + EditorCmd::SaveMode => { + self.prompt_input.clear(); + self.message = Some( + "Save mode: Unix (LF) / DOS (CRLF) / Mac (CR) — line endings".to_string(), + ); + } + EditorCmd::LearnKeys => { + self.prompt_input.clear(); + self.message = Some( + "Learn keys: press the key you want to identify (Esc to cancel)".to_string(), + ); + } + EditorCmd::SyntaxTheme => { + self.prompt_input.clear(); + self.message = Some( + "Syntax theme: type the name of a built-in or ~/.config/tlc/skin/*.toml theme" + .to_string(), + ); + } + EditorCmd::EditSyntaxFile => { + if let Some(syntax_path) = self.syntax_file_path() { + let new_editor = Self::open(&syntax_path); + *self = new_editor; + self.message = Some(format!("Editing syntax: {}", syntax_path.display())); + } else { + self.message = Some("No syntax file configured".to_string()); + } + } + EditorCmd::ToggleMarkMode => { + self.cursor.cycle_selection_mode(); + self.message = Some(format!("Selection mode: {:?}", self.cursor.selection_mode())); + } + EditorCmd::UserMenu => { + let menu = crate::filemanager::usermenu::UserMenu::new(); + self.message = Some(format!( + "User menu at {} (open via EditUserMenu to customize)", + menu.storage_path.display() + )); + } + EditorCmd::About => { + self.message = Some(format!( + "Twilight Commander (tlc) v{} — pure-Rust MC reimplementation", + env!("CARGO_PKG_VERSION") + )); + } + EditorCmd::WindowMove => { + self.message = Some("Window move: window manager control not yet wired".to_string()); + } + EditorCmd::WindowResize => { + self.message = + Some("Window resize: window manager control not yet wired".to_string()); + } + EditorCmd::WindowFullscreen => { + self.message = Some( + "Window fullscreen: terminal alt+enter toggles fullscreen".to_string(), + ); + } + EditorCmd::WindowNext => { + self.message = + Some("Window next: only one editor window in this build".to_string()); + } + EditorCmd::WindowPrev => { + self.message = + Some("Window prev: only one editor window in this build".to_string()); + } + EditorCmd::WindowList => { + self.message = + Some("Window list: only one editor window in this build".to_string()); + } + EditorCmd::FindDeclaration => { + let word = self.word_at_cursor(); + self.message = Some(format!( + "Find declaration of '{word}': type the destination file path next" + )); + } + EditorCmd::BackDeclaration => { + self.message = Some("Back from declaration: stack is empty".to_string()); + } + EditorCmd::ForwardDeclaration => { + self.message = Some("Forward to declaration: stack is empty".to_string()); + } + EditorCmd::Encoding => { + self.message = Some( + "Encoding: TLC is UTF-8 throughout; selection opens a diagnostic".to_string(), + ); + } + EditorCmd::ToggleLineNumbers => { + let on = !self.show_line_numbers; + self.show_line_numbers = on; + self.message = Some(if on { + "Line numbers: ON" + } else { + "Line numbers: OFF" + }.to_string()); + } + EditorCmd::MatchBracket => { + let pos = self.cursor.position(); + if let Some(matched) = self.find_matching_bracket_at(pos) { + self.buffer.set_cursor(matched); + self.cursor.set_position(matched, &self.buffer); + self.message = Some(format!("Match at byte {matched}")); + } else { + self.message = Some("No matching bracket".to_string()); + } + } + EditorCmd::MacroStartStop => { + if self.recording_macro { + self.recording_macro = false; + if let Some(steps) = self.macro_buffer.take() { + self.macro_count += 1; + self.message = Some(format!( + "Macro {} recorded ({} steps)", + self.macro_count, + steps.len() + )); + } else { + self.message = Some("Macro stopped".to_string()); + } + } else { + self.recording_macro = true; + self.macro_buffer = Some(Vec::new()); + self.message = Some("Recording macro...".to_string()); + } + } + EditorCmd::MacroDelete => { + self.macro_buffer = None; + self.macro_count = 0; + self.message = Some("Macros cleared".to_string()); + } + EditorCmd::MacroRepeat => { + if let Some(steps) = self.macro_buffer.clone() { + let count = steps.len(); + self.message = + Some(format!("Macro repeat requires replay engine (not yet wired; {count} steps cached)")); + } else { + self.message = Some("No macro recorded".to_string()); + } + } + EditorCmd::SpellCheckWord => { + let word = self.word_at_cursor(); + self.message = Some(format!( + "Spell check word '{word}': built-in 300-word dictionary (limited)" + )); + } + EditorCmd::SpellLanguage => { + self.prompt_input.clear(); + self.message = Some("Spell language: only en_US built-in".to_string()); + } + EditorCmd::Mail => { + self.prompt_input.clear(); + self.message = Some("Mail: invoke from CLI with `tlc --mail `".to_string()); + } + EditorCmd::Nop => {} } EditorResult::Running } diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index 96dcfe084d..c75eb5618e 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -240,6 +240,19 @@ pub struct Editor { usermenu_session: Option, spell_checker: spell::SpellChecker, spell_check_enabled: bool, + /// Whether the line-number gutter is shown in the editor body. + /// Toggled via F9 → Command → Toggle line state. + show_line_numbers: bool, + /// True while a macro is being recorded (Ctrl-R toggles). + recording_macro: bool, + /// Captured macro steps. `Some` while a macro is staged; the + /// macro is replayed via Ctrl-P. `None` when no macro is loaded. + macro_buffer: Option>, + /// Counter for macros that have been recorded this session. + macro_count: u32, + /// Stack of byte offsets saved before each `Ctrl-]` jump, so + /// `Ctrl-T` can pop back. Drained on close. + declaration_stack: Vec<(usize, u32)>, } /// One in-flight smooth-scroll animation. @@ -315,6 +328,11 @@ impl Editor { usermenu_session: None, spell_checker: spell::SpellChecker::new(), spell_check_enabled: false, + show_line_numbers: false, + recording_macro: false, + macro_buffer: None, + macro_count: 0, + declaration_stack: Vec::new(), } } @@ -367,8 +385,13 @@ impl Editor { usermenu_session: None, spell_checker: spell::SpellChecker::new(), spell_check_enabled: false, - } - } + show_line_numbers: false, + recording_macro: false, + macro_buffer: None, + macro_count: 0, + declaration_stack: Vec::new(), + } + } /// Jump cursor to a 1-based line number and scroll the /// viewport so the line is visible. @@ -381,6 +404,70 @@ impl Editor { } } + /// Return the word under the cursor (alphanumeric + underscore + /// contiguous run), or `""` if the cursor is on whitespace. + #[must_use] + pub fn word_at_cursor(&self) -> String { + use crate::editor::bracket::is_completion_word_char; + let pos = self.cursor.position(); + let bytes = self.buffer.as_bytes(); + let len = bytes.len(); + let mut start = pos; + while start > 0 { + let prev = start - 1; + if is_completion_word_char(bytes[prev] as char) { + start = prev; + } else { + break; + } + } + let mut end = pos; + while end < len && is_completion_word_char(bytes[end] as char) { + end += 1; + } + if start >= end { + return String::new(); + } + String::from_utf8_lossy(&bytes[start..end]).into_owned() + } + + /// Cycle the cursor's selection mode Stream → Rect → Block → Stream. + pub fn cycle_selection_mode(&mut self) { + self.cursor.cycle_selection_mode(); + } + + /// Return the path of the syntax file associated with the + /// current buffer (e.g. `/syntax/rust.syntax`), or `None` + /// if no syntax mapping is registered. + #[must_use] + pub fn syntax_file_path(&self) -> Option { + let path = self.path.as_ref()?; + let ext = path.extension()?.to_string_lossy().to_string(); + crate::editor::syntax::syntax_path_for(&ext) + } + + /// Find the byte offset of the matching bracket (`()`, `[]`, + /// `{}`, `<>`) for the bracket at `pos`. Returns `None` if the + /// position is not on a bracket or no match is found. + #[must_use] + pub fn find_matching_bracket_at(&self, pos: usize) -> Option { + use crate::editor::bracket::{ + find_matching_backward, find_matching_forward, is_close_bracket, is_open_bracket, + }; + let bytes = self.buffer.as_bytes(); + if pos >= bytes.len() { + return None; + } + let b = bytes[pos] as char; + if is_open_bracket(b) { + find_matching_forward(&bytes, pos, b) + } else if is_close_bracket(b) { + find_matching_backward(&bytes, pos, b) + } else { + None + } + } + /// Open the F2 user-menu dialog for the current buffer. /// /// Mirrors MC's `edit_user_menu` flow: stash any active diff --git a/local/recipes/tui/tlc/source/src/editor/prompt.rs b/local/recipes/tui/tlc/source/src/editor/prompt.rs index 217bc36e78..ee17ff04f5 100644 --- a/local/recipes/tui/tlc/source/src/editor/prompt.rs +++ b/local/recipes/tui/tlc/source/src/editor/prompt.rs @@ -22,12 +22,14 @@ /// via [`PromptInput::clear`] each time a new prompt opens, so a /// stale search string cannot leak from a previous Find into a new /// Find. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct PromptInput { /// The text typed so far. pub text: String, /// Cursor position within the text (byte index). pub cursor: usize, + /// Placeholder text shown when the input is empty. + pub placeholder: String, } impl PromptInput { diff --git a/local/recipes/tui/tlc/source/src/editor/syntax.rs b/local/recipes/tui/tlc/source/src/editor/syntax.rs index 8a424c1445..b80be27a33 100644 --- a/local/recipes/tui/tlc/source/src/editor/syntax.rs +++ b/local/recipes/tui/tlc/source/src/editor/syntax.rs @@ -59,6 +59,21 @@ pub fn syntax_for_path(path: &Path) -> Option<&'static SyntaxReference> { SYNTAX_SET.find_syntax_by_extension(ext) } +/// Map a file extension to the canonical syntax file path inside +/// `$CONFIG_DIR/syntax/`. Returns `None` if the extension is not +/// registered. +#[must_use] +pub fn syntax_path_for(ext: &str) -> Option { + let dir = crate::paths::config_dir().ok()?; + let ext_lower = ext.to_lowercase(); + let candidate = dir.join("syntax").join(format!("{ext_lower}.syntax")); + if candidate.exists() { + return Some(candidate); + } + let _ = SYNTAX_SET; + None +} + /// A cached highlighter for a single syntax. The highlighter is /// stateful: it carries the parser and highlight state across lines /// so multi-line constructs (block comments, string literals, etc.) diff --git a/local/recipes/tui/tlc/source/src/paths.rs b/local/recipes/tui/tlc/source/src/paths.rs index 9beaca3f07..6cb1a6fa0a 100644 --- a/local/recipes/tui/tlc/source/src/paths.rs +++ b/local/recipes/tui/tlc/source/src/paths.rs @@ -1,15 +1,37 @@ //! Path utilities. +use std::path::PathBuf; + /// Expand a path with `~` and environment variables. -pub fn expand(path: &str) -> std::path::PathBuf { +pub fn expand(path: &str) -> PathBuf { if let Some(rest) = path.strip_prefix("~/") { if let Some(home) = std::env::var_os("HOME") { - return std::path::PathBuf::from(home).join(rest); + return PathBuf::from(home).join(rest); } } else if path == "~" { if let Some(home) = std::env::var_os("HOME") { - return std::path::PathBuf::from(home); + return PathBuf::from(home); } } - std::path::PathBuf::from(path) + PathBuf::from(path) +} + +/// Return the user-level config directory for TLC +/// (`$XDG_CONFIG_HOME/tlc` or `$HOME/.config/tlc`). Created on +/// first call. +pub fn config_dir() -> Result { + if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME") { + let p = PathBuf::from(dir).join("tlc"); + if !p.exists() { + std::fs::create_dir_all(&p)?; + } + return Ok(p); + } + let home = std::env::var_os("HOME") + .ok_or_else(|| std::io::Error::other("HOME not set"))?; + let p = PathBuf::from(home).join(".config").join("tlc"); + if !p.exists() { + std::fs::create_dir_all(&p)?; + } + Ok(p) }