···5656- CLI loads bundled default cart or a provided `.lua` path.
5757- Audio capture foundation: `cpal` input stream with mono downmix into a lock‑free ring buffer (8192 samples); CLI flags `--list-audio`, `--audio-device`, `--audio-vu`, `--audio-disable`; simple VU feedback prints peak dBFS once per second.
5858 - FFT: 2k R2C (`realfft`) on tick thread; maintains raw/smoothed/normalized buffers with peak tracking; `--debug-fft` throttled print; Lua `fft/ffts/fftr/fftrs` wired with C-identical clamping/sum semantics; headless tests added; simple cart at `assets/fft_test.lua`.
5959- - Screenshots: CLI supports `--screenshot <path> [--screenshot-scale N] [--screenshot-frame N]` and `--headless` offscreen capture. In windowed mode, F12 saves to `./screenshots/scr-YYYYmmdd-HHMMSS.png` without exiting.
5959+- Screenshots: CLI supports `--screenshot <path> [--screenshot-scale N] [--screenshot-frame N]` and `--headless` offscreen capture. In windowed mode, F12 saves to `./screenshots/scr-YYYYmmdd-HHMMSS.png` without exiting.
6060+ - Editor: CODE view supports basic editing (insert chars/newline/tab, backspace/delete, Home/End), selection (Shift+arrows), clipboard (Ctrl/Cmd+C/V/X), undo/redo (Ctrl/Cmd+Z / Shift+Z or Y). Caret + auto-scroll; gutter and rendering intact; tests added.
60616162**Near-Term Backlog**
6263- FFT implementation (cpal + realfft) per `docs/specs/audio_fft_vqt.md` (see Implementation TODOs section); add headless tests and Lua `fft/ffts/fftr/fftrs`.
···140141 - Ran `cargo fmt`, `cargo clippy --all-targets --all-features -D warnings`, and `cargo test`: all green.
141142 - Conducted a full code review of the `tic80_rust` crate. Findings are positive; suggestions for minor refactorings have been logged in `docs/roadmap/todos_code_review.md` and a summary added to `docs/adr/codereviews/001.md`.
142143 - Performed a second code review. The summary is located at `docs/adr/codereviews/002_ai_review.md` and actionable suggestions are in `docs/roadmap/todos_from_ai_review.md`.
144144+- 2025-08-27 (cont.):
145145+ - Editor basic editing implemented and tested:
146146+ - Text input (ReceivedCharacter), Enter newline, Tab → one space; Backspace/Delete; Home/End.
147147+ - Selection with Shift+arrows; clipboard shortcuts (copy/cut/paste) using OS clipboard; select-all.
148148+ - Undo/redo stacks with batching for replace; redo semantics fixed and tested.
149149+ - Key handling wired in windowed path; caret and auto-scroll preserved.
150150+ - Tests: `tic80_rust/tests/editor_editing_tests.rs`, `tic80_rust/tests/editor_selection_undo_tests.rs`.
151151+ - Hygiene verified: `cargo fmt`, `cargo clippy --all-targets --all-features -- -D warnings`, and `cargo test` all green.
152152+ - Editor polish and bugfixes:
153153+ - macOS Cmd shortcuts fixed by using per-event modifiers (`KeyboardInput.modifiers`) for Cmd/Ctrl detection.
154154+ - Selection highlight aligned with caret box (vertical off-by-one vs clip corrected); added unit test for alignment.
143155144156**Docs Index**
145157- Start here: `docs/README.md`
+5-2
docs/README.md
···7979## Editor (Livecoding)
8080- Launch with `--editor` to open the framebuffer UI with CODE/CONSOLE tabs.
8181- CODE view:
8282- - Rope-backed text buffer for the loaded cart code (read-only viewport initially).
8282+ - Rope-backed text buffer for the loaded cart code; editable.
8383 - Monospace grid 6×8 per cell (fixed-width glyphs render left 6 columns and advance 6 px).
8484- - Gutter with 1-based line numbers; arrow keys move the caret; viewport auto-scrolls.
8484+ - Gutter with 1-based line numbers; viewport auto-scroll keeps caret visible.
8585+ - Selection highlight with Shift+Arrows; clipboard via Cmd/Ctrl+C/V/X; Select All Cmd/Ctrl+A.
8686+ - Undo/Redo: Cmd/Ctrl+Z (undo), Shift+Z or Y (redo).
8787+ - Keys: Left/Right/Up/Down move the caret; Home/End jump to line bounds; Tab inserts a single space; Enter inserts newline; Backspace/Delete remove characters (joining lines at SOL/EOL).
8588 - Caret matches TIC‑80 style: red box slightly larger than the glyph, with 1 px drop shadow; underlying glyph drawn dark to simulate inversion.
8689- Roadmap: see `docs/roadmap/editor_livecoding.md` for phases (editing, undo/redo, colorizer, find, console, hot reload).
8790
+6-2
docs/roadmap/editor_livecoding.md
···3434Phase 1 — Text Engine + Editor Basics
3535- [x] Integrate a rope‑backed buffer (ropey) and load cart code into it.
3636- [x] Caret movement (arrows) and auto-scroll; Home/End pending.
3737-- [ ] Insert/delete/backspace, newlines.
3838-- [ ] Selection (shift+arrows), clipboard (Ctrl/Cmd+C/V/X), undo/redo (local stack for code buffer).
3737+- [x] Insert/delete/backspace, newlines.
3838+- [x] Selection (shift+arrows), clipboard (Ctrl/Cmd+C/V/X), undo/redo (local stack for code buffer).
3939- [x] Horizontal/vertical scrolling; viewport mapping from text rows to framebuffer pixels.
4040- [x] Draw gutter (line numbers).
4141···4343- [ ] Lightweight Lua colorizer (keywords, comments, strings, numbers) with palette colors.
4444- [ ] Find/replace panel (Ctrl/Cmd+F) with next/prev navigation.
4545- [ ] Adjustable font scale within 8×8 multiples (e.g., 1×/2×) while preserving 240×136 layout.
4646+4747+Notes
4848+- Home/End implemented for quick navigation to line bounds.
4949+- Tab inserts a single space by default (compact layout).
46504751Phase 3 — Console Pane (may be reduced)
4852- [ ] Console ring buffer model with timestamps and color tags.
+6-2
docs/specs/implementation_status.md
···7676- Sprite flags
7777 - `fget`, `fset`.
78787979-Implemented (Editor — initial)
7979+Implemented (Editor)
8080- UI shell: top bar (tabs + buttons) rendered in framebuffer; integer-scaling window.
8181-- CODE view: rope‑backed buffer; read‑only viewport rendering; gutter; caret navigation (arrows) with auto‑scroll; TIC‑style caret (red box + shadow with inverted glyph); 6×8 cell grid.
8181+- CODE view: rope‑backed buffer; editable; gutter; selection highlight; TIC‑style caret (red box + shadow with inverted glyph); 6×8 cell grid.
8282+- Navigation: arrows, Home/End; auto‑scroll keeps caret visible.
8383+- Editing: insert chars/newline/tab (1 space), backspace/delete with line joins at SOL/EOL.
8484+- Selection/Clipboard: Shift+arrows; Select All (Cmd/Ctrl+A); copy/cut/paste via OS clipboard (Cmd/Ctrl+C/V/X).
8585+- Undo/Redo: Cmd/Ctrl+Z undo; Shift+Z or Y redo; batched operations for replace.
8286- CLI: `--editor` launches the editor.
83878488Test Coverage (summary)
+2
docs/testing/test_catalog.md
···7373## Editor Tests
7474- `tic80_rust/tests/editor_smoke.rs`: UI shell draws and tabs switch on click; verifies top-bar pixels.
7575- `tic80_rust/tests/editor_code_view_tests.rs`: CODE viewport renders gutter digits and text cells.
7676+- `tic80_rust/tests/editor_editing_tests.rs`: basic editing behavior for insert/newline, backspace (join-prev), delete (join-next), Home/End, and Tab-as-spaces.
7777+- `tic80_rust/tests/editor_selection_undo_tests.rs`: selection replace/cut/paste and undo/redo cycles; select-all.
76787779## Screenshot Tests
7880- `tic80_rust/tests/screenshot_smoke.rs`
···1414 pub caret_col: usize,
1515 pub scroll_line: usize,
1616 pub scroll_col: usize,
1717+ // Selection anchor as (line, col) if active
1818+ sel_anchor: Option<(usize, usize)>,
1919+ // Undo/redo stacks (each EditOp can be a batch of atomic edits)
2020+ undo: Vec<EditOp>,
2121+ redo: Vec<EditOp>,
2222+}
2323+2424+#[derive(Clone, Debug)]
2525+enum EditKind {
2626+ Insert { index: usize, text: String },
2727+ Delete { index: usize, text: String },
2828+}
2929+3030+#[derive(Clone, Debug, Default)]
3131+struct EditOp {
3232+ ops: Vec<EditKind>,
1733}
18341935impl CodeBuffer {
···2541 caret_col: 0,
2642 scroll_line: 0,
2743 scroll_col: 0,
4444+ sel_anchor: None,
4545+ undo: Vec::new(),
4646+ redo: Vec::new(),
2847 }
2948 }
30493150 #[must_use]
5151+ fn caret_char_index(&self) -> usize {
5252+ let base = self.rope.line_to_char(self.caret_line);
5353+ let col = self.caret_col.min(self.line_len(self.caret_line));
5454+ base + col
5555+ }
5656+5757+ pub fn insert_char(&mut self, ch: char) {
5858+ if ch == '\r' {
5959+ return;
6060+ }
6161+ if ch == '\n' {
6262+ self.insert_newline();
6363+ return;
6464+ }
6565+ if self.has_selection() {
6666+ let text = ch.to_string();
6767+ self.replace_selection_with(&text);
6868+ return;
6969+ }
7070+ let idx = self.caret_char_index();
7171+ self.rope.insert_char(idx, ch);
7272+ self.push_undo(EditKind::Insert {
7373+ index: idx,
7474+ text: ch.to_string(),
7575+ });
7676+ self.clear_redo();
7777+ self.set_caret_at_index(idx + 1);
7878+ }
7979+8080+ pub fn insert_tab(&mut self) {
8181+ // Default to a single space for compact 240x136 layout
8282+ if self.has_selection() {
8383+ self.replace_selection_with(" ");
8484+ return;
8585+ }
8686+ let idx = self.caret_char_index();
8787+ self.rope.insert(idx, " ");
8888+ self.push_undo(EditKind::Insert {
8989+ index: idx,
9090+ text: " ".to_string(),
9191+ });
9292+ self.clear_redo();
9393+ self.set_caret_at_index(idx + 1);
9494+ }
9595+9696+ pub fn insert_newline(&mut self) {
9797+ if self.has_selection() {
9898+ self.replace_selection_with("\n");
9999+ return;
100100+ }
101101+ let idx = self.caret_char_index();
102102+ self.rope.insert_char(idx, '\n');
103103+ self.push_undo(EditKind::Insert {
104104+ index: idx,
105105+ text: "\n".to_string(),
106106+ });
107107+ self.clear_redo();
108108+ self.set_caret_at_index(idx + 1);
109109+ }
110110+111111+ pub fn backspace(&mut self) {
112112+ if self.has_selection() {
113113+ if let Some((start, end)) = self.selection_range_idx() {
114114+ let deleted = self.delete_range(start, end);
115115+ self.push_undo(EditKind::Delete {
116116+ index: start,
117117+ text: deleted,
118118+ });
119119+ self.clear_redo();
120120+ }
121121+ return;
122122+ }
123123+ if self.caret_col > 0 {
124124+ let idx = self.caret_char_index();
125125+ if idx > 0 {
126126+ let removed = self.slice_to_string(idx - 1, idx);
127127+ self.rope.remove(idx - 1..idx);
128128+ self.push_undo(EditKind::Delete {
129129+ index: idx - 1,
130130+ text: removed,
131131+ });
132132+ self.clear_redo();
133133+ self.set_caret_at_index(idx - 1);
134134+ }
135135+ } else if self.caret_line > 0 {
136136+ // Merge with previous line (remove the preceding newline)
137137+ let idx = self.caret_char_index();
138138+ if idx > 0 {
139139+ let removed = self.slice_to_string(idx - 1, idx);
140140+ self.rope.remove(idx - 1..idx);
141141+ self.push_undo(EditKind::Delete {
142142+ index: idx - 1,
143143+ text: removed,
144144+ });
145145+ self.clear_redo();
146146+ // caret moves to start index (previous line end)
147147+ self.set_caret_at_index(idx - 1);
148148+ }
149149+ }
150150+ }
151151+152152+ pub fn delete_forward(&mut self) {
153153+ if self.has_selection() {
154154+ if let Some((start, end)) = self.selection_range_idx() {
155155+ let deleted = self.delete_range(start, end);
156156+ self.push_undo(EditKind::Delete {
157157+ index: start,
158158+ text: deleted,
159159+ });
160160+ self.clear_redo();
161161+ }
162162+ return;
163163+ }
164164+ let idx = self.caret_char_index();
165165+ if idx < self.rope.len_chars() {
166166+ // Deleting forward: remove current char or join next line when at EOL
167167+ let removed = self.slice_to_string(idx, idx + 1);
168168+ self.rope.remove(idx..=idx);
169169+ self.push_undo(EditKind::Delete {
170170+ index: idx,
171171+ text: removed,
172172+ });
173173+ self.clear_redo();
174174+ // Caret stays; adjust if we deleted a newline (join lines)
175175+ self.set_caret_at_index(idx);
176176+ }
177177+ }
178178+179179+ #[allow(clippy::missing_const_for_fn)]
180180+ pub fn home(&mut self) {
181181+ self.caret_col = 0;
182182+ }
183183+184184+ #[allow(clippy::missing_const_for_fn)]
185185+ pub fn end(&mut self) {
186186+ self.caret_col = self.line_len(self.caret_line);
187187+ }
188188+189189+ #[must_use]
32190 pub fn line_count(&self) -> usize {
33191 self.rope.len_lines()
34192 }
···99257 }
100258 }
101259260260+ #[allow(clippy::cast_possible_truncation)]
102261 pub fn draw(&mut self, fb: &mut crate::gfx::framebuffer::Framebuffer, area: Area) {
103262 let gutter_w = 24i32;
104263 let lines_vis = (area.h / 8).max(1) as usize;
···128287 let start = self.scroll_col.min(line.chars().count());
129288 let mut iter = line.chars().skip(start);
130289 let vis: String = iter.by_ref().take(cols_vis).collect();
290290+ // Selection highlight for this line (with TIC-80-style drop shadow)
291291+ if let Some((sel_start, sel_end)) = self.selection_range_idx() {
292292+ // Compute selection coverage in columns for this visible segment
293293+ let line_char_start = self.rope.line_to_char(line_idx);
294294+ let line_char_end = line_char_start + self.line_len(line_idx);
295295+ let s = sel_start.max(line_char_start);
296296+ let e = sel_end.min(line_char_end);
297297+ if e > s {
298298+ let a = (s - line_char_start) as i32;
299299+ let b = (e - line_char_start) as i32;
300300+ let a_vis = (a - self.scroll_col as i32).max(0);
301301+ let b_vis = (b - self.scroll_col as i32).max(0);
302302+ let from = a_vis.min(cols_vis as i32).max(0);
303303+ let to = b_vis.min(cols_vis as i32).max(from);
304304+ if to > from {
305305+ let sel_x = area.x + gutter_w + from * 6;
306306+ let y_top = (gutter_y - 1).max(area.y);
307307+ let sel_w = (to - from) * 6;
308308+ // Fill (7px tall), like caret box
309309+ fb.rect(sel_x, y_top, sel_w, 7, 14);
310310+ // Decide whether to draw the right shadow for this row segment.
311311+ // Only draw if the next line's selection doesn't extend as far right (outer perimeter).
312312+ let mut draw_right_shadow = true;
313313+ if line_idx + 1 < self.line_count() {
314314+ let next_line_char_start = self.rope.line_to_char(line_idx + 1);
315315+ let next_line_char_end = next_line_char_start + self.line_len(line_idx + 1);
316316+ // Next line selection coverage
317317+ let ns = sel_start.max(next_line_char_start);
318318+ let ne = sel_end.min(next_line_char_end);
319319+ if ne > ns {
320320+ let na = (ns.saturating_sub(next_line_char_start)) as i32;
321321+ let nb = (ne.saturating_sub(next_line_char_start)) as i32;
322322+ let na_vis = (na - self.scroll_col as i32).max(0);
323323+ let nb_vis = (nb - self.scroll_col as i32).max(0);
324324+ let nfrom = na_vis.min(cols_vis as i32).max(0);
325325+ let nto = nb_vis.min(cols_vis as i32).max(nfrom);
326326+ // If next line's right edge is strictly greater than this line's,
327327+ // skip right shadow here (it's interior to the overall blob).
328328+ // Equal width should draw to produce a continuous vertical edge.
329329+ if nto > to {
330330+ draw_right_shadow = false;
331331+ }
332332+ }
333333+ }
334334+ if draw_right_shadow {
335335+ // Right edge: start at the same top as fill and span 8px so adjacent rows abut exactly
336336+ fb.rect(sel_x + sel_w, y_top, 1, 8, 0);
337337+ }
338338+ // Only draw bottom shadow if selection does not continue to next line
339339+ let continues_down = sel_end >= line_char_end;
340340+ if !continues_down {
341341+ fb.rect(sel_x, y_top + 7, sel_w, 1, 0);
342342+ }
343343+ }
344344+ }
345345+ }
131346 // Monospace rendering for alignment (fixed=true)
132347 let _ = fb.print_text(&vis, area.x + gutter_w, gutter_y, 12, true, 1, false);
133348 }
134349135135- // Caret (TIC-80 style: red box slightly larger than glyph, with 1px drop shadow; underlying glyph drawn dark)
350350+ // Caret (red box aligned to 6x8 cell, with 1px drop shadow; underlying glyph drawn dark)
136351 if self.caret_line >= self.scroll_line && self.caret_line < self.scroll_line + lines_vis {
137352 let row = i32::try_from(self.caret_line - self.scroll_line).unwrap_or(0);
138353 let col = i32::try_from(self.caret_col.saturating_sub(self.scroll_col)).unwrap_or(0);
139354 let cell_x = area.x + gutter_w + col * 6;
140355 let cell_y = area.y + row * 8;
141141- // Target a box that is ~1px larger than the glyph horizontally and vertically
142142- // within the 6x8 cell: width 7 (left-extended by 1), height 7, leaving room for a 1px bottom shadow.
143143- let mut fill_x = cell_x - 1; // extend 1px to the left
144144- let fill_y = (cell_y - 1).max(area.y); // shift up by 1px
145145- let mut fill_w = 7; // cover 6px cell width + 1px extra on left
146146- let fill_h = 7; // leave 1px bottom for shadow
147147- // Clamp fill to the code pane (don’t bleed into gutter if at first column)
148148- let code_left = area.x + gutter_w;
149149- if fill_x < code_left {
150150- let delta = code_left - fill_x;
151151- fill_x = code_left;
152152- fill_w = (fill_w - delta).max(1);
153153- }
356356+ // Box aligned to cell: width 6, height 7 (reserve 1px bottom for shadow)
357357+ let fill_x = cell_x;
358358+ let fill_y = (cell_y - 1).max(area.y);
359359+ let fill_w = 6;
360360+ let fill_h = 7;
154361 // Fill: palette 8 (red)
155362 fb.rect(fill_x, fill_y, fill_w, fill_h, 8);
156363 // Shadow (palette 0) to the right and along bottom
157364 fb.rect(fill_x + fill_w, fill_y, 1, fill_h, 0);
158158- fb.rect(fill_x + 1, fill_y + fill_h, fill_w, 1, 0);
365365+ fb.rect(fill_x, fill_y + fill_h, fill_w, 1, 0);
159366160367 // Draw the underlying glyph in dark color to simulate inversion
161368 let line_idx = self.caret_line;
···177384 }
178385179386 fb.clip_reset();
387387+ }
388388+389389+ #[must_use]
390390+ pub fn as_string(&self) -> String {
391391+ self.rope.to_string()
392392+ }
393393+394394+ // Selection helpers
395395+ #[allow(clippy::missing_const_for_fn)]
396396+ pub fn clear_selection(&mut self) {
397397+ self.sel_anchor = None;
398398+ }
399399+400400+ #[allow(clippy::missing_const_for_fn)]
401401+ pub fn start_selection(&mut self) {
402402+ self.sel_anchor = Some((self.caret_line, self.caret_col));
403403+ }
404404+405405+ pub fn ensure_selection_anchor(&mut self) {
406406+ if self.sel_anchor.is_none() {
407407+ self.start_selection();
408408+ }
409409+ }
410410+411411+ #[must_use]
412412+ pub fn has_selection(&self) -> bool {
413413+ self.selection_range_idx().is_some()
414414+ }
415415+416416+ #[must_use]
417417+ pub fn selection_range_idx(&self) -> Option<(usize, usize)> {
418418+ let (al, ac) = self.sel_anchor?;
419419+ let a = self.line_col_to_index(al, ac);
420420+ let b = self.caret_char_index();
421421+ match a.cmp(&b) {
422422+ core::cmp::Ordering::Equal => None,
423423+ core::cmp::Ordering::Less => Some((a, b)),
424424+ core::cmp::Ordering::Greater => Some((b, a)),
425425+ }
426426+ }
427427+428428+ pub fn select_all(&mut self) {
429429+ self.sel_anchor = Some((0, 0));
430430+ let end_idx = self.rope.len_chars();
431431+ self.set_caret_at_index(end_idx);
432432+ }
433433+434434+ // Clipboard-friendly operations (pure text; caller integrates with OS clipboard)
435435+ #[must_use]
436436+ pub fn copy_selection_text(&self) -> Option<String> {
437437+ let (s, e) = self.selection_range_idx()?;
438438+ Some(self.slice_to_string(s, e))
439439+ }
440440+441441+ pub fn cut_selection_text(&mut self) -> Option<String> {
442442+ let (s, e) = self.selection_range_idx()?;
443443+ let deleted = self.delete_range(s, e);
444444+ self.push_undo(EditKind::Delete {
445445+ index: s,
446446+ text: deleted.clone(),
447447+ });
448448+ self.clear_redo();
449449+ Some(deleted)
450450+ }
451451+452452+ pub fn paste_text(&mut self, text: &str) {
453453+ if self.has_selection() {
454454+ if let Some((s, e)) = self.selection_range_idx() {
455455+ let mut op = EditOp { ops: Vec::new() };
456456+ let deleted = self.delete_range(s, e);
457457+ op.ops.push(EditKind::Delete {
458458+ index: s,
459459+ text: deleted,
460460+ });
461461+ self.insert_text_at(s, text);
462462+ op.ops.push(EditKind::Insert {
463463+ index: s,
464464+ text: text.to_string(),
465465+ });
466466+ self.undo.push(op);
467467+ }
468468+ } else {
469469+ let idx = self.caret_char_index();
470470+ self.insert_text_at(idx, text);
471471+ self.push_undo(EditKind::Insert {
472472+ index: idx,
473473+ text: text.to_string(),
474474+ });
475475+ }
476476+ self.clear_redo();
477477+ }
478478+479479+ pub fn undo(&mut self) {
480480+ if let Some(op) = self.undo.pop() {
481481+ let redo_op = op.clone();
482482+ // Apply inverse in reverse order
483483+ for k in op.ops.iter().rev() {
484484+ match k {
485485+ EditKind::Insert { index, text } => {
486486+ let s = *index;
487487+ let e = s + text.chars().count();
488488+ let _ = self.delete_range(s, e);
489489+ self.set_caret_at_index(*index);
490490+ }
491491+ EditKind::Delete { index, text } => {
492492+ self.insert_text_at(*index, text);
493493+ self.set_caret_at_index(index + text.chars().count());
494494+ }
495495+ }
496496+ }
497497+ self.redo.push(redo_op);
498498+ self.clear_selection();
499499+ }
500500+ }
501501+502502+ pub fn redo(&mut self) {
503503+ if let Some(op) = self.redo.pop() {
504504+ let undo_op = op.clone();
505505+ for k in &op.ops {
506506+ match k {
507507+ EditKind::Insert { index, text } => {
508508+ self.insert_text_at(*index, text);
509509+ self.set_caret_at_index(index + text.chars().count());
510510+ }
511511+ EditKind::Delete { index, text } => {
512512+ let s = *index;
513513+ let e = s + text.chars().count();
514514+ let _ = self.delete_range(s, e);
515515+ self.set_caret_at_index(*index);
516516+ }
517517+ }
518518+ }
519519+ self.undo.push(undo_op);
520520+ self.clear_selection();
521521+ }
522522+ }
523523+524524+ // Internals --------------------------------------------------------------------------------
525525+ fn push_undo(&mut self, k: EditKind) {
526526+ self.undo.push(EditOp { ops: vec![k] });
527527+ }
528528+ fn clear_redo(&mut self) {
529529+ self.redo.clear();
530530+ }
531531+ fn line_col_to_index(&self, line: usize, col: usize) -> usize {
532532+ let base = self.rope.line_to_char(line);
533533+ base + col.min(self.line_len(line))
534534+ }
535535+ fn set_caret_at_index(&mut self, idx: usize) {
536536+ let line = self.rope.char_to_line(idx.min(self.rope.len_chars()));
537537+ let base = self.rope.line_to_char(line);
538538+ self.caret_line = line;
539539+ self.caret_col = idx.saturating_sub(base);
540540+ }
541541+ fn slice_to_string(&self, s: usize, e: usize) -> String {
542542+ self.rope.slice(s..e).to_string()
543543+ }
544544+ fn delete_range(&mut self, s: usize, e: usize) -> String {
545545+ let text = self.slice_to_string(s, e);
546546+ self.rope.remove(s..e);
547547+ self.set_caret_at_index(s);
548548+ self.clear_selection();
549549+ text
550550+ }
551551+ fn insert_text_at(&mut self, idx: usize, text: &str) {
552552+ self.rope.insert(idx, text);
553553+ let new_idx = idx + text.chars().count();
554554+ self.set_caret_at_index(new_idx);
555555+ self.clear_selection();
556556+ }
557557+ fn replace_selection_with(&mut self, text: &str) {
558558+ if let Some((s, e)) = self.selection_range_idx() {
559559+ let mut op = EditOp { ops: Vec::new() };
560560+ let deleted = self.delete_range(s, e);
561561+ op.ops.push(EditKind::Delete {
562562+ index: s,
563563+ text: deleted,
564564+ });
565565+ self.insert_text_at(s, text);
566566+ op.ops.push(EditKind::Insert {
567567+ index: s,
568568+ text: text.to_string(),
569569+ });
570570+ self.undo.push(op);
571571+ self.clear_redo();
572572+ } else {
573573+ self.paste_text(text);
574574+ }
180575 }
181576}
+83-5
tic80_rust/src/main.rs
···33333434use pixels::{Pixels, SurfaceTexture};
3535use winit::dpi::LogicalSize;
3636-use winit::event::{ElementState, Event, KeyboardInput, VirtualKeyCode, WindowEvent};
3636+use winit::event::{ElementState, Event, KeyboardInput, ModifiersState, VirtualKeyCode, WindowEvent};
3737use winit::event_loop::{ControlFlow, EventLoop};
3838use winit::window::{Window, WindowBuilder};
3939···351351 let mut last_cursor_fb: Option<(i32, i32)> = None;
352352 let mut frame_counter: u32 = 0;
353353 let mut one_off_saved = false;
354354+ let mut modifiers = ModifiersState::empty();
354355355356 #[allow(clippy::cognitive_complexity)]
356357 event_loop.run(move |event, _, control_flow| {
···394395 if let Err(e) = save_timestamped_screenshot(&args, &rgba, w, h) {
395396 eprintln!("Screenshot error: {e}");
396397 }
398398+ }
399399+ WindowEvent::ReceivedCharacter(ch) => {
400400+ if let (Some(ui), Some(cb)) = (editor_ui.as_ref(), code_buf.as_mut()) {
401401+ if ui.active == tic80_rust::editor::ui::Tab::Code {
402402+ // Ignore character input when Cmd/Ctrl is held (shortcut) to avoid inserting letters
403403+ if modifiers.ctrl() || modifiers.logo() {
404404+ return;
405405+ }
406406+ if ch == '\n' {
407407+ cb.insert_newline();
408408+ } else if ch == '\t' {
409409+ cb.insert_tab();
410410+ } else if !ch.is_control() {
411411+ cb.insert_char(ch);
412412+ }
413413+ }
414414+ }
415415+ }
416416+ WindowEvent::ModifiersChanged(m) => {
417417+ modifiers = m;
397418 }
398419 WindowEvent::KeyboardInput { input, .. } => {
399420 if let (Some(ui), Some(cb)) = (editor_ui.as_ref(), code_buf.as_mut()) {
···401422 && input.state == ElementState::Pressed
402423 {
403424 if let Some(key) = input.virtual_keycode {
425425+ // Prefer latest modifiers from event loop; fall back to key-based detection
426426+ #[allow(deprecated)]
427427+ let m = input.modifiers;
428428+ let shift = m.shift();
429429+ let ctrl = m.ctrl();
430430+ let cmd = m.logo();
431431+ // Shortcuts (cmd/ctrl)
432432+ if ctrl || cmd {
433433+ match key {
434434+ VirtualKeyCode::C => {
435435+ if let Some(text) = cb.copy_selection_text() {
436436+ let _ = set_clipboard_text(&text);
437437+ }
438438+ return;
439439+ }
440440+ VirtualKeyCode::X => {
441441+ if let Some(text) = cb.cut_selection_text() {
442442+ let _ = set_clipboard_text(&text);
443443+ }
444444+ return;
445445+ }
446446+ VirtualKeyCode::V => {
447447+ if let Ok(text) = get_clipboard_text() {
448448+ cb.paste_text(&text);
449449+ }
450450+ return;
451451+ }
452452+ VirtualKeyCode::Z => {
453453+ if shift { cb.redo(); } else { cb.undo(); }
454454+ return;
455455+ }
456456+ VirtualKeyCode::Y => { cb.redo(); return; }
457457+ VirtualKeyCode::A => { cb.select_all(); return; }
458458+ _ => {}
459459+ }
460460+ }
461461+ // Navigation and editing
404462 match key {
405405- VirtualKeyCode::Left => cb.move_left(),
406406- VirtualKeyCode::Right => cb.move_right(),
407407- VirtualKeyCode::Up => cb.move_up(),
408408- VirtualKeyCode::Down => cb.move_down(),
463463+ VirtualKeyCode::Left => { if shift { cb.ensure_selection_anchor(); } else { cb.clear_selection(); } cb.move_left(); },
464464+ VirtualKeyCode::Right => { if shift { cb.ensure_selection_anchor(); } else { cb.clear_selection(); } cb.move_right(); },
465465+ VirtualKeyCode::Up => { if shift { cb.ensure_selection_anchor(); } else { cb.clear_selection(); } cb.move_up(); },
466466+ VirtualKeyCode::Down => { if shift { cb.ensure_selection_anchor(); } else { cb.clear_selection(); } cb.move_down(); },
467467+ VirtualKeyCode::Back => { cb.backspace(); },
468468+ VirtualKeyCode::Delete => { cb.delete_forward(); },
469469+ VirtualKeyCode::Return => { cb.insert_newline(); },
470470+ VirtualKeyCode::Tab => { cb.insert_tab(); },
471471+ VirtualKeyCode::Home => { if shift { cb.ensure_selection_anchor(); } else { cb.clear_selection(); } cb.home(); },
472472+ VirtualKeyCode::End => { if shift { cb.ensure_selection_anchor(); } else { cb.clear_selection(); } cb.end(); },
409473 _ => {}
410474 }
411475 }
···664728 save_screenshot_from_rgba(args, &rgba)?;
665729 Ok(())
666730}
731731+732732+// -- Clipboard helpers -------------------------------------------------------------------------
733733+734734+fn set_clipboard_text(text: &str) -> anyhow::Result<()> {
735735+ let mut cb = arboard::Clipboard::new()?;
736736+ cb.set_text(text.to_string())?;
737737+ Ok(())
738738+}
739739+740740+fn get_clipboard_text() -> anyhow::Result<String> {
741741+ let mut cb = arboard::Clipboard::new()?;
742742+ let t = cb.get_text()?;
743743+ Ok(t)
744744+}
-1
tic80_rust/tests/e2e_headless_cli.rs
···4747 run_and_capture(&args, &p);
4848 let _ = std::fs::remove_file(&p);
4949}
5050-
+62
tic80_rust/tests/editor_editing_tests.rs
···11+use tic80_rust::editor::code::CodeBuffer;
22+33+#[test]
44+fn insert_chars_and_newlines() {
55+ let mut cb = CodeBuffer::from_text("");
66+ cb.insert_char('a');
77+ cb.insert_char('b');
88+ cb.insert_newline();
99+ cb.insert_char('c');
1010+ cb.insert_char('d');
1111+ assert_eq!(cb.as_string(), "ab\ncd");
1212+}
1313+1414+#[test]
1515+fn backspace_within_line_and_join_previous() {
1616+ let mut cb = CodeBuffer::from_text("hello\nworld");
1717+ // Place caret after 'l' in first line
1818+ cb.caret_line = 0;
1919+ cb.caret_col = 3; // hel|lo
2020+ cb.backspace(); // remove 'l'
2121+ assert_eq!(cb.as_string(), "helo\nworld");
2222+ // Move to start of second line and backspace -> join
2323+ cb.caret_line = 1;
2424+ cb.caret_col = 0;
2525+ cb.backspace();
2626+ assert_eq!(cb.as_string(), "heloworld");
2727+ assert_eq!(cb.caret_line, 0);
2828+}
2929+3030+#[test]
3131+fn delete_forward_and_join_next_line() {
3232+ let mut cb = CodeBuffer::from_text("xy\nzz");
3333+ // place at end of first line and delete -> join lines
3434+ cb.caret_line = 0;
3535+ cb.caret_col = 2; // xy|\nzz
3636+ cb.delete_forward();
3737+ assert_eq!(cb.as_string(), "xyzz");
3838+ // delete in middle
3939+ cb.caret_line = 0;
4040+ cb.caret_col = 1; // x|yzz
4141+ cb.delete_forward();
4242+ assert_eq!(cb.as_string(), "xzz");
4343+}
4444+4545+#[test]
4646+fn home_and_end() {
4747+ let mut cb = CodeBuffer::from_text("abc\ndef");
4848+ cb.caret_line = 1;
4949+ cb.caret_col = 1;
5050+ cb.home();
5151+ assert_eq!(cb.caret_col, 0);
5252+ cb.end();
5353+ assert_eq!(cb.caret_col, 3);
5454+}
5555+5656+#[test]
5757+fn insert_tab_inserts_two_spaces() {
5858+ let mut cb = CodeBuffer::from_text("");
5959+ cb.insert_tab();
6060+ cb.insert_char('x');
6161+ assert_eq!(cb.as_string(), " x");
6262+}
+35
tic80_rust/tests/editor_selection_align_tests.rs
···11+use std::cell::RefCell;
22+use std::rc::Rc;
33+44+use tic80_rust::editor::code::{Area, CodeBuffer};
55+use tic80_rust::gfx::framebuffer::Framebuffer;
66+77+// Verify selection top aligns with caret fill top (one px above text baseline)
88+#[test]
99+fn selection_aligned_with_caret_box() {
1010+ let text = "a def\n";
1111+ let mut cb = CodeBuffer::from_text(text);
1212+ // Prepare selection from col 1 to col 3 on line 0
1313+ cb.caret_line = 0;
1414+ cb.caret_col = 1;
1515+ cb.start_selection();
1616+ cb.caret_col = 3; // selection [1,3)
1717+ // Also place caret for box at col 3
1818+ let fb = Rc::new(RefCell::new(Framebuffer::new()));
1919+ let mut fbb = fb.borrow_mut();
2020+ let area = Area { x: 0, y: 12, w: 240, h: 124 };
2121+ cb.draw(&mut fbb, area);
2222+2323+ // Expected coordinates
2424+ let gutter_w = 24i32;
2525+ let row = 0i32; // first line
2626+ let gutter_y = area.y + row * 8;
2727+ let caret_fill_top = (gutter_y - 1).max(area.y); // caret fills 7px starting 1px above baseline, clipped to area
2828+ // Selection starts at col 1 (from) over a space (no glyph ink)
2929+ let sel_x = area.x + gutter_w + 6;
3030+ let sel_y = caret_fill_top;
3131+3232+ // Sample a pixel inside selection highlight
3333+ let c = fbb.pix(sel_x + 1, sel_y, None).unwrap_or(0);
3434+ assert_eq!(c, 14, "expected selection color at aligned top row");
3535+}