Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/technical/ui/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |

Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions locales/de.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ shortcuts:
# Edit
undo: ""
redo: ""
delete_line: ""
duplicate_line: ""
move_line_up: ""
move_line_down: ""
Expand Down Expand Up @@ -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: ""
Expand Down
6 changes: 6 additions & 0 deletions locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions locales/ja.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ shortcuts:
# Edit
undo: ""
redo: ""
delete_line: ""
duplicate_line: ""
move_line_up: ""
move_line_down: ""
Expand Down Expand Up @@ -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: ""
Expand Down
6 changes: 6 additions & 0 deletions locales/zh_Hans.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ shortcuts:
# Edit
undo: "撤销"
redo: "重做"
delete_line: "删除行"
duplicate_line: "复制行"
move_line_up: "上移行"
move_line_down: "下移行"
Expand Down Expand Up @@ -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: "切换原始/渲染状态"
Expand Down
122 changes: 116 additions & 6 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -5322,6 +5328,9 @@ impl FerriteApp {
KeyboardAction::DuplicateLine => {
self.handle_duplicate_line();
}
KeyboardAction::DeleteLine => {
self.handle_delete_line();
}
KeyboardAction::InsertToc => {
self.handle_insert_toc();
}
Expand Down Expand Up @@ -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
// ─────────────────────────────────────────────────────────────────────────
Expand Down
Loading