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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,21 @@ It includes shared prompts for commands and buffer navigation, a directory picke
| `C-x C-r` | Reload a clean buffer from disk |
| `C-x C-s` | Save the file |
| `C-x C-c` | Quit |
| Tab / Shift-Tab | Indent or outdent the active region; otherwise insert to a tab stop or outdent the current line |
| `M-x` | Open the named command prompt |

If any open buffer is dirty, `C-x C-c` asks whether to quit without saving.
Press `y` to confirm.
Press `n` or Escape to cancel.

Tab inserts spaces to the next four-column stop.
Enter carries leading spaces and tabs from the current line, limited to indentation before point.
Newlines retain the current line's LF or CRLF style; an unterminated final line uses the previous line's style, and a new file uses LF.
With a region active, Tab adds four spaces to each selected line and Shift-Tab removes up to four columns of leading indentation.
Each region change is one undo step and keeps the region active for another indentation command.
A selection ending at the start of a line excludes that line.
Shift-Tab without a region outdents the current line and preserves any remaining tabs.

## Named commands

Press `M-x`, type a command name, and press Enter.
Expand All @@ -164,7 +173,8 @@ Escape cancels the prompt; unknown names and invalid arguments report an error w
| `forward-char`, `backward-char` | Move one grapheme | |
| `next-line`, `previous-line` | Move one line | |
| `beginning-of-line`, `end-of-line` | Move to a line boundary | |
| `newline` | Insert a newline | |
| `newline` | Insert a newline carrying leading indentation | |
| `indent`, `outdent` | Apply Tab or Shift-Tab behavior | |
| `delete-char`, `delete-backward-char` | Delete one grapheme | |
| `set-mark`, `kill-region`, `kill-line`, `yank` | Select, cut, or insert cut text | |
| `self-insert-command <character>` | Insert one printable character | |
Expand Down
35 changes: 35 additions & 0 deletions docs/issues/164-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Issue 164: Practical indentation

## Task and acceptance

Tab inserts spaces to the next four-column display stop when no region is active.
Enter copies the current line's leading spaces and tabs, limited to whitespace before point.
It uses the current line's LF or CRLF ending, falling back to the previous line at an unterminated final line, then LF for a new file.
Tab and Shift-Tab change all selected lines as one undo step and preserve the selected range for repeated indentation.
A region ending at the start of a line excludes that line.
Shift-Tab without a region removes up to four columns of leading indentation from the current line.
Do not split graphemes or convert existing tabs while carrying or removing indentation.

## Implementation

Keep line and Rope access in Buffer and point in View.
Add bounded leading-whitespace and line-ending queries to Buffer.
Reuse one contiguous replacement for a selected block so existing history records the entire operation as one edit.
End the replacement at the last changed prefix, so single-line operations retain only indentation bytes in history.
Map point and mark through inserted or removed prefixes; leave an unchanged outdent as a no-op.
Named indent and outdent commands share their keybinding behavior.

## Checks

Cover tab stops after wide characters and tabs, indentation split points, CRLF and mixed line endings, region boundaries and direction, blank lines, mixed whitespace, graphemes, and undo/redo point restoration.
Run focused command/app/input tests, the full suite, formatting, all-target Clippy, and release build.
Use a real PTY to type nested code, indent and outdent a region, undo, save, and verify terminal restoration and a usable shell.

## Verification

The final local suite passed 352 unit tests and eight terminal integration tests.
The two redirected-input tests remain restricted by the local sandbox; require their unchanged CI coverage before merge.
Formatting, all-target Clippy, and the release build passed.
A real release PTY flow typed nested Rust using Tab, Enter, and Shift-Tab, saved it, indented and outdented a region ending at a line boundary, exercised one-step undo/redo across saves, and restored a usable shell.
Independent review approved after replacing whole-line indentation history with prefix-only changes.
A one-million-character regression proves that single-line indent/outdent retains only the four changed bytes in history.
6 changes: 3 additions & 3 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,18 +111,18 @@ Shipped:
- Multiple buffers.
- Find file.
- Switch buffer.
- Command registry and `M-x` with prefix completion.

Planned:
- Incremental search.
- Command registry and `M-x`.
- Kill ring and yank-pop.

Tracking issues:
- [closed] [#27 Add undo and redo](https://github.com/owainlewis/cortex/issues/27)
- [closed] [#28 Add minibuffer foundation](https://github.com/owainlewis/cortex/issues/28)
- [open] [#29 Add incremental search](https://github.com/owainlewis/cortex/issues/29)
- [closed] [#30 Add multiple buffers, find-file, and switch-buffer](https://github.com/owainlewis/cortex/issues/30)
- [open] [#46 Add command registry and M-x](https://github.com/owainlewis/cortex/issues/46)
- [closed] [#46 Add command registry and M-x](https://github.com/owainlewis/cortex/issues/46)
- [open] [#47 Replace cut slot with a real kill ring](https://github.com/owainlewis/cortex/issues/47)

Release notes should focus on editing safety, search, and buffer navigation.
Expand All @@ -137,7 +137,7 @@ Delivery order:
- [closed] [#161 Align the product direction](https://github.com/owainlewis/cortex/issues/161)
- [closed] [#162 Correct TypeScript and fenced Markdown colours](https://github.com/owainlewis/cortex/issues/162)
- [closed] [#175 Fix macOS PTY disconnect monitoring](https://github.com/owainlewis/cortex/issues/175)
- [open] [#46 Add command registry and M-x](https://github.com/owainlewis/cortex/issues/46)
- [closed] [#46 Add command registry and M-x](https://github.com/owainlewis/cortex/issues/46)
- [open] [#164 Add typing and region indentation](https://github.com/owainlewis/cortex/issues/164)
- [open] [#165 Handle literal terminal paste](https://github.com/owainlewis/cortex/issues/165)
- [open] [#166 Group typing and deletion undo steps](https://github.com/owainlewis/cortex/issues/166)
Expand Down
87 changes: 87 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,22 @@ impl AppState {
use commands::Command;
match command {
Command::OpenCommandLine => self.start_command_line(),
Command::Indent | Command::Outdent => {
if let Some(region) = self.active_region(buffer, view) {
let point_at_start = view.point() == region.start;
let region =
commands::indent_region(buffer, view, region, command == Command::Outdent);
self.mark = Some(if point_at_start {
region.end
} else {
region.start
});
self.clear_status();
AppAction::Continue
} else {
self.dispatch_command(command, buffer, view)
}
}
Command::SetMark => self.set_mark(view),
Command::KillRegion => self.kill_region(buffer, view),
Command::KillLine => self.kill_line(buffer, view),
Expand Down Expand Up @@ -781,6 +797,7 @@ fn keycast_text(key: crate::input::Key) -> Option<String> {
crate::input::Key::Command(ch) => Some(format!("Cmd-{ch}")),
crate::input::Key::Enter => Some("Enter".to_string()),
crate::input::Key::Tab => Some("Tab".to_string()),
crate::input::Key::BackTab => Some("Shift-Tab".to_string()),
crate::input::Key::Escape => Some("Esc".to_string()),
crate::input::Key::Backspace => Some("Backspace".to_string()),
crate::input::Key::Delete => Some("Delete".to_string()),
Expand Down Expand Up @@ -812,6 +829,8 @@ fn command_clears_mark(command: commands::Command) -> bool {
command,
commands::Command::Insert(_)
| commands::Command::InsertNewline
| commands::Command::Indent
| commands::Command::Outdent
| commands::Command::DeleteBackward
| commands::Command::DeleteForward
| commands::Command::ReloadBuffer
Expand Down Expand Up @@ -1793,6 +1812,74 @@ mod tests {
);
}

#[test]
fn tab_and_backtab_preserve_forward_and_reverse_selected_regions() {
for reverse in [false, true] {
let mut app = AppState::default();
let mut keymap = Keymap::new();
let source = "pre\n\talpha\n beta\n";
let mut buffer = buffer_with_text("region.txt", source);
let mut view = View::new();
let start = buffer.line_start_char(1);
let end = buffer.len_chars();
let point = if reverse { start } else { end };
app.mark = Some(if reverse { end } else { start });
view.set_point(point, &buffer);
app.handle_key(Key::Tab, &mut keymap, &mut buffer, &mut view);
assert_eq!(buffer.text(), "pre\n \talpha\n beta\n");
assert_eq!(app.active_region(&buffer, &view), Some(start..end + 8));
assert_eq!(view.point(), if reverse { start } else { end + 8 });
app.handle_key(Key::BackTab, &mut keymap, &mut buffer, &mut view);
assert_eq!(buffer.text(), source);
assert_eq!(app.active_region(&buffer, &view), Some(start..end));
assert_eq!(view.point(), point);
run_slash_command("undo", &mut app, &mut keymap, &mut buffer, &mut view);
assert_eq!(buffer.text(), "pre\n \talpha\n beta\n");
assert!(app.mark.is_none());
run_slash_command("undo", &mut app, &mut keymap, &mut buffer, &mut view);
assert_eq!(buffer.text(), source);
assert_eq!(view.point(), point);
assert_eq!(buffer.undo(), None);
}
}

#[test]
fn region_indentation_keeps_blank_lines_and_graphemes_intact() {
let mut app = AppState::default();
let mut keymap = Keymap::new();
let source = " \u{301}a\n\n\t界\r\nlast";
let mut buffer = buffer_with_text("region.txt", source);
let mut view = View::new();
let end = buffer.line_start_char(3);
app.mark = Some(0);
view.set_point(end, &buffer);
app.handle_key(Key::Tab, &mut keymap, &mut buffer, &mut view);
assert_eq!(buffer.text(), " \u{301}a\n \n \t界\r\nlast");
assert_eq!(app.active_region(&buffer, &view), Some(0..end + 12));
app.handle_key(Key::BackTab, &mut keymap, &mut buffer, &mut view);
assert_eq!(buffer.text(), source);
assert_eq!(view.point(), end);
assert_eq!(app.mark, Some(0));
}

#[test]
fn named_indentation_preserves_partial_line_region_endpoints() {
let mut app = AppState::default();
let mut keymap = Keymap::new();
let mut buffer = buffer_with_text("region.txt", " a\n b\nc");
let mut view = View::new();
app.mark = Some(1);
view.set_point(6, &buffer);
run_slash_command("indent", &mut app, &mut keymap, &mut buffer, &mut view);
assert_eq!(buffer.text(), " a\n b\nc");
assert_eq!(app.mark, Some(5));
assert_eq!(view.point(), 14);
run_slash_command("outdent", &mut app, &mut keymap, &mut buffer, &mut view);
assert_eq!(buffer.text(), " a\n b\nc");
assert_eq!(app.mark, Some(1));
assert_eq!(view.point(), 6);
}

#[test]
fn named_commands_match_editing_and_movement_keys() {
for (name, key) in [
Expand Down
67 changes: 66 additions & 1 deletion src/buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,36 @@ impl Buffer {
self.line_start_char(line_idx) + line_content_len_chars(line)
}

pub(crate) fn leading_indentation(&self, line_idx: usize, max_chars: usize) -> String {
let line_idx = self.clamp_line_idx(line_idx);
let start = self.line_start_char(line_idx);
let count = self
.text
.line(line_idx)
.chars()
.take(max_chars)
.take_while(|ch| matches!(ch, ' ' | '\t'))
.count();
let end = self.grapheme_boundary_at_or_before(start + count);
self.text_range(start..end)
}

pub(crate) fn newline_at(&self, line_idx: usize) -> &'static str {
let line_idx = self.clamp_line_idx(line_idx);
for index in [line_idx, line_idx.saturating_sub(1)] {
let line = self.text.line(index);
let len = line.len_chars();
if len > 0 && line.char(len - 1) == '\n' {
return if len > 1 && line.char(len - 2) == '\r' {
"\r\n"
} else {
"\n"
};
}
}
"\n"
}

pub fn line_prefix_text(&self, line_idx: usize, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
Expand Down Expand Up @@ -823,7 +853,7 @@ impl Buffer {
line_idx.min(self.len_lines().saturating_sub(1))
}

fn replace_with_points(
pub(crate) fn replace_with_points(
&mut self,
char_range: Range<usize>,
inserted: &str,
Expand Down Expand Up @@ -2213,6 +2243,41 @@ mod tests {
("paragraph-separator", "\u{2029}"),
];

#[test]
fn single_line_indentation_history_retains_only_the_changed_prefix() {
use crate::{
commands::{self, Command},
view::View,
};
let dir = test_dir("indentation-history");
let path = dir.join("long.txt");
let source = format!(" {}", "x".repeat(1_000_000));
fs::write(&path, &source).unwrap();
for selected in [false, true] {
let mut buffer = Buffer::open(&path).unwrap();
let mut view = View::new();
let end = buffer.len_chars();
view.set_point(end, &buffer);
if selected {
commands::indent_region(&mut buffer, &mut view, 0..end, true);
} else {
commands::dispatch(Command::Outdent, &mut buffer, &mut view);
}
let edit = buffer.undo_stack.last().unwrap();
assert_eq!(edit.deleted, " ");
assert!(edit.inserted.is_empty());
assert_eq!(view.point(), end - 4);
commands::dispatch(Command::Undo, &mut buffer, &mut view);
assert_eq!(view.point(), end);
assert_eq!(buffer.text(), source);
commands::indent_region(&mut buffer, &mut view, 0..end, false);
let edit = buffer.undo_stack.last().unwrap();
assert!(edit.deleted.is_empty());
assert_eq!(edit.inserted, " ");
}
remove_dir(dir);
}

#[test]
fn loads_existing_files_into_the_buffer() {
let dir = test_dir("loads-existing-files");
Expand Down
18 changes: 17 additions & 1 deletion src/command_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,25 @@ pub const COMMANDS: &[CommandSpec] = &[
),
command(
"newline",
"Insert a newline at point",
"Insert a newline carrying the current indentation",
&[],
Command::InsertNewline,
None,
),
command(
"indent",
"Insert spaces to a tab stop or indent selected lines",
&[],
Command::Indent,
None,
),
command(
"outdent",
"Remove one indentation level from current or selected lines",
&[],
Command::Outdent,
None,
),
command(
"delete-backward-char",
"Delete the previous grapheme",
Expand Down Expand Up @@ -439,6 +453,8 @@ mod tests {
})
.chain([
Key::Enter,
Key::Tab,
Key::BackTab,
Key::Backspace,
Key::Delete,
Key::Left,
Expand Down
Loading