diff --git a/docs/technical/ui/keyboard-shortcuts.md b/docs/technical/ui/keyboard-shortcuts.md index 64f18a8..61c4fe6 100644 --- a/docs/technical/ui/keyboard-shortcuts.md +++ b/docs/technical/ui/keyboard-shortcuts.md @@ -47,6 +47,11 @@ The shortcuts below show `Cmd/Ctrl` to indicate this cross-platform behavior. | **Cmd/Ctrl+F** | Find | Open find panel | | **Cmd/Ctrl+H** | Find & Replace | Open find/replace panel | | **Cmd/Ctrl+A** | Select All | Select all text | +| **Cmd/Ctrl+D** | Delete Line | Delete the current line (Raw mode only) | +| **Cmd/Ctrl+Shift+D** | Duplicate Line | Duplicate the current line or selection | +| **Alt/Option+Up** | Move Line Up | Move the current line up | +| **Alt/Option+Down** | Move Line Down | Move the current line down | +| **Ctrl+G** | Select Next Occurrence | Select the next occurrence of current word (raw Ctrl on all platforms) | ### View Operations @@ -82,7 +87,7 @@ The shortcuts below show `Cmd/Ctrl` to indicate this cross-platform behavior. | Shortcut | Action | Description | |----------|--------|-------------| -| **Cmd/Ctrl+G** | Go to Line | Jump to specific line number | +| **Cmd/Ctrl+Shift+G** | Go to Line | Jump to specific line number | | **F3** | Find Next | Jump to next search match | | **Shift+F3** | Find Previous | Jump to previous search match | @@ -94,6 +99,8 @@ egui provides built-in cross-platform support through `modifiers.command`: - On macOS: Maps to Command key - On Windows/Linux: Maps to Control key +**Raw Ctrl Mode:** Some shortcuts use the physical Ctrl key on all platforms (including macOS where it's normally unused for shortcuts). This is used for shortcuts like **Ctrl+G** (Select Next Occurrence) which work identically across platforms. This allows Cmd+D on macOS to be used for Delete Line while Ctrl+G handles Select Next Occurrence. + ```rust /// Get the display name for the primary modifier key. /// Returns "Cmd" on macOS, "Ctrl" on Windows/Linux. diff --git a/locales/de.yaml b/locales/de.yaml index f584a76..616da5e 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -461,6 +461,7 @@ shortcuts: # Edit undo: "" redo: "" + delete_line: "" duplicate_line: "" move_line_up: "" move_line_down: "" @@ -513,6 +514,11 @@ shortcuts: copy: "" cut: "" paste: "" + delete_line: "" + duplicate_line: "" + move_line_up: "" + move_line_down: "" + select_next_occurrence: "" # View shortcuts view: toggle_view: "" diff --git a/locales/en.yaml b/locales/en.yaml index edd40e6..763c557 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -461,6 +461,7 @@ shortcuts: # Edit undo: "Undo" redo: "Redo" + delete_line: "Delete Line" duplicate_line: "Duplicate Line" move_line_up: "Move Line Up" move_line_down: "Move Line Down" @@ -513,6 +514,11 @@ shortcuts: copy: "Copy" cut: "Cut" paste: "Paste" + delete_line: "Delete Line" + duplicate_line: "Duplicate Line" + move_line_up: "Move Line Up" + move_line_down: "Move Line Down" + select_next_occurrence: "Select Next Occurrence" # View shortcuts (for help panel) view: toggle_view: "Toggle Raw/Rendered" diff --git a/locales/ja.yaml b/locales/ja.yaml index 188e45b..979c829 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -461,6 +461,7 @@ shortcuts: # Edit undo: "" redo: "" + delete_line: "" duplicate_line: "" move_line_up: "" move_line_down: "" @@ -513,6 +514,11 @@ shortcuts: copy: "" cut: "" paste: "" + delete_line: "" + duplicate_line: "" + move_line_up: "" + move_line_down: "" + select_next_occurrence: "" # View shortcuts view: toggle_view: "" diff --git a/locales/zh_Hans.yaml b/locales/zh_Hans.yaml index 147371a..1dce67b 100644 --- a/locales/zh_Hans.yaml +++ b/locales/zh_Hans.yaml @@ -461,6 +461,7 @@ shortcuts: # Edit undo: "撤销" redo: "重做" + delete_line: "删除行" duplicate_line: "复制行" move_line_up: "上移行" move_line_down: "下移行" @@ -513,6 +514,11 @@ shortcuts: copy: "复制" cut: "剪切" paste: "粘贴" + delete_line: "删除行" + duplicate_line: "复制行" + move_line_up: "上移行" + move_line_down: "下移行" + select_next_occurrence: "选择下一个匹配项" # View shortcuts (for help panel) view: toggle_view: "切换原始/渲染状态" diff --git a/src/app.rs b/src/app.rs index 522fc35..ca0e909 100644 --- a/src/app.rs +++ b/src/app.rs @@ -127,6 +127,8 @@ enum KeyboardAction { GoToLine, /// Duplicate current line or selection (Ctrl+Shift+D) DuplicateLine, + /// Delete current line (Ctrl+D) + DeleteLine, /// Insert/Update Table of Contents (Ctrl+Shift+U) InsertToc, } @@ -1439,13 +1441,14 @@ impl FerriteApp { // View Mode cycle button (only if there's an active editor with renderable content) if has_editor && (current_file_type.is_markdown() || current_file_type.is_structured() || current_file_type.is_tabular()) { // Show current mode icon and cycle on click + let mod_key = modifier_symbol(); let (mode_icon, mode_tooltip) = match current_view_mode { - ViewMode::Raw => ("R", "Raw mode - Click to switch to Split (Ctrl+E)"), - ViewMode::Split => ("S", "Split mode - Click to switch to Rendered (Ctrl+E)"), - ViewMode::Rendered => ("V", "Rendered mode - Click to switch to Raw (Ctrl+E)"), + ViewMode::Raw => ("R", format!("Raw mode - Click to switch to Split ({}+E)", mod_key)), + ViewMode::Split => ("S", format!("Split mode - Click to switch to Rendered ({}+E)", mod_key)), + ViewMode::Rendered => ("V", format!("Rendered mode - Click to switch to Raw ({}+E)", mod_key)), }; - if TitleBarButton::show(ui, mode_icon, mode_tooltip, false, is_dark).clicked() { + if TitleBarButton::show(ui, mode_icon, &mode_tooltip, false, is_dark).clicked() { // Cycle through modes: Raw -> Split -> Rendered -> Raw // Markdown and tabular files support all three modes // Structured files (JSON/YAML/TOML) only support Raw <-> Rendered @@ -3331,8 +3334,10 @@ impl FerriteApp { debug!("Content modified in split rendered pane, recorded for undo"); } - // Update cursor position from rendered editor - tab.cursor_position = editor_output.cursor_position; + // Don't update cursor_position in Split mode - the raw editor (left pane) + // already maintains it via sync_cursor_from_primary(). Overwriting it here + // would break line operations (delete line, move line) when editing the raw pane. + // cursor_position is only needed for Rendered-only mode. // Update selection from focused element (for formatting toolbar) if let Some(focused) = editor_output.focused_element { @@ -5149,6 +5154,7 @@ impl FerriteApp { check_shortcut!(ShortcutCommand::TogglePipeline, KeyboardAction::TogglePipeline); // Edit - note: Undo/Redo handled separately, MoveLineUp/Down handled separately + check_shortcut!(ShortcutCommand::DeleteLine, KeyboardAction::DeleteLine); check_shortcut!(ShortcutCommand::DuplicateLine, KeyboardAction::DuplicateLine); check_shortcut!(ShortcutCommand::SelectNextOccurrence, KeyboardAction::SelectNextOccurrence); @@ -5322,6 +5328,9 @@ impl FerriteApp { KeyboardAction::DuplicateLine => { self.handle_duplicate_line(); } + KeyboardAction::DeleteLine => { + self.handle_delete_line(); + } KeyboardAction::InsertToc => { self.handle_insert_toc(); } @@ -6099,6 +6108,107 @@ impl FerriteApp { debug!("Move line: direction={}, line {} -> {}", direction, current_line_num, new_line_num); } + /// Handle deleting the current line. + /// + /// Operates in Raw or Split view mode (both have raw editor). Removes the current line entirely, + /// placing the cursor at the same column on the next line (or previous if at end). + fn handle_delete_line(&mut self) { + // Only operate in Raw or Split view mode (both have raw editor) + let view_mode = self.state.active_tab() + .map(|t| t.view_mode) + .unwrap_or(ViewMode::Raw); + + if view_mode == ViewMode::Rendered { + debug!("Delete line: skipping, Rendered mode has no raw editor"); + return; + } + + let Some(tab) = self.state.active_tab_mut() else { + return; + }; + + // Save state for undo + let old_content = tab.content.clone(); + let old_cursor = tab.cursors.primary().head; + + // Get cursor position - cursor_position gives (line, column) directly + let (current_line_num, cursor_col) = tab.cursor_position; + let total_lines = tab.content.matches('\n').count() + 1; + + // Can't delete if document is empty or has only one empty line + if tab.content.is_empty() { + debug!("Delete line: skipping, document is empty"); + return; + } + + // Split into lines for manipulation + let lines: Vec<&str> = tab.content.split('\n').collect(); + let mut new_lines: Vec<&str> = Vec::with_capacity(lines.len().saturating_sub(1)); + + // Remove the current line + for (i, line) in lines.iter().enumerate() { + if i != current_line_num { + new_lines.push(line); + } + } + + // Build new content + let new_content = if new_lines.is_empty() { + // If we deleted the last line, result is empty + String::new() + } else { + new_lines.join("\n") + }; + + // Calculate new cursor position + // Stay on same line number if possible, or move to previous line if we were on last line + let new_line_num = if current_line_num >= new_lines.len() { + new_lines.len().saturating_sub(1) + } else { + current_line_num + }; + + // Find byte offset of the new line position + let mut new_line_start = 0usize; + for (i, line) in new_lines.iter().enumerate() { + if i == new_line_num { + break; + } + new_line_start += line.len() + 1; // +1 for newline + } + + // Calculate new cursor byte position (line start + column, clamped to line length) + let new_line_len = new_lines.get(new_line_num).map(|l| l.len()).unwrap_or(0); + let new_cursor_byte = new_line_start + cursor_col.min(new_line_len); + + // Convert byte position to character position + let new_cursor_char = if new_content.is_empty() { + 0 + } else { + new_content[..new_cursor_byte.min(new_content.len())].chars().count() + }; + + debug!( + "Delete line: line={}, total_lines={}, new_line_num={}, new_cursor_char={}", + current_line_num, total_lines, new_line_num, new_cursor_char + ); + + // Apply changes + tab.content = new_content; + + // Use pending_cursor_restore to ensure the cursor position is applied + tab.pending_cursor_restore = Some(new_cursor_char); + + // Also update internal state for consistency + tab.cursors.set_single(crate::state::Selection::cursor(new_cursor_char)); + tab.sync_cursor_from_primary(); + + // Record for undo + tab.record_edit(old_content, old_cursor); + + debug!("Delete line: deleted line {} (total was {})", current_line_num, total_lines); + } + // ───────────────────────────────────────────────────────────────────────── // Export Handlers // ───────────────────────────────────────────────────────────────────────── diff --git a/src/config/settings.rs b/src/config/settings.rs index 67fae24..d415af0 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -27,32 +27,42 @@ pub struct KeyModifiers { /// Alt key (Option on macOS) #[serde(default)] pub alt: bool, + /// Raw Ctrl key (physical Ctrl on all platforms, including macOS) + /// This is used for shortcuts that need the actual Ctrl key, not Command on macOS + #[serde(default)] + pub raw_ctrl: bool, } impl KeyModifiers { /// Create modifiers with only Ctrl/Command pub const fn ctrl() -> Self { - Self { ctrl: true, shift: false, alt: false } + Self { ctrl: true, shift: false, alt: false, raw_ctrl: false } } /// Create modifiers with Ctrl+Shift pub const fn ctrl_shift() -> Self { - Self { ctrl: true, shift: true, alt: false } + Self { ctrl: true, shift: true, alt: false, raw_ctrl: false } } /// Create modifiers with only Alt pub const fn alt() -> Self { - Self { ctrl: false, shift: false, alt: true } + Self { ctrl: false, shift: false, alt: true, raw_ctrl: false } } /// Create modifiers with only Shift pub const fn shift() -> Self { - Self { ctrl: false, shift: true, alt: false } + Self { ctrl: false, shift: true, alt: false, raw_ctrl: false } } /// No modifiers pub const fn none() -> Self { - Self { ctrl: false, shift: false, alt: false } + Self { ctrl: false, shift: false, alt: false, raw_ctrl: false } + } + + /// Create modifiers with only raw Ctrl (physical Ctrl key on all platforms) + /// This is used for shortcuts that need the actual Ctrl key, not Command on macOS + pub const fn raw_ctrl() -> Self { + Self { ctrl: false, shift: false, alt: false, raw_ctrl: true } } /// Convert to egui::Modifiers for comparison @@ -67,6 +77,7 @@ impl KeyModifiers { if self.alt { mods = mods | egui::Modifiers::ALT; } + // Note: raw_ctrl is handled specially in KeyBinding::matches() mods } @@ -76,13 +87,16 @@ impl KeyModifiers { ctrl: mods.command, shift: mods.shift, alt: mods.alt, + raw_ctrl: false, // raw_ctrl is only set explicitly, not from egui input } } /// Get display string for the modifiers pub fn display_string(&self) -> String { let mut parts = Vec::new(); - if self.ctrl { + if self.raw_ctrl { + parts.push("Ctrl"); // Always show "Ctrl" for raw_ctrl, even on macOS + } else if self.ctrl { parts.push(if cfg!(target_os = "macos") { "Cmd" } else { "Ctrl" }); } if self.shift { @@ -348,9 +362,19 @@ impl KeyBinding { let key = self.key.to_egui(); // Check modifiers match exactly - let mods_match = input.modifiers.command == self.modifiers.ctrl - && input.modifiers.shift == self.modifiers.shift - && input.modifiers.alt == self.modifiers.alt; + // For raw_ctrl, we check the physical Ctrl key directly, not Command + let mods_match = if self.modifiers.raw_ctrl { + // Raw Ctrl mode: check physical Ctrl key and ensure Command/other modifiers are not pressed + input.modifiers.ctrl + && !input.modifiers.command + && input.modifiers.shift == self.modifiers.shift + && input.modifiers.alt == self.modifiers.alt + } else { + // Normal mode: check Command (Cmd on macOS, Ctrl on others) + input.modifiers.command == self.modifiers.ctrl + && input.modifiers.shift == self.modifiers.shift + && input.modifiers.alt == self.modifiers.alt + }; mods_match && input.key_pressed(key) } @@ -396,6 +420,7 @@ pub enum ShortcutCommand { // Edit Undo, Redo, + DeleteLine, DuplicateLine, MoveLineUp, MoveLineDown, @@ -445,7 +470,7 @@ impl ShortcutCommand { // View ToggleViewMode, CycleTheme, ToggleZenMode, ToggleOutline, ToggleFileTree, TogglePipeline, // Edit - Undo, Redo, DuplicateLine, MoveLineUp, MoveLineDown, SelectNextOccurrence, + Undo, Redo, DeleteLine, DuplicateLine, MoveLineUp, MoveLineDown, SelectNextOccurrence, // Search Find, FindReplace, FindNext, FindPrev, SearchInFiles, // Formatting @@ -485,6 +510,7 @@ impl ShortcutCommand { // Edit ShortcutCommand::Undo => "Undo", ShortcutCommand::Redo => "Redo", + ShortcutCommand::DeleteLine => "Delete Line", ShortcutCommand::DuplicateLine => "Duplicate Line", ShortcutCommand::MoveLineUp => "Move Line Up", ShortcutCommand::MoveLineDown => "Move Line Down", @@ -536,8 +562,8 @@ impl ShortcutCommand { | ShortcutCommand::ToggleFullscreen | ShortcutCommand::ToggleOutline | ShortcutCommand::ToggleFileTree | ShortcutCommand::TogglePipeline => "View", - ShortcutCommand::Undo | ShortcutCommand::Redo | ShortcutCommand::DuplicateLine - | ShortcutCommand::MoveLineUp | ShortcutCommand::MoveLineDown + ShortcutCommand::Undo | ShortcutCommand::Redo | ShortcutCommand::DeleteLine + | ShortcutCommand::DuplicateLine | ShortcutCommand::MoveLineUp | ShortcutCommand::MoveLineDown | ShortcutCommand::SelectNextOccurrence => "Edit", ShortcutCommand::Find | ShortcutCommand::FindReplace | ShortcutCommand::FindNext @@ -571,7 +597,7 @@ impl ShortcutCommand { // Navigation ShortcutCommand::NextTab => KeyBinding::new(M::ctrl(), Tab), ShortcutCommand::PrevTab => KeyBinding::new(M::ctrl_shift(), Tab), - ShortcutCommand::GoToLine => KeyBinding::new(M::ctrl(), G), + ShortcutCommand::GoToLine => KeyBinding::new(M::ctrl_shift(), G), ShortcutCommand::QuickOpen => KeyBinding::new(M::ctrl(), P), // View ShortcutCommand::ToggleViewMode => KeyBinding::new(M::ctrl(), E), @@ -584,10 +610,11 @@ impl ShortcutCommand { // Edit ShortcutCommand::Undo => KeyBinding::new(M::ctrl(), Z), ShortcutCommand::Redo => KeyBinding::new(M::ctrl(), Y), + ShortcutCommand::DeleteLine => KeyBinding::new(M::ctrl(), D), ShortcutCommand::DuplicateLine => KeyBinding::new(M::ctrl_shift(), D), ShortcutCommand::MoveLineUp => KeyBinding::new(M::alt(), ArrowUp), ShortcutCommand::MoveLineDown => KeyBinding::new(M::alt(), ArrowDown), - ShortcutCommand::SelectNextOccurrence => KeyBinding::new(M::ctrl(), D), + ShortcutCommand::SelectNextOccurrence => KeyBinding::new(M::raw_ctrl(), G), // Search ShortcutCommand::Find => KeyBinding::new(M::ctrl(), F), ShortcutCommand::FindReplace => KeyBinding::new(M::ctrl(), H), diff --git a/src/ui/about.rs b/src/ui/about.rs index f38b8d7..b28b9c9 100644 --- a/src/ui/about.rs +++ b/src/ui/about.rs @@ -101,6 +101,11 @@ fn get_shortcuts(category: ShortcutCategory) -> Vec { Shortcut::new(format!("{}+C", m), "shortcuts.edit.copy"), Shortcut::new(format!("{}+X", m), "shortcuts.edit.cut"), Shortcut::new(format!("{}+V", m), "shortcuts.edit.paste"), + Shortcut::new(format!("{}+D", m), "shortcuts.edit.delete_line"), + Shortcut::new(format!("{}+Shift+D", m), "shortcuts.edit.duplicate_line"), + Shortcut::new("Alt+Up", "shortcuts.edit.move_line_up"), + Shortcut::new("Alt+Down", "shortcuts.edit.move_line_down"), + Shortcut::new("Ctrl+G", "shortcuts.edit.select_next_occurrence"), ], ShortcutCategory::View => vec![ Shortcut::new(format!("{}+E", m), "shortcuts.view.toggle_view"), @@ -126,7 +131,7 @@ fn get_shortcuts(category: ShortcutCategory) -> Vec { ShortcutCategory::Navigation => vec![ Shortcut::new(format!("{}+Tab", m), "shortcuts.nav.next_tab"), Shortcut::new(format!("{}+Shift+Tab", m), "shortcuts.nav.prev_tab"), - Shortcut::new(format!("{}+G", m), "shortcuts.nav.go_to_line"), + Shortcut::new(format!("{}+Shift+G", m), "shortcuts.nav.go_to_line"), Shortcut::new("F3", "shortcuts.nav.find_next"), Shortcut::new("Shift+F3", "shortcuts.nav.find_prev"), ], diff --git a/src/ui/settings.rs b/src/ui/settings.rs index d06820f..5b02ec4 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -78,6 +78,7 @@ fn shortcut_command_name(cmd: &ShortcutCommand) -> String { // Edit ShortcutCommand::Undo => t!("shortcuts.commands.undo").to_string(), ShortcutCommand::Redo => t!("shortcuts.commands.redo").to_string(), + ShortcutCommand::DeleteLine => t!("shortcuts.commands.delete_line").to_string(), ShortcutCommand::DuplicateLine => t!("shortcuts.commands.duplicate_line").to_string(), ShortcutCommand::MoveLineUp => t!("shortcuts.commands.move_line_up").to_string(), ShortcutCommand::MoveLineDown => t!("shortcuts.commands.move_line_down").to_string(),