W1: Fix 3 dead dialogs (DisplayBits/VfsSettings/LearnKeys) that could never close
- Capture handle_key() return value, close on Cancel/Confirm
W2: Implement Cmd::Suspend with actual SIGTSTP via kill -TSTP 4035172
- Add want_suspend field, ExternalAction::Suspend variant
- Drop TUI, send SIGTSTP, recreate TUI on resume
W3: Wire Connection dialog to Panel::navigate_to_vfs()
- Parse VFS URL, look up backend, redirect active panel
- Store Encoding dialog selection on FileManager.display_encoding
W4: Wire CK_EditUserMenu (EditorCmd::EditUserMenu)
- Opens user menu storage_path in editor via Editor::open()
- Fix unreachable!() in SaveBeforeClose prompt rendering
W5: Add ErrorOutcome::SkipAll variant + Shift-S keybinding
- Fix misleading doc comment about non-existent 'All' variants
- Add SkipAll button in error dialog render
Also: Fix all 41 compiler warnings (unused imports/vars, missing docs on
public API, remove dead SPECIAL_LABELS constant, remove unused viewer_bold)
1381 tests pass, zero warnings.
The getty binary opens a PTY via /scheme/pty during startup. Adding an
explicit requires_weak on 00_ptyd.service avoids races where getty runs
before the pseudo-terminal daemon is fully ready, which could delay or
prevent the login prompt from appearing.
G1: Error dialog Retry now re-invokes the stored file operation (copy/move/
delete) using the PendingErrorOp parameters. Previously Retry just logged
'Retry not yet wired'. Copy/move/delete error paths now create a
PendingErrorOp + ErrorDialog instead of silently setting a status message.
Skip/Ignore/Abort outcomes produce appropriate status messages.
G2: SftpVfs::open_write now returns a working SftpWriter that buffers writes
and flushes to the remote SFTP server via session.write(). Previously
returned VfsError::Unsupported. The SftpWriter buffers in memory and
writes on flush/drop, matching the existing open_read buffer pattern.
1369 tests pass (default and --features sftp).
- relibc: variadic sem_open and local fork path deps
- base: already at latest RedBear-OS submodule/base
- bootloader/kernel/libredox/userutils: pushed local path-dep fixes
- installer/redoxfs: diverged from remote submodule/*; local commits saved,
divergence to be resolved after build
- driver-manager: add syscall path dependency
- AGENTS.md: document +rb build metadata and no-patches-for-local-forks rule
- remove dead patch symlinks from recipes/core/relibc (path-source local fork)
F1: Remove stale 'Phase N' / 'not yet wired' comments from vfs/local.rs,
vfs/traits.rs, editor/usermenu.rs — the functionality they described as
future work is already implemented.
F2: Replace placeholder stubs in viewer/hex.rs and viewer/text.rs with actual
rendering for Chunked sources (files >= 1 MiB). hex.rs reads viewport-sized
chunks via read_at(); text.rs reads up to 64 MiB cap for line offset mapping.
check_growing() in viewer/mod.rs also reads Chunked content instead of
returning empty Vec.
F3: Editor Settings dialog now shows actual toggle state (auto-indent,
word-wrap, show-whitespace, save-on-quit) instead of '(TBD)' placeholder.
F4: Add xz2 crate dependency and TarKind::Xz decompression support.
Feature-gated as 'xz2' (optional, follows bzip2 pattern). Uses
XzDecoder::new_multi_decoder for multi-stream .tar.xz files.
1369 tests pass. Default build (without optional features) verified.
- Update AGENTS.md single-repo rule to explicitly forbid creating any new
Gitea repositories and to require deleting per-component repos.
- Change version suffix policy from pre-release -rb to build-metadata +rb
in AGENTS.md, sync-versions.sh, apply-rb-suffix.sh, verify-fork-versions.sh.
- Update migration instructions to push to existing submodule/<component>
branches inside RedBear-OS, not create new repos.
- Update BUILD-SYSTEM-IMPROVEMENTS.md and SLEEP-IMPLEMENTATION-PLAN.md to
reference submodule/* branches instead of defunct per-component repos.
- Fix delete-per-component-repos.sh example to use a real historical repo.
SpellChecker with ~300 common English words + programming terms.
Word tokenizer handles apostrophes, digits, underscores.
Editor integration: toggle_spell_check(), find_next_misspelled().
MC uses libaspell; TLC embeds dictionary for zero external deps.
17 new tests: check_word, check_line, case insensitive, editor
integration (toggle, find_next, disabled, correct text).
Mirrors MC's panel.c:4080-4197 mouse handling. Wraps the
Tui screen in termion's MouseTerminal (enables GPM on Linux /
xterm mouse on others — without it mouse events never reach
ratatui). The translate_mouse function maps termion MouseEvent
to a MouseAction enum that the FileManager dispatches as
cursor moves / entries / scroll (deferring HistoryPrev/Next/
List and ToggleHidden which need dedicated Cmds not yet in
the keymap).
New APIs:
- terminal::event::translate_mouse(event, panel_top,
panel_height, panel_width) -> MouseAction
- terminal::event::MouseAction (Click, DoubleClick,
ScrollUp/Down, HistoryPrev/Next/List, ToggleHidden,
Unhandled)
- FileManager::handle_mouse_event(m, size) routes
MouseAction to existing cursor_up_n / cursor_down_n /
Panel::enter / Panel::set_cursor helpers.
- app.rs routes TermEvent::Mouse through handle_mouse_event
before the keyboard-dispatch path.
Tests (9 new in terminal::event::mouse_tests):
- click_on_file_row_emits_click_action
- click_below_panel_is_unhandled
- click_on_header_history_prev_button (col 1)
- click_on_header_history_next_button (col width-2)
- click_on_header_toggle_hidden_button (col width-6)
- scroll_up_emits_scroll_up
- scroll_down_emits_scroll_down
- click_on_column_name_row_is_unhandled (sort-by-column
not yet wired)
- click_respects_panel_top_offset (panel_top parameter
works for menu-bar offset)
1345 passing (was 1336; +9 new).
Mirrors MC's src/learn.c::learn_keys (mc/source/src/learn.c:393-422).
MC shows a grid of buttons; TLC is simpler: scrollable list of
captured keys with their resolved Cmd.
New file: src/filemanager/learn_keys_dialog.rs (LearnKeysDialog,
CapturedKey, 6 unit tests).
New DialogState::LearnKeys variant wired into dispatch, render,
is_finished, title. Keymap is stored at construction so the
dispatcher doesn't thread it through every key event.
Tests: 1336 passing (was 1330; +6 new).
Mirrors MC's boxes.c::configure_vfs_box
(mc/source/src/filemanager/boxes.c:1120-1198). MC's dialog has
VFS timeout plus FTP options (anonymous password, directory
cache timeout, proxy host, passive mode); TLC has no FTP/SFTP in
the active build, so we expose the relevant 1-field subset:
- Timeout for freeing VFSs (sec): 0..=10000, default 10
(out-of-range falls back to 10 — matches MC's
boxes.c:1193-1194 validation)
New file: src/filemanager/vfs_settings_dialog.rs
- VfsSettings struct (free_timeout: u32)
- parse_timeout() (handles negative as 0, > 10000 as None)
- VfsSettingsDialog with Edit-keyed text input
- 12 unit tests
New DialogState variant:
DialogState::VfsSettings(Box<VfsSettingsDialog>)
Wired into dispatch, render, is_finished, title.
Tests: 1330 passing (was 1318; +12 new).
Mirrors MC's boxes.c::display_bits_box
(mc/source/src/filemanager/boxes.c:955-1004). MC exposes 4 modes
(UTF-8, Full 8 bits, ISO 8859-1, 7 bits); TLC is UTF-8 throughout
so we expose the relevant 3-state subset:
- Full 8 bits: bytes pass through verbatim
- 7 bits: high bit stripped (parity-stripped serial consoles)
- UTF-8 (validated): pass through UTF-8, ? for invalid
New file: src/filemanager/display_bits_dialog.rs
- DisplayBits enum with 3 variants
- DisplayBitsDialog (radio-list, mirrors SortDialog)
- from_config() / map_byte() / is_valid_utf8() helpers
- 12 unit tests
New DialogState variant:
DialogState::DisplayBits(Box<DisplayBitsDialog>)
Wired into dispatch (handle_key), render, is_finished (false;
the dispatcher captures Confirm/Cancel from handle_key), title.
Tests: 1318 passing (was 1306; +12 new).
Co-Authored-By: Claude <noreply@anthropic.com>
Mirrors MC's filegui.c::file_progress_real_query_replace and the
FileProgressStatus enum (filegui.h:45-54): FILE_CONT, FILE_RETRY,
FILE_SKIP, FILE_ABORT, FILE_IGNORE.
New file: src/filemanager/error_dialog.rs
- ErrorDialog with path + error message
- R / S / I / A shortcuts (MC letter bindings)
- Esc maps to Abort
- 7 unit tests (one per shortcut + an 'other keys don't finish' guard)
New FileManager state:
- pending_error_op: Option<PendingErrorOp>
stores kind + sources + dst + flags for the in-flight op so
the user can Retry / Skip / Ignore / Abort
- PendingErrorOp struct (parallel to existing PendingFileOp)
- pending_error_op initialized to None
New DialogState variant:
DialogState::Error(Box<error_dialog::ErrorDialog>)
Wired into dispatch (handle_key), render, is_finished, title.
Retry is currently a stub (logs a message) because the copy
engine's batch step resumption would require threading the
operation index into copy_many/move_many/delete_many. The dialog
scaffolding is complete and tested; the retry can be wired in a
follow-up sprint when the batch step API is added.
Tests: 1306 passing (was 1299; +7 new).
Adds regression tests for Sprint 3 items that were verified
already-done during recon but had no dedicated test coverage:
- viewer::tests::hex_edit_apply_nibble_at_eof_is_safe_noop
Locks in the C8 contract: apply_nibble at cursor past EOF
is a safe no-op (no panic, no spurious modified flag).
- viewer::tests::move_cursor_clamps_to_size
Locks in the C8 cursor bounds invariant at move_cursor
level: positive deltas clamp to file size, negative deltas
clamp to 0 with no underflow.
- filemanager::panel::tests::history_dedups_consecutive_entries
Locks in the C11 contract: refreshing the same directory
N times grows the history by 1 entry (consecutive dedup),
not N.
- filemanager::panel::tests::sort_field_mtime_round_trips_through_config
Locks in the C12 contract: 'mtime' and 'time' config
strings both resolve to the Mtime sort field, and the
Panel reports the human-readable name 'Mtime'.
Tests (4 new, total 1299 passing):
+hex_edit_apply_nibble_at_eof_is_safe_noop
+move_cursor_clamps_to_size
+history_dedups_consecutive_entries
+sort_field_mtime_round_trips_through_config
Adds regression tests for already-implemented Sprint 3 items so
future refactors can't silently regress these behaviors:
- terminal::color::tests::default_theme_preserves_rgb_precision
Locks in the C1 contract: when Theme::background is Rgb, the
exact 8-bit values (julia256's core.bg = 58,58,58) survive.
The parser may also emit Indexed(237) for the same source
value depending on detected color depth, so the test accepts
either variant but verifies 24-bit precision when present.
- terminal::color::tests::light_theme_preserves_rgb_precision
Same invariant for LIGHT_THEME (sand256 bright off-white
foreground > 200 RGB).
- editor::tests::bookmark_jump_restores_both_line_and_column
Locks in the C5 contract: jumping to a named bookmark must
restore both the line AND the column where the bookmark was
set. The existing test only verified the byte offset, not
the column, so a future regression to line-only restoration
would not have been caught.
Tests (3 new, total 1295 passing):
+default_theme_preserves_rgb_precision
+light_theme_preserves_rgb_precision
+bookmark_jump_restores_both_line_and_column
When set_message() is called with text containing '\n' characters,
the status line now renders the message across multiple rows
(previously only the first line was shown, with the rest lost).
StatusLine (terminal/status.rs):
- Message::line_count() returns the newline-segment count
- StatusLine::has_multiline_message() detects newline-containing
unexpired messages
- StatusLine::current_message_line_count() returns the row
count for allocation (0 for no message)
- StatusLine::render_multiline() renders each newline-separated
line as its own row
FileManager (filemanager/mod.rs):
- status_message_line_count() helper that clamps to 1..=4 rows
to avoid eating the whole screen, returns 1 when no message
render.rs (filemanager/render.rs):
- When has_message() AND has_multiline_message(), allocate the
multi-line area above the single-line status bar (which still
shows clock/spinner/hint). Single-line messages unchanged.
Tests (3 new in terminal::status::tests):
- has_multiline_message_detects_newlines
- current_message_line_count_counts_newlines
- message_line_count_handles_empty_and_multiline
Total: 1292 passing (was 1289; +3 new).
Previously reformat_paragraph_at() would join indented lines
together, destroying intentional indentation (Python code,
shell heredocs, ASCII art, markdown sub-blocks).
Now reformat_paragraph_at() detects code blocks (paragraphs
where every non-blank line begins with whitespace) and
returns the text unchanged. The detection is intentionally
conservative — false positives leave the paragraph alone
rather than destroying user-written indentation.
format.rs:
- New is_code_block(paragraph) helper
- reformat_paragraph_at() calls is_code_block(); if true,
returns text unchanged (preserving indentation)
Tests (6 new in editor::format::tests):
- is_code_block_detects_indented_paragraph
- is_code_block_rejects_unindented_paragraph
- is_code_block_ignores_blank_lines_in_paragraph
- reformat_paragraph_skips_code_block
- reformat_paragraph_skips_shell_heredoc
- reformat_paragraph_handles_mixed_code_and_prose
Total: 1289 passing (was 1283; +6 new).
Previously cmdline commands were always synchronous — even
typing 'firefox &' would block TLC until the foreground shell
process exited (which for firefox would never happen because
the shell exits immediately and firefox keeps running, but
the spawn process remained a child of the shell until the shell
exited, blocking the parent).
Now run_command detects a trailing '&' (with optional
trailing whitespace) and spawns the command in a new process
group via process_group(0) (Unix). The command is detached,
TLC continues immediately, and the parent shell exits without
waiting.
Falls back to a synchronous run on non-Unix platforms.
Tests (2 new in terminal::subshell::tests):
- run_command_backgrounded_with_ampersand_succeeds
'sleep 5 &' returns in < 2s (not 5s)
- run_command_backgrounded_with_trailing_whitespace
'sleep 3 & ' (space before &) also backgrounded
Total: 1283 passing (was 1281; +2 new).
The tar crate returns an empty iterator (without erroring) for
both zero-byte files and files that aren't valid tar archives.
TarVfs::open() previously succeeded in both cases, producing an
'unbrowsable' archive the user couldn't navigate.
Fix:
TarVfs::open() now checks entries.is_empty() after list() and
returns VfsError::Other('empty tar archive') when empty. This
covers both truly empty files (corrupt/truncated) and garbage
bytes (e.g., text mistakenly saved with .tar extension).
Tests (2 new in vfs::tar::tests):
- tar_vfs_open_empty_file_errors
- tar_vfs_open_garbage_bytes_errors
Total: 1281 passing (was 1279; +2 new).
Previously parse() only recognised the local:// scheme. A
file:///path URL (the standard scheme used by browsers, text
editors, and many CLI tools) would be treated as an unknown
scheme and fall through to the 'unknown scheme → local'
fallback in for_path(), which works but is silent.
Now parse() explicitly handles file:// as an alias for local://,
making the VFS path normal form 'local://' after parsing.
Tests (1 new in vfs::path::tests):
- parse_file_scheme_is_alias_for_local
Total: 1279 passing (was 1278; +1 new).
Previously Find dialog always opened with the default placeholder
('*.rs'). Now open_find_dialog() inspects the active panel's
cursor entry and uses its name as the initial pattern — so
pressing M-? with cursor on 'main.rs' opens the dialog with
'main.rs' already in the input, ready for refinement (e.g.
'main.rs~' for backups, 'main*' for variants).
Implementation:
- find::FindDialog::new_with_pattern(start_dir, initial_pattern)
- panel::Panel::cursor_entry() -> Option<&Entry>
- widget::input::Input::set_text(s) mutable setter (the
existing .text() builder consumes self)
- dialog_ops::open_find_dialog() uses cursor entry name as
the initial pattern; skips '..' and falls back to default
when the panel is empty
Tests (2 new in find::tests):
- new_with_pattern_prefills_pattern_input
- new_with_pattern_empty_falls_back_to_default
Total: 1278 passing (was 1276; +2 new).
Previously mark_pattern("") and unmark_pattern("") were silent
no-ops (glob_match returned false for empty pattern). Now they
match MC's behavior:
- mark_pattern(""): calls reverse_marks() (toggles every
entry; from 0 marks → all entries marked, vice versa)
- unmark_pattern(""): clears all marks
Tests (2 new in panel::tests):
- mark_pattern_empty_inverts_marks (0→2→0 cycle)
- unmark_pattern_empty_clears_all_marks
Total: 1276 passing (was 1274; +2 new).
The hex viewer now reserves the top row for a status header
showing the cursor byte offset and the total file size:
'Offset: 00000040 / 00000100 bytes'
When the area has height < 2 (degenerate case), the header is
skipped and the full row is used for hex bytes.
Layout split: total_height >= 2 → 1-row header + body_area
(remaining rows). total_height == 1 → no header, body uses
full area. The body Paragraph is rendered to body_area (not
the full area) so the header is not overwritten.
Tests (2 new in viewer::hex::tests):
- render_shows_offset_header_when_area_has_height
cursor=64 → header shows 'Offset: 00000040' at row 0
- render_skips_header_when_area_height_is_one
80x1 area → first row starts with hex offset '00000000'
Total: 1274 passing (was 1272; +2 new).
Previously, Panel::set_path() would pass the input path straight to
read_directory(). A relative path like 'sub' would fail because
read_directory tries to stat it relative to wherever, not relative
to the user's current working directory.
Implementation:
Panel::set_path() now checks if p.is_relative() and, if so,
joins it onto std::env::current_dir() before reading. Absolute
paths pass through unchanged. Falls back to the raw path if
current_dir() fails (process has no cwd).
Tests (1 new in panel::tests):
- set_path_resolves_relative_against_cwd
Total: 1272 passing (was 1271; +1 new).
DeleteDialog header now includes the total recursive size of
all paths to be deleted (formatted via format_size). For example:
'Delete 3 (1.2 MB) ?' instead of just 'Delete ?'.
Computed eagerly at construction via crate::ops::count_bytes so
render() does not block on filesystem traversal.
DeleteDialog (delete_dialog.rs):
- New field total_bytes: u64 (cached at construction)
- new() calls crate::ops::count_bytes(&paths) to populate it
- render() prepends '(N items)' to header and appends size when > 0
Tests (3 new in delete_dialog::tests):
- new_computes_total_bytes_for_directory (recursive dir sum)
- new_total_bytes_zero_for_missing_paths
- new_total_bytes_sums_multiple_paths
Total: 1271 passing (was 1268; +3 new).
Input widget colors no longer hardcoded to Color::White / Blue /
Yellow. New instances use Color::Reset as a sentinel meaning
'use the theme token', and render() falls back to:
- fg: theme.foreground
- bg: theme.background
- cursor_color: theme.warning
Callers that explicitly call .fg() / .bg() / .cursor_color() still
override the sentinel — existing API surface preserved.
Changes:
- input.rs::Input::new() defaults fg/bg/cursor_color to Color::Reset
- input.rs::Input::render() resolves Reset → theme token
- New pub fn cursor_color(c) builder (was missing; .fg/.bg existed)
Tests (2 new in widget::input::tests):
- default_colors_are_reset_sentinel
- explicit_color_overrides_reset_sentinel
Total: 1268 passing (was 1266; +2 new).
Theme::by_name() now lowercases the input before matching aliases,
so 'MC-Classic', 'Mc-Dark', and 'DARK' all resolve identically
to their lowercase canonical names. Previously a user setting
config.toml [skin] name = 'MC-Classic' would silently fall back
to default (no match found for the capitalized name).
Implementation:
- In by_name(), lowercase the input via to_ascii_lowercase()
- Match against the existing alias map (which already covers
mc-classic, mc-dark, mc-dark-gray, default-dark, etc.)
- The lowercase form is passed to mc_skin::theme_by_name,
which handles the embedded MC .ini files; the lookup there
also becomes case-insensitive as a side effect.
Tests (2 new in terminal::color::tests):
- by_name_resolves_aliases_case_insensitively
MC-CLASSIC → default, Mc-Dark → dark, etc.
- by_name_real_skin_name_case_insensitive
DARK → dark, NICEDARK → nicedark (verifies the alias->real
skin chain works regardless of input case)
Total: 1266 passing (was 1264; +2 new).
Previously Viewer::search for Chunked sources read the ENTIRE
file into RAM via read_at(0, size) — defeating the memory-efficient
design of Chunked mode (a 150 MB Chunked file would load all 150 MB
just to search it).
Search (viewer/search.rs):
New method find_all_streaming<E>(pattern, case_insensitive, next_chunk)
- Generic error type E so callers can use their own error type
- next_chunk closure yields (bytes, is_last_chunk) until exhausted
- Sliding-window algorithm: keeps last (pattern.len() - 1) bytes
from each chunk as 'tail', combines with next chunk for regex
- Matches that span chunk boundaries are detected correctly
- Matches across overlapping combined buffers are deduped by
start offset after collection
Viewer (viewer/mod.rs):
search() now dispatches by source variant:
- Inline / Compressed: in-memory find_all (unchanged)
- Chunked: find_all_streaming with closure yielding CHUNK_SIZE
reads until EOF reached
Tests (5 new in viewer::search::tests):
- streaming_single_chunk_finds_match
- streaming_match_spans_two_chunks (xxfoobarxx at chunk 5)
- streaming_no_matches
- streaming_multiple_matches_in_one_chunk
- streaming_chunk_size_smaller_than_pattern (chunk_size=1, pattern=hello)
Total: 1264 passing (was 1259; +5 new).
Bookmarks now track the correct line number across insert/delete
edits (MC parity). Previously a bookmark at line 5 would still
report line 5 even after lines 1-3 were deleted, pointing at
wrong content.
BookmarkSet (editor/bookmark.rs):
New method adjust_lines(at_line: u32, delta: i32)
- For each mark with line > at_line: shift by delta
- For positive delta: mark.line += delta
- For negative delta with mark.line in deletion range:
clamp to at_line + 1 (first surviving line after range)
- Column is preserved across all shifts
- Zero delta is a no-op (no allocation)
Editor (editor/mod.rs):
New private helper adjust_bookmarks_after_edit(edit_line, lines_before)
Wraps insert_char, insert_str, delete_back, delete_forward:
- Captures (line_before, lines_before) before buffer op
- Calls buffer op
- Computes delta = lines_after - lines_before
- Calls bookmarks.adjust_lines(line_before, delta) if non-zero
Tests (6 new in editor::bookmark::tests):
- adjust_lines_zero_delta_is_noop
- adjust_lines_shifts_marks_after_insertion
- adjust_lines_shifts_marks_after_deletion
- adjust_lines_multi_line_insertion
- adjust_lines_deletion_clamps_to_anchor
- adjust_lines_does_not_touch_col
Total: 1259 passing (was 1253; +6 new).
Adds user-facing control over the editor tab width.
New methods on Editor (editor/mod.rs):
tab_width() -> usize
- Returns current visual tab width in spaces.
set_tab_width(width: usize)
- Sets tab width, clamped to min 1.
cycle_tab_width() -> usize
- Cycles 4 → 8 → 2 → 4 (default 4 → wider → narrower → back).
- Returns the new width.
Alt-T binding (editor/handlers.rs):
try_global_shortcut now handles Alt-T (0x74) and calls
cycle_tab_width(). Status bar shows 'Tab width: N'.
Tab-to-indent (B3) and word-wrap (B1) both consume tab_width
via the field, so changing it via Alt-T takes effect immediately
on next render / insert.
Tests (5 new in editor::tests):
- tab_width_default_is_four
- set_tab_width_updates_field (verifies 0 → 1 clamp)
- cycle_tab_width_advances_2_4_8_2
- cycle_tab_width_from_unusual_value_resets_to_2
- tab_to_indent_uses_current_tab_width
Config wiring note: cfg.editor.tab_width (config.rs) already has
the field with serde defaults and persistence. Wiring it into
Editor::open() requires the constructor to accept &Config and
is deferred to a future sprint.
Total: 1253 passing (was 1248; +5 new).
Tab key now has indent-aware behavior (MC parity):
- When cursor is in the indent zone (only whitespace before it on
the current line), Tab inserts enough spaces to advance to the
next tab_width boundary.
- When cursor is past any non-whitespace, Tab inserts a single
literal '\t' character for column alignment.
New methods on Editor (editor/mod.rs):
insert_tab_with_indent()
- Computes next_boundary = ((col / tab_width) + 1) * tab_width
- In indent zone: inserts (next_boundary - col) spaces
- Otherwise: inserts literal '\t'
is_in_indent_zone() -> bool
- True if all bytes between line start and cursor are space/tab
Tab handler in editor/handlers.rs routes through
insert_tab_with_indent() instead of insert_char('\t').
Tests (6 new in editor::tests):
- tab_at_col_0_indents_to_next_boundary
- tab_at_col_4_indents_to_next_boundary
- tab_mid_line_inserts_literal_tab
- tab_after_non_whitespace_inserts_literal_tab
- tab_after_whitespace_runs_continues_indent
- tab_inside_word_inserts_literal_tab
Total: 1248 passing (was 1242; +6 new).
Replaces the character-counting wrap in build_wrap_map with a
word-boundary algorithm that matches MC's intent: lines break at
the last whitespace before the wrap limit, mid-word break as
fallback for overlong words, tabs counted as their visual width
(next tab_width boundary), control chars as 2 cols.
New helpers (editor/render.rs):
visual_width(ch, col, tab_width) -> usize
- Tabs: tab_width - (col % tab_width), min 1
- Control chars (0x00..=0x1F, 0x7F): 2 cols (renders as ^X)
- Other chars: 1 col
count_wrapped_rows(line_bytes, body_width, tab_width) -> usize
- Walks line left-to-right, tracks last whitespace position
- On overflow: wrap at last_ws_col (preserves word boundary),
else mid-word break
- Stops at \n (line_bytes excludes \n in caller)
Editor (mod.rs):
- New tab_width: usize field (default 4)
- Matches the renderer's ' ' tab expansion in push_rendered_text
- Future B4 will make this user-configurable
Tests:
+12 wrap_tests in editor::render::wrap_tests:
- visual_width_tab_expands_to_next_boundary
- visual_width_ascii_and_control
- count_wrapped_rows_empty_line
- count_wrapped_rows_short_line_fits_in_one_row
- count_wrapped_rows_exact_fit
- count_wrapped_rows_one_char_overflow_no_whitespace
- count_wrapped_rows_word_boundary_break
- count_wrapped_rows_three_words_at_width_six
- count_wrapped_rows_long_word_falls_back_to_mid_word
- count_wrapped_rows_tab_visual_width
- count_wrapped_rows_zero_width_returns_one
- count_wrapped_rows_stops_at_newline
Total: 1242 passing (was 1230; +12 new).