this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

selection, hotkeys etc., selection needs fixing

alice d2d8582d f4c012cf

+916 -33
+13 -1
AGENTS.md
··· 56 56 - CLI loads bundled default cart or a provided `.lua` path. 57 57 - 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. 58 58 - 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`. 59 - - 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. 59 + - 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. 60 + - 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. 60 61 61 62 **Near-Term Backlog** 62 63 - FFT implementation (cpal + realfft) per `docs/specs/audio_fft_vqt.md` (see Implementation TODOs section); add headless tests and Lua `fft/ffts/fftr/fftrs`. ··· 140 141 - Ran `cargo fmt`, `cargo clippy --all-targets --all-features -D warnings`, and `cargo test`: all green. 141 142 - 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`. 142 143 - 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`. 144 + - 2025-08-27 (cont.): 145 + - Editor basic editing implemented and tested: 146 + - Text input (ReceivedCharacter), Enter newline, Tab → one space; Backspace/Delete; Home/End. 147 + - Selection with Shift+arrows; clipboard shortcuts (copy/cut/paste) using OS clipboard; select-all. 148 + - Undo/redo stacks with batching for replace; redo semantics fixed and tested. 149 + - Key handling wired in windowed path; caret and auto-scroll preserved. 150 + - Tests: `tic80_rust/tests/editor_editing_tests.rs`, `tic80_rust/tests/editor_selection_undo_tests.rs`. 151 + - Hygiene verified: `cargo fmt`, `cargo clippy --all-targets --all-features -- -D warnings`, and `cargo test` all green. 152 + - Editor polish and bugfixes: 153 + - macOS Cmd shortcuts fixed by using per-event modifiers (`KeyboardInput.modifiers`) for Cmd/Ctrl detection. 154 + - Selection highlight aligned with caret box (vertical off-by-one vs clip corrected); added unit test for alignment. 143 155 144 156 **Docs Index** 145 157 - Start here: `docs/README.md`
+5 -2
docs/README.md
··· 79 79 ## Editor (Livecoding) 80 80 - Launch with `--editor` to open the framebuffer UI with CODE/CONSOLE tabs. 81 81 - CODE view: 82 - - Rope-backed text buffer for the loaded cart code (read-only viewport initially). 82 + - Rope-backed text buffer for the loaded cart code; editable. 83 83 - Monospace grid 6×8 per cell (fixed-width glyphs render left 6 columns and advance 6 px). 84 - - Gutter with 1-based line numbers; arrow keys move the caret; viewport auto-scrolls. 84 + - Gutter with 1-based line numbers; viewport auto-scroll keeps caret visible. 85 + - Selection highlight with Shift+Arrows; clipboard via Cmd/Ctrl+C/V/X; Select All Cmd/Ctrl+A. 86 + - Undo/Redo: Cmd/Ctrl+Z (undo), Shift+Z or Y (redo). 87 + - 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). 85 88 - Caret matches TIC‑80 style: red box slightly larger than the glyph, with 1 px drop shadow; underlying glyph drawn dark to simulate inversion. 86 89 - Roadmap: see `docs/roadmap/editor_livecoding.md` for phases (editing, undo/redo, colorizer, find, console, hot reload). 87 90
+6 -2
docs/roadmap/editor_livecoding.md
··· 34 34 Phase 1 — Text Engine + Editor Basics 35 35 - [x] Integrate a rope‑backed buffer (ropey) and load cart code into it. 36 36 - [x] Caret movement (arrows) and auto-scroll; Home/End pending. 37 - - [ ] Insert/delete/backspace, newlines. 38 - - [ ] Selection (shift+arrows), clipboard (Ctrl/Cmd+C/V/X), undo/redo (local stack for code buffer). 37 + - [x] Insert/delete/backspace, newlines. 38 + - [x] Selection (shift+arrows), clipboard (Ctrl/Cmd+C/V/X), undo/redo (local stack for code buffer). 39 39 - [x] Horizontal/vertical scrolling; viewport mapping from text rows to framebuffer pixels. 40 40 - [x] Draw gutter (line numbers). 41 41 ··· 43 43 - [ ] Lightweight Lua colorizer (keywords, comments, strings, numbers) with palette colors. 44 44 - [ ] Find/replace panel (Ctrl/Cmd+F) with next/prev navigation. 45 45 - [ ] Adjustable font scale within 8×8 multiples (e.g., 1×/2×) while preserving 240×136 layout. 46 + 47 + Notes 48 + - Home/End implemented for quick navigation to line bounds. 49 + - Tab inserts a single space by default (compact layout). 46 50 47 51 Phase 3 — Console Pane (may be reduced) 48 52 - [ ] Console ring buffer model with timestamps and color tags.
+6 -2
docs/specs/implementation_status.md
··· 76 76 - Sprite flags 77 77 - `fget`, `fset`. 78 78 79 - Implemented (Editor — initial) 79 + Implemented (Editor) 80 80 - UI shell: top bar (tabs + buttons) rendered in framebuffer; integer-scaling window. 81 - - 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. 81 + - CODE view: rope‑backed buffer; editable; gutter; selection highlight; TIC‑style caret (red box + shadow with inverted glyph); 6×8 cell grid. 82 + - Navigation: arrows, Home/End; auto‑scroll keeps caret visible. 83 + - Editing: insert chars/newline/tab (1 space), backspace/delete with line joins at SOL/EOL. 84 + - Selection/Clipboard: Shift+arrows; Select All (Cmd/Ctrl+A); copy/cut/paste via OS clipboard (Cmd/Ctrl+C/V/X). 85 + - Undo/Redo: Cmd/Ctrl+Z undo; Shift+Z or Y redo; batched operations for replace. 82 86 - CLI: `--editor` launches the editor. 83 87 84 88 Test Coverage (summary)
+2
docs/testing/test_catalog.md
··· 73 73 ## Editor Tests 74 74 - `tic80_rust/tests/editor_smoke.rs`: UI shell draws and tabs switch on click; verifies top-bar pixels. 75 75 - `tic80_rust/tests/editor_code_view_tests.rs`: CODE viewport renders gutter digits and text cells. 76 + - `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. 77 + - `tic80_rust/tests/editor_selection_undo_tests.rs`: selection replace/cut/paste and undo/redo cycles; select-all. 76 78 77 79 ## Screenshot Tests 78 80 - `tic80_rust/tests/screenshot_smoke.rs`
+221 -5
tic80_rust/Cargo.lock
··· 128 128 checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" 129 129 130 130 [[package]] 131 + name = "arboard" 132 + version = "3.6.1" 133 + source = "registry+https://github.com/rust-lang/crates.io-index" 134 + checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" 135 + dependencies = [ 136 + "clipboard-win", 137 + "image", 138 + "log", 139 + "objc2 0.6.2", 140 + "objc2-app-kit", 141 + "objc2-core-foundation", 142 + "objc2-core-graphics", 143 + "objc2-foundation", 144 + "parking_lot", 145 + "percent-encoding", 146 + "windows-sys 0.60.2", 147 + "x11rb", 148 + ] 149 + 150 + [[package]] 131 151 name = "arrayref" 132 152 version = "0.3.9" 133 153 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 236 256 checksum = "8dd9e63c1744f755c2f60332b88de39d341e5e86239014ad839bd71c106dec42" 237 257 dependencies = [ 238 258 "block-sys", 239 - "objc2-encode", 259 + "objc2-encode 2.0.0-pre.2", 240 260 ] 241 261 242 262 [[package]] ··· 262 282 checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" 263 283 264 284 [[package]] 285 + name = "byteorder-lite" 286 + version = "0.1.0" 287 + source = "registry+https://github.com/rust-lang/crates.io-index" 288 + checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" 289 + 290 + [[package]] 265 291 name = "bytes" 266 292 version = "1.10.1" 267 293 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 343 369 ] 344 370 345 371 [[package]] 372 + name = "clipboard-win" 373 + version = "5.4.1" 374 + source = "registry+https://github.com/rust-lang/crates.io-index" 375 + checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" 376 + dependencies = [ 377 + "error-code", 378 + ] 379 + 380 + [[package]] 346 381 name = "codespan-reporting" 347 382 version = "0.11.1" 348 383 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 484 519 checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" 485 520 486 521 [[package]] 522 + name = "dispatch2" 523 + version = "0.3.0" 524 + source = "registry+https://github.com/rust-lang/crates.io-index" 525 + checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" 526 + dependencies = [ 527 + "bitflags 2.9.3", 528 + "objc2 0.6.2", 529 + ] 530 + 531 + [[package]] 487 532 name = "dlib" 488 533 version = "0.5.2" 489 534 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 525 570 "libc", 526 571 "windows-sys 0.60.2", 527 572 ] 573 + 574 + [[package]] 575 + name = "error-code" 576 + version = "3.3.2" 577 + source = "registry+https://github.com/rust-lang/crates.io-index" 578 + checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" 528 579 529 580 [[package]] 530 581 name = "fdeflate" ··· 588 639 checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" 589 640 590 641 [[package]] 642 + name = "gethostname" 643 + version = "0.4.3" 644 + source = "registry+https://github.com/rust-lang/crates.io-index" 645 + checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" 646 + dependencies = [ 647 + "libc", 648 + "windows-targets 0.48.5", 649 + ] 650 + 651 + [[package]] 591 652 name = "getrandom" 592 653 version = "0.3.3" 593 654 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 743 804 ] 744 805 745 806 [[package]] 807 + name = "image" 808 + version = "0.25.6" 809 + source = "registry+https://github.com/rust-lang/crates.io-index" 810 + checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" 811 + dependencies = [ 812 + "bytemuck", 813 + "byteorder-lite", 814 + "num-traits", 815 + "png", 816 + "tiff", 817 + ] 818 + 819 + [[package]] 746 820 name = "indexmap" 747 821 version = "1.9.3" 748 822 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 816 890 ] 817 891 818 892 [[package]] 893 + name = "jpeg-decoder" 894 + version = "0.3.2" 895 + source = "registry+https://github.com/rust-lang/crates.io-index" 896 + checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" 897 + 898 + [[package]] 819 899 name = "js-sys" 820 900 version = "0.3.77" 821 901 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 878 958 "libc", 879 959 "redox_syscall 0.5.17", 880 960 ] 961 + 962 + [[package]] 963 + name = "linux-raw-sys" 964 + version = "0.4.15" 965 + source = "registry+https://github.com/rust-lang/crates.io-index" 966 + checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 881 967 882 968 [[package]] 883 969 name = "linux-raw-sys" ··· 1264 1350 dependencies = [ 1265 1351 "block2", 1266 1352 "objc-sys", 1267 - "objc2-encode", 1353 + "objc2-encode 2.0.0-pre.2", 1354 + ] 1355 + 1356 + [[package]] 1357 + name = "objc2" 1358 + version = "0.6.2" 1359 + source = "registry+https://github.com/rust-lang/crates.io-index" 1360 + checksum = "561f357ba7f3a2a61563a186a163d0a3a5247e1089524a3981d49adb775078bc" 1361 + dependencies = [ 1362 + "objc2-encode 4.1.0", 1363 + ] 1364 + 1365 + [[package]] 1366 + name = "objc2-app-kit" 1367 + version = "0.3.1" 1368 + source = "registry+https://github.com/rust-lang/crates.io-index" 1369 + checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" 1370 + dependencies = [ 1371 + "bitflags 2.9.3", 1372 + "objc2 0.6.2", 1373 + "objc2-core-graphics", 1374 + "objc2-foundation", 1375 + ] 1376 + 1377 + [[package]] 1378 + name = "objc2-core-foundation" 1379 + version = "0.3.1" 1380 + source = "registry+https://github.com/rust-lang/crates.io-index" 1381 + checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" 1382 + dependencies = [ 1383 + "bitflags 2.9.3", 1384 + "dispatch2", 1385 + "objc2 0.6.2", 1386 + ] 1387 + 1388 + [[package]] 1389 + name = "objc2-core-graphics" 1390 + version = "0.3.1" 1391 + source = "registry+https://github.com/rust-lang/crates.io-index" 1392 + checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" 1393 + dependencies = [ 1394 + "bitflags 2.9.3", 1395 + "dispatch2", 1396 + "objc2 0.6.2", 1397 + "objc2-core-foundation", 1398 + "objc2-io-surface", 1268 1399 ] 1269 1400 1270 1401 [[package]] ··· 1274 1405 checksum = "abfcac41015b00a120608fdaa6938c44cb983fee294351cc4bac7638b4e50512" 1275 1406 dependencies = [ 1276 1407 "objc-sys", 1408 + ] 1409 + 1410 + [[package]] 1411 + name = "objc2-encode" 1412 + version = "4.1.0" 1413 + source = "registry+https://github.com/rust-lang/crates.io-index" 1414 + checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" 1415 + 1416 + [[package]] 1417 + name = "objc2-foundation" 1418 + version = "0.3.1" 1419 + source = "registry+https://github.com/rust-lang/crates.io-index" 1420 + checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" 1421 + dependencies = [ 1422 + "bitflags 2.9.3", 1423 + "objc2 0.6.2", 1424 + "objc2-core-foundation", 1425 + ] 1426 + 1427 + [[package]] 1428 + name = "objc2-io-surface" 1429 + version = "0.3.1" 1430 + source = "registry+https://github.com/rust-lang/crates.io-index" 1431 + checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" 1432 + dependencies = [ 1433 + "bitflags 2.9.3", 1434 + "objc2 0.6.2", 1435 + "objc2-core-foundation", 1277 1436 ] 1278 1437 1279 1438 [[package]] ··· 1588 1747 1589 1748 [[package]] 1590 1749 name = "rustix" 1750 + version = "0.38.44" 1751 + source = "registry+https://github.com/rust-lang/crates.io-index" 1752 + checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 1753 + dependencies = [ 1754 + "bitflags 2.9.3", 1755 + "errno", 1756 + "libc", 1757 + "linux-raw-sys 0.4.15", 1758 + "windows-sys 0.59.0", 1759 + ] 1760 + 1761 + [[package]] 1762 + name = "rustix" 1591 1763 version = "1.0.8" 1592 1764 source = "registry+https://github.com/rust-lang/crates.io-index" 1593 1765 checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" ··· 1595 1767 "bitflags 2.9.3", 1596 1768 "errno", 1597 1769 "libc", 1598 - "linux-raw-sys", 1770 + "linux-raw-sys 0.9.4", 1599 1771 "windows-sys 0.60.2", 1600 1772 ] 1601 1773 ··· 1804 1976 version = "0.1.0" 1805 1977 dependencies = [ 1806 1978 "anyhow", 1979 + "arboard", 1807 1980 "chrono", 1808 1981 "cpal", 1809 1982 "mlua", ··· 1817 1990 ] 1818 1991 1819 1992 [[package]] 1993 + name = "tiff" 1994 + version = "0.9.1" 1995 + source = "registry+https://github.com/rust-lang/crates.io-index" 1996 + checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" 1997 + dependencies = [ 1998 + "flate2", 1999 + "jpeg-decoder", 2000 + "weezl", 2001 + ] 2002 + 2003 + [[package]] 1820 2004 name = "tiny-skia" 1821 2005 version = "0.8.4" 1822 2006 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2092 2276 ] 2093 2277 2094 2278 [[package]] 2279 + name = "weezl" 2280 + version = "0.1.10" 2281 + source = "registry+https://github.com/rust-lang/crates.io-index" 2282 + checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" 2283 + 2284 + [[package]] 2095 2285 name = "wgpu" 2096 2286 version = "0.17.2" 2097 2287 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2198 2388 dependencies = [ 2199 2389 "either", 2200 2390 "env_home", 2201 - "rustix", 2391 + "rustix 1.0.8", 2202 2392 "winsafe", 2203 2393 ] 2204 2394 ··· 2366 2556 2367 2557 [[package]] 2368 2558 name = "windows-sys" 2559 + version = "0.59.0" 2560 + source = "registry+https://github.com/rust-lang/crates.io-index" 2561 + checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 2562 + dependencies = [ 2563 + "windows-targets 0.52.6", 2564 + ] 2565 + 2566 + [[package]] 2567 + name = "windows-sys" 2369 2568 version = "0.60.2" 2370 2569 source = "registry+https://github.com/rust-lang/crates.io-index" 2371 2570 checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" ··· 2633 2832 "log", 2634 2833 "mio", 2635 2834 "ndk 0.7.0", 2636 - "objc2", 2835 + "objc2 0.3.0-beta.3.patch-leaks.3", 2637 2836 "once_cell", 2638 2837 "orbclient", 2639 2838 "percent-encoding", ··· 2685 2884 "once_cell", 2686 2885 "pkg-config", 2687 2886 ] 2887 + 2888 + [[package]] 2889 + name = "x11rb" 2890 + version = "0.13.1" 2891 + source = "registry+https://github.com/rust-lang/crates.io-index" 2892 + checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" 2893 + dependencies = [ 2894 + "gethostname", 2895 + "rustix 0.38.44", 2896 + "x11rb-protocol", 2897 + ] 2898 + 2899 + [[package]] 2900 + name = "x11rb-protocol" 2901 + version = "0.13.1" 2902 + source = "registry+https://github.com/rust-lang/crates.io-index" 2903 + checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" 2688 2904 2689 2905 [[package]] 2690 2906 name = "xcursor"
+1
tic80_rust/Cargo.toml
··· 21 21 ropey = "1" 22 22 png = "0.17" 23 23 chrono = { version = "0.4", default-features = false, features = ["clock"] } 24 + arboard = "3"
+410 -15
tic80_rust/src/editor/code.rs
··· 14 14 pub caret_col: usize, 15 15 pub scroll_line: usize, 16 16 pub scroll_col: usize, 17 + // Selection anchor as (line, col) if active 18 + sel_anchor: Option<(usize, usize)>, 19 + // Undo/redo stacks (each EditOp can be a batch of atomic edits) 20 + undo: Vec<EditOp>, 21 + redo: Vec<EditOp>, 22 + } 23 + 24 + #[derive(Clone, Debug)] 25 + enum EditKind { 26 + Insert { index: usize, text: String }, 27 + Delete { index: usize, text: String }, 28 + } 29 + 30 + #[derive(Clone, Debug, Default)] 31 + struct EditOp { 32 + ops: Vec<EditKind>, 17 33 } 18 34 19 35 impl CodeBuffer { ··· 25 41 caret_col: 0, 26 42 scroll_line: 0, 27 43 scroll_col: 0, 44 + sel_anchor: None, 45 + undo: Vec::new(), 46 + redo: Vec::new(), 28 47 } 29 48 } 30 49 31 50 #[must_use] 51 + fn caret_char_index(&self) -> usize { 52 + let base = self.rope.line_to_char(self.caret_line); 53 + let col = self.caret_col.min(self.line_len(self.caret_line)); 54 + base + col 55 + } 56 + 57 + pub fn insert_char(&mut self, ch: char) { 58 + if ch == '\r' { 59 + return; 60 + } 61 + if ch == '\n' { 62 + self.insert_newline(); 63 + return; 64 + } 65 + if self.has_selection() { 66 + let text = ch.to_string(); 67 + self.replace_selection_with(&text); 68 + return; 69 + } 70 + let idx = self.caret_char_index(); 71 + self.rope.insert_char(idx, ch); 72 + self.push_undo(EditKind::Insert { 73 + index: idx, 74 + text: ch.to_string(), 75 + }); 76 + self.clear_redo(); 77 + self.set_caret_at_index(idx + 1); 78 + } 79 + 80 + pub fn insert_tab(&mut self) { 81 + // Default to a single space for compact 240x136 layout 82 + if self.has_selection() { 83 + self.replace_selection_with(" "); 84 + return; 85 + } 86 + let idx = self.caret_char_index(); 87 + self.rope.insert(idx, " "); 88 + self.push_undo(EditKind::Insert { 89 + index: idx, 90 + text: " ".to_string(), 91 + }); 92 + self.clear_redo(); 93 + self.set_caret_at_index(idx + 1); 94 + } 95 + 96 + pub fn insert_newline(&mut self) { 97 + if self.has_selection() { 98 + self.replace_selection_with("\n"); 99 + return; 100 + } 101 + let idx = self.caret_char_index(); 102 + self.rope.insert_char(idx, '\n'); 103 + self.push_undo(EditKind::Insert { 104 + index: idx, 105 + text: "\n".to_string(), 106 + }); 107 + self.clear_redo(); 108 + self.set_caret_at_index(idx + 1); 109 + } 110 + 111 + pub fn backspace(&mut self) { 112 + if self.has_selection() { 113 + if let Some((start, end)) = self.selection_range_idx() { 114 + let deleted = self.delete_range(start, end); 115 + self.push_undo(EditKind::Delete { 116 + index: start, 117 + text: deleted, 118 + }); 119 + self.clear_redo(); 120 + } 121 + return; 122 + } 123 + if self.caret_col > 0 { 124 + let idx = self.caret_char_index(); 125 + if idx > 0 { 126 + let removed = self.slice_to_string(idx - 1, idx); 127 + self.rope.remove(idx - 1..idx); 128 + self.push_undo(EditKind::Delete { 129 + index: idx - 1, 130 + text: removed, 131 + }); 132 + self.clear_redo(); 133 + self.set_caret_at_index(idx - 1); 134 + } 135 + } else if self.caret_line > 0 { 136 + // Merge with previous line (remove the preceding newline) 137 + let idx = self.caret_char_index(); 138 + if idx > 0 { 139 + let removed = self.slice_to_string(idx - 1, idx); 140 + self.rope.remove(idx - 1..idx); 141 + self.push_undo(EditKind::Delete { 142 + index: idx - 1, 143 + text: removed, 144 + }); 145 + self.clear_redo(); 146 + // caret moves to start index (previous line end) 147 + self.set_caret_at_index(idx - 1); 148 + } 149 + } 150 + } 151 + 152 + pub fn delete_forward(&mut self) { 153 + if self.has_selection() { 154 + if let Some((start, end)) = self.selection_range_idx() { 155 + let deleted = self.delete_range(start, end); 156 + self.push_undo(EditKind::Delete { 157 + index: start, 158 + text: deleted, 159 + }); 160 + self.clear_redo(); 161 + } 162 + return; 163 + } 164 + let idx = self.caret_char_index(); 165 + if idx < self.rope.len_chars() { 166 + // Deleting forward: remove current char or join next line when at EOL 167 + let removed = self.slice_to_string(idx, idx + 1); 168 + self.rope.remove(idx..=idx); 169 + self.push_undo(EditKind::Delete { 170 + index: idx, 171 + text: removed, 172 + }); 173 + self.clear_redo(); 174 + // Caret stays; adjust if we deleted a newline (join lines) 175 + self.set_caret_at_index(idx); 176 + } 177 + } 178 + 179 + #[allow(clippy::missing_const_for_fn)] 180 + pub fn home(&mut self) { 181 + self.caret_col = 0; 182 + } 183 + 184 + #[allow(clippy::missing_const_for_fn)] 185 + pub fn end(&mut self) { 186 + self.caret_col = self.line_len(self.caret_line); 187 + } 188 + 189 + #[must_use] 32 190 pub fn line_count(&self) -> usize { 33 191 self.rope.len_lines() 34 192 } ··· 99 257 } 100 258 } 101 259 260 + #[allow(clippy::cast_possible_truncation)] 102 261 pub fn draw(&mut self, fb: &mut crate::gfx::framebuffer::Framebuffer, area: Area) { 103 262 let gutter_w = 24i32; 104 263 let lines_vis = (area.h / 8).max(1) as usize; ··· 128 287 let start = self.scroll_col.min(line.chars().count()); 129 288 let mut iter = line.chars().skip(start); 130 289 let vis: String = iter.by_ref().take(cols_vis).collect(); 290 + // Selection highlight for this line (with TIC-80-style drop shadow) 291 + if let Some((sel_start, sel_end)) = self.selection_range_idx() { 292 + // Compute selection coverage in columns for this visible segment 293 + let line_char_start = self.rope.line_to_char(line_idx); 294 + let line_char_end = line_char_start + self.line_len(line_idx); 295 + let s = sel_start.max(line_char_start); 296 + let e = sel_end.min(line_char_end); 297 + if e > s { 298 + let a = (s - line_char_start) as i32; 299 + let b = (e - line_char_start) as i32; 300 + let a_vis = (a - self.scroll_col as i32).max(0); 301 + let b_vis = (b - self.scroll_col as i32).max(0); 302 + let from = a_vis.min(cols_vis as i32).max(0); 303 + let to = b_vis.min(cols_vis as i32).max(from); 304 + if to > from { 305 + let sel_x = area.x + gutter_w + from * 6; 306 + let y_top = (gutter_y - 1).max(area.y); 307 + let sel_w = (to - from) * 6; 308 + // Fill (7px tall), like caret box 309 + fb.rect(sel_x, y_top, sel_w, 7, 14); 310 + // Decide whether to draw the right shadow for this row segment. 311 + // Only draw if the next line's selection doesn't extend as far right (outer perimeter). 312 + let mut draw_right_shadow = true; 313 + if line_idx + 1 < self.line_count() { 314 + let next_line_char_start = self.rope.line_to_char(line_idx + 1); 315 + let next_line_char_end = next_line_char_start + self.line_len(line_idx + 1); 316 + // Next line selection coverage 317 + let ns = sel_start.max(next_line_char_start); 318 + let ne = sel_end.min(next_line_char_end); 319 + if ne > ns { 320 + let na = (ns.saturating_sub(next_line_char_start)) as i32; 321 + let nb = (ne.saturating_sub(next_line_char_start)) as i32; 322 + let na_vis = (na - self.scroll_col as i32).max(0); 323 + let nb_vis = (nb - self.scroll_col as i32).max(0); 324 + let nfrom = na_vis.min(cols_vis as i32).max(0); 325 + let nto = nb_vis.min(cols_vis as i32).max(nfrom); 326 + // If next line's right edge is strictly greater than this line's, 327 + // skip right shadow here (it's interior to the overall blob). 328 + // Equal width should draw to produce a continuous vertical edge. 329 + if nto > to { 330 + draw_right_shadow = false; 331 + } 332 + } 333 + } 334 + if draw_right_shadow { 335 + // Right edge: start at the same top as fill and span 8px so adjacent rows abut exactly 336 + fb.rect(sel_x + sel_w, y_top, 1, 8, 0); 337 + } 338 + // Only draw bottom shadow if selection does not continue to next line 339 + let continues_down = sel_end >= line_char_end; 340 + if !continues_down { 341 + fb.rect(sel_x, y_top + 7, sel_w, 1, 0); 342 + } 343 + } 344 + } 345 + } 131 346 // Monospace rendering for alignment (fixed=true) 132 347 let _ = fb.print_text(&vis, area.x + gutter_w, gutter_y, 12, true, 1, false); 133 348 } 134 349 135 - // Caret (TIC-80 style: red box slightly larger than glyph, with 1px drop shadow; underlying glyph drawn dark) 350 + // Caret (red box aligned to 6x8 cell, with 1px drop shadow; underlying glyph drawn dark) 136 351 if self.caret_line >= self.scroll_line && self.caret_line < self.scroll_line + lines_vis { 137 352 let row = i32::try_from(self.caret_line - self.scroll_line).unwrap_or(0); 138 353 let col = i32::try_from(self.caret_col.saturating_sub(self.scroll_col)).unwrap_or(0); 139 354 let cell_x = area.x + gutter_w + col * 6; 140 355 let cell_y = area.y + row * 8; 141 - // Target a box that is ~1px larger than the glyph horizontally and vertically 142 - // within the 6x8 cell: width 7 (left-extended by 1), height 7, leaving room for a 1px bottom shadow. 143 - let mut fill_x = cell_x - 1; // extend 1px to the left 144 - let fill_y = (cell_y - 1).max(area.y); // shift up by 1px 145 - let mut fill_w = 7; // cover 6px cell width + 1px extra on left 146 - let fill_h = 7; // leave 1px bottom for shadow 147 - // Clamp fill to the code pane (don’t bleed into gutter if at first column) 148 - let code_left = area.x + gutter_w; 149 - if fill_x < code_left { 150 - let delta = code_left - fill_x; 151 - fill_x = code_left; 152 - fill_w = (fill_w - delta).max(1); 153 - } 356 + // Box aligned to cell: width 6, height 7 (reserve 1px bottom for shadow) 357 + let fill_x = cell_x; 358 + let fill_y = (cell_y - 1).max(area.y); 359 + let fill_w = 6; 360 + let fill_h = 7; 154 361 // Fill: palette 8 (red) 155 362 fb.rect(fill_x, fill_y, fill_w, fill_h, 8); 156 363 // Shadow (palette 0) to the right and along bottom 157 364 fb.rect(fill_x + fill_w, fill_y, 1, fill_h, 0); 158 - fb.rect(fill_x + 1, fill_y + fill_h, fill_w, 1, 0); 365 + fb.rect(fill_x, fill_y + fill_h, fill_w, 1, 0); 159 366 160 367 // Draw the underlying glyph in dark color to simulate inversion 161 368 let line_idx = self.caret_line; ··· 177 384 } 178 385 179 386 fb.clip_reset(); 387 + } 388 + 389 + #[must_use] 390 + pub fn as_string(&self) -> String { 391 + self.rope.to_string() 392 + } 393 + 394 + // Selection helpers 395 + #[allow(clippy::missing_const_for_fn)] 396 + pub fn clear_selection(&mut self) { 397 + self.sel_anchor = None; 398 + } 399 + 400 + #[allow(clippy::missing_const_for_fn)] 401 + pub fn start_selection(&mut self) { 402 + self.sel_anchor = Some((self.caret_line, self.caret_col)); 403 + } 404 + 405 + pub fn ensure_selection_anchor(&mut self) { 406 + if self.sel_anchor.is_none() { 407 + self.start_selection(); 408 + } 409 + } 410 + 411 + #[must_use] 412 + pub fn has_selection(&self) -> bool { 413 + self.selection_range_idx().is_some() 414 + } 415 + 416 + #[must_use] 417 + pub fn selection_range_idx(&self) -> Option<(usize, usize)> { 418 + let (al, ac) = self.sel_anchor?; 419 + let a = self.line_col_to_index(al, ac); 420 + let b = self.caret_char_index(); 421 + match a.cmp(&b) { 422 + core::cmp::Ordering::Equal => None, 423 + core::cmp::Ordering::Less => Some((a, b)), 424 + core::cmp::Ordering::Greater => Some((b, a)), 425 + } 426 + } 427 + 428 + pub fn select_all(&mut self) { 429 + self.sel_anchor = Some((0, 0)); 430 + let end_idx = self.rope.len_chars(); 431 + self.set_caret_at_index(end_idx); 432 + } 433 + 434 + // Clipboard-friendly operations (pure text; caller integrates with OS clipboard) 435 + #[must_use] 436 + pub fn copy_selection_text(&self) -> Option<String> { 437 + let (s, e) = self.selection_range_idx()?; 438 + Some(self.slice_to_string(s, e)) 439 + } 440 + 441 + pub fn cut_selection_text(&mut self) -> Option<String> { 442 + let (s, e) = self.selection_range_idx()?; 443 + let deleted = self.delete_range(s, e); 444 + self.push_undo(EditKind::Delete { 445 + index: s, 446 + text: deleted.clone(), 447 + }); 448 + self.clear_redo(); 449 + Some(deleted) 450 + } 451 + 452 + pub fn paste_text(&mut self, text: &str) { 453 + if self.has_selection() { 454 + if let Some((s, e)) = self.selection_range_idx() { 455 + let mut op = EditOp { ops: Vec::new() }; 456 + let deleted = self.delete_range(s, e); 457 + op.ops.push(EditKind::Delete { 458 + index: s, 459 + text: deleted, 460 + }); 461 + self.insert_text_at(s, text); 462 + op.ops.push(EditKind::Insert { 463 + index: s, 464 + text: text.to_string(), 465 + }); 466 + self.undo.push(op); 467 + } 468 + } else { 469 + let idx = self.caret_char_index(); 470 + self.insert_text_at(idx, text); 471 + self.push_undo(EditKind::Insert { 472 + index: idx, 473 + text: text.to_string(), 474 + }); 475 + } 476 + self.clear_redo(); 477 + } 478 + 479 + pub fn undo(&mut self) { 480 + if let Some(op) = self.undo.pop() { 481 + let redo_op = op.clone(); 482 + // Apply inverse in reverse order 483 + for k in op.ops.iter().rev() { 484 + match k { 485 + EditKind::Insert { index, text } => { 486 + let s = *index; 487 + let e = s + text.chars().count(); 488 + let _ = self.delete_range(s, e); 489 + self.set_caret_at_index(*index); 490 + } 491 + EditKind::Delete { index, text } => { 492 + self.insert_text_at(*index, text); 493 + self.set_caret_at_index(index + text.chars().count()); 494 + } 495 + } 496 + } 497 + self.redo.push(redo_op); 498 + self.clear_selection(); 499 + } 500 + } 501 + 502 + pub fn redo(&mut self) { 503 + if let Some(op) = self.redo.pop() { 504 + let undo_op = op.clone(); 505 + for k in &op.ops { 506 + match k { 507 + EditKind::Insert { index, text } => { 508 + self.insert_text_at(*index, text); 509 + self.set_caret_at_index(index + text.chars().count()); 510 + } 511 + EditKind::Delete { index, text } => { 512 + let s = *index; 513 + let e = s + text.chars().count(); 514 + let _ = self.delete_range(s, e); 515 + self.set_caret_at_index(*index); 516 + } 517 + } 518 + } 519 + self.undo.push(undo_op); 520 + self.clear_selection(); 521 + } 522 + } 523 + 524 + // Internals -------------------------------------------------------------------------------- 525 + fn push_undo(&mut self, k: EditKind) { 526 + self.undo.push(EditOp { ops: vec![k] }); 527 + } 528 + fn clear_redo(&mut self) { 529 + self.redo.clear(); 530 + } 531 + fn line_col_to_index(&self, line: usize, col: usize) -> usize { 532 + let base = self.rope.line_to_char(line); 533 + base + col.min(self.line_len(line)) 534 + } 535 + fn set_caret_at_index(&mut self, idx: usize) { 536 + let line = self.rope.char_to_line(idx.min(self.rope.len_chars())); 537 + let base = self.rope.line_to_char(line); 538 + self.caret_line = line; 539 + self.caret_col = idx.saturating_sub(base); 540 + } 541 + fn slice_to_string(&self, s: usize, e: usize) -> String { 542 + self.rope.slice(s..e).to_string() 543 + } 544 + fn delete_range(&mut self, s: usize, e: usize) -> String { 545 + let text = self.slice_to_string(s, e); 546 + self.rope.remove(s..e); 547 + self.set_caret_at_index(s); 548 + self.clear_selection(); 549 + text 550 + } 551 + fn insert_text_at(&mut self, idx: usize, text: &str) { 552 + self.rope.insert(idx, text); 553 + let new_idx = idx + text.chars().count(); 554 + self.set_caret_at_index(new_idx); 555 + self.clear_selection(); 556 + } 557 + fn replace_selection_with(&mut self, text: &str) { 558 + if let Some((s, e)) = self.selection_range_idx() { 559 + let mut op = EditOp { ops: Vec::new() }; 560 + let deleted = self.delete_range(s, e); 561 + op.ops.push(EditKind::Delete { 562 + index: s, 563 + text: deleted, 564 + }); 565 + self.insert_text_at(s, text); 566 + op.ops.push(EditKind::Insert { 567 + index: s, 568 + text: text.to_string(), 569 + }); 570 + self.undo.push(op); 571 + self.clear_redo(); 572 + } else { 573 + self.paste_text(text); 574 + } 180 575 } 181 576 }
+83 -5
tic80_rust/src/main.rs
··· 33 33 34 34 use pixels::{Pixels, SurfaceTexture}; 35 35 use winit::dpi::LogicalSize; 36 - use winit::event::{ElementState, Event, KeyboardInput, VirtualKeyCode, WindowEvent}; 36 + use winit::event::{ElementState, Event, KeyboardInput, ModifiersState, VirtualKeyCode, WindowEvent}; 37 37 use winit::event_loop::{ControlFlow, EventLoop}; 38 38 use winit::window::{Window, WindowBuilder}; 39 39 ··· 351 351 let mut last_cursor_fb: Option<(i32, i32)> = None; 352 352 let mut frame_counter: u32 = 0; 353 353 let mut one_off_saved = false; 354 + let mut modifiers = ModifiersState::empty(); 354 355 355 356 #[allow(clippy::cognitive_complexity)] 356 357 event_loop.run(move |event, _, control_flow| { ··· 394 395 if let Err(e) = save_timestamped_screenshot(&args, &rgba, w, h) { 395 396 eprintln!("Screenshot error: {e}"); 396 397 } 398 + } 399 + WindowEvent::ReceivedCharacter(ch) => { 400 + if let (Some(ui), Some(cb)) = (editor_ui.as_ref(), code_buf.as_mut()) { 401 + if ui.active == tic80_rust::editor::ui::Tab::Code { 402 + // Ignore character input when Cmd/Ctrl is held (shortcut) to avoid inserting letters 403 + if modifiers.ctrl() || modifiers.logo() { 404 + return; 405 + } 406 + if ch == '\n' { 407 + cb.insert_newline(); 408 + } else if ch == '\t' { 409 + cb.insert_tab(); 410 + } else if !ch.is_control() { 411 + cb.insert_char(ch); 412 + } 413 + } 414 + } 415 + } 416 + WindowEvent::ModifiersChanged(m) => { 417 + modifiers = m; 397 418 } 398 419 WindowEvent::KeyboardInput { input, .. } => { 399 420 if let (Some(ui), Some(cb)) = (editor_ui.as_ref(), code_buf.as_mut()) { ··· 401 422 && input.state == ElementState::Pressed 402 423 { 403 424 if let Some(key) = input.virtual_keycode { 425 + // Prefer latest modifiers from event loop; fall back to key-based detection 426 + #[allow(deprecated)] 427 + let m = input.modifiers; 428 + let shift = m.shift(); 429 + let ctrl = m.ctrl(); 430 + let cmd = m.logo(); 431 + // Shortcuts (cmd/ctrl) 432 + if ctrl || cmd { 433 + match key { 434 + VirtualKeyCode::C => { 435 + if let Some(text) = cb.copy_selection_text() { 436 + let _ = set_clipboard_text(&text); 437 + } 438 + return; 439 + } 440 + VirtualKeyCode::X => { 441 + if let Some(text) = cb.cut_selection_text() { 442 + let _ = set_clipboard_text(&text); 443 + } 444 + return; 445 + } 446 + VirtualKeyCode::V => { 447 + if let Ok(text) = get_clipboard_text() { 448 + cb.paste_text(&text); 449 + } 450 + return; 451 + } 452 + VirtualKeyCode::Z => { 453 + if shift { cb.redo(); } else { cb.undo(); } 454 + return; 455 + } 456 + VirtualKeyCode::Y => { cb.redo(); return; } 457 + VirtualKeyCode::A => { cb.select_all(); return; } 458 + _ => {} 459 + } 460 + } 461 + // Navigation and editing 404 462 match key { 405 - VirtualKeyCode::Left => cb.move_left(), 406 - VirtualKeyCode::Right => cb.move_right(), 407 - VirtualKeyCode::Up => cb.move_up(), 408 - VirtualKeyCode::Down => cb.move_down(), 463 + VirtualKeyCode::Left => { if shift { cb.ensure_selection_anchor(); } else { cb.clear_selection(); } cb.move_left(); }, 464 + VirtualKeyCode::Right => { if shift { cb.ensure_selection_anchor(); } else { cb.clear_selection(); } cb.move_right(); }, 465 + VirtualKeyCode::Up => { if shift { cb.ensure_selection_anchor(); } else { cb.clear_selection(); } cb.move_up(); }, 466 + VirtualKeyCode::Down => { if shift { cb.ensure_selection_anchor(); } else { cb.clear_selection(); } cb.move_down(); }, 467 + VirtualKeyCode::Back => { cb.backspace(); }, 468 + VirtualKeyCode::Delete => { cb.delete_forward(); }, 469 + VirtualKeyCode::Return => { cb.insert_newline(); }, 470 + VirtualKeyCode::Tab => { cb.insert_tab(); }, 471 + VirtualKeyCode::Home => { if shift { cb.ensure_selection_anchor(); } else { cb.clear_selection(); } cb.home(); }, 472 + VirtualKeyCode::End => { if shift { cb.ensure_selection_anchor(); } else { cb.clear_selection(); } cb.end(); }, 409 473 _ => {} 410 474 } 411 475 } ··· 664 728 save_screenshot_from_rgba(args, &rgba)?; 665 729 Ok(()) 666 730 } 731 + 732 + // -- Clipboard helpers ------------------------------------------------------------------------- 733 + 734 + fn set_clipboard_text(text: &str) -> anyhow::Result<()> { 735 + let mut cb = arboard::Clipboard::new()?; 736 + cb.set_text(text.to_string())?; 737 + Ok(()) 738 + } 739 + 740 + fn get_clipboard_text() -> anyhow::Result<String> { 741 + let mut cb = arboard::Clipboard::new()?; 742 + let t = cb.get_text()?; 743 + Ok(t) 744 + }
-1
tic80_rust/tests/e2e_headless_cli.rs
··· 47 47 run_and_capture(&args, &p); 48 48 let _ = std::fs::remove_file(&p); 49 49 } 50 -
+62
tic80_rust/tests/editor_editing_tests.rs
··· 1 + use tic80_rust::editor::code::CodeBuffer; 2 + 3 + #[test] 4 + fn insert_chars_and_newlines() { 5 + let mut cb = CodeBuffer::from_text(""); 6 + cb.insert_char('a'); 7 + cb.insert_char('b'); 8 + cb.insert_newline(); 9 + cb.insert_char('c'); 10 + cb.insert_char('d'); 11 + assert_eq!(cb.as_string(), "ab\ncd"); 12 + } 13 + 14 + #[test] 15 + fn backspace_within_line_and_join_previous() { 16 + let mut cb = CodeBuffer::from_text("hello\nworld"); 17 + // Place caret after 'l' in first line 18 + cb.caret_line = 0; 19 + cb.caret_col = 3; // hel|lo 20 + cb.backspace(); // remove 'l' 21 + assert_eq!(cb.as_string(), "helo\nworld"); 22 + // Move to start of second line and backspace -> join 23 + cb.caret_line = 1; 24 + cb.caret_col = 0; 25 + cb.backspace(); 26 + assert_eq!(cb.as_string(), "heloworld"); 27 + assert_eq!(cb.caret_line, 0); 28 + } 29 + 30 + #[test] 31 + fn delete_forward_and_join_next_line() { 32 + let mut cb = CodeBuffer::from_text("xy\nzz"); 33 + // place at end of first line and delete -> join lines 34 + cb.caret_line = 0; 35 + cb.caret_col = 2; // xy|\nzz 36 + cb.delete_forward(); 37 + assert_eq!(cb.as_string(), "xyzz"); 38 + // delete in middle 39 + cb.caret_line = 0; 40 + cb.caret_col = 1; // x|yzz 41 + cb.delete_forward(); 42 + assert_eq!(cb.as_string(), "xzz"); 43 + } 44 + 45 + #[test] 46 + fn home_and_end() { 47 + let mut cb = CodeBuffer::from_text("abc\ndef"); 48 + cb.caret_line = 1; 49 + cb.caret_col = 1; 50 + cb.home(); 51 + assert_eq!(cb.caret_col, 0); 52 + cb.end(); 53 + assert_eq!(cb.caret_col, 3); 54 + } 55 + 56 + #[test] 57 + fn insert_tab_inserts_two_spaces() { 58 + let mut cb = CodeBuffer::from_text(""); 59 + cb.insert_tab(); 60 + cb.insert_char('x'); 61 + assert_eq!(cb.as_string(), " x"); 62 + }
+35
tic80_rust/tests/editor_selection_align_tests.rs
··· 1 + use std::cell::RefCell; 2 + use std::rc::Rc; 3 + 4 + use tic80_rust::editor::code::{Area, CodeBuffer}; 5 + use tic80_rust::gfx::framebuffer::Framebuffer; 6 + 7 + // Verify selection top aligns with caret fill top (one px above text baseline) 8 + #[test] 9 + fn selection_aligned_with_caret_box() { 10 + let text = "a def\n"; 11 + let mut cb = CodeBuffer::from_text(text); 12 + // Prepare selection from col 1 to col 3 on line 0 13 + cb.caret_line = 0; 14 + cb.caret_col = 1; 15 + cb.start_selection(); 16 + cb.caret_col = 3; // selection [1,3) 17 + // Also place caret for box at col 3 18 + let fb = Rc::new(RefCell::new(Framebuffer::new())); 19 + let mut fbb = fb.borrow_mut(); 20 + let area = Area { x: 0, y: 12, w: 240, h: 124 }; 21 + cb.draw(&mut fbb, area); 22 + 23 + // Expected coordinates 24 + let gutter_w = 24i32; 25 + let row = 0i32; // first line 26 + let gutter_y = area.y + row * 8; 27 + let caret_fill_top = (gutter_y - 1).max(area.y); // caret fills 7px starting 1px above baseline, clipped to area 28 + // Selection starts at col 1 (from) over a space (no glyph ink) 29 + let sel_x = area.x + gutter_w + 6; 30 + let sel_y = caret_fill_top; 31 + 32 + // Sample a pixel inside selection highlight 33 + let c = fbb.pix(sel_x + 1, sel_y, None).unwrap_or(0); 34 + assert_eq!(c, 14, "expected selection color at aligned top row"); 35 + }
+72
tic80_rust/tests/editor_selection_undo_tests.rs
··· 1 + use tic80_rust::editor::code::CodeBuffer; 2 + 3 + #[test] 4 + fn replace_selection_and_undo_redo() { 5 + let mut cb = CodeBuffer::from_text("abc\ndef"); 6 + eprintln!("R0: {:?}", cb.as_string()); 7 + // Select "bc" on first line 8 + cb.caret_line = 0; 9 + cb.caret_col = 1; 10 + cb.start_selection(); 11 + cb.caret_col = 3; // selection [1,3) 12 + // Cut, then paste "Z" 13 + let cut = cb.cut_selection_text().unwrap(); 14 + eprintln!("R1: cut={:?} now={:?}", cut, cb.as_string()); 15 + assert_eq!(cut, "bc"); 16 + assert_eq!(cb.as_string(), "a\ndef"); 17 + cb.paste_text("Z"); 18 + eprintln!("R2: {:?}", cb.as_string()); 19 + assert_eq!(cb.as_string(), "aZ\ndef"); 20 + // Undo paste+cut (batch), redo 21 + cb.undo(); 22 + eprintln!("R3: {:?}", cb.as_string()); 23 + assert_eq!(cb.as_string(), "a\ndef"); 24 + cb.redo(); 25 + eprintln!("R4: {:?}", cb.as_string()); 26 + assert_eq!(cb.as_string(), "aZ\ndef"); 27 + } 28 + 29 + #[test] 30 + fn undo_redo_simple_backspace() { 31 + let mut cb = CodeBuffer::from_text("ab"); 32 + eprintln!("T0: {:?}", cb.as_string()); 33 + cb.caret_line = 0; 34 + cb.caret_col = 2; // end 35 + cb.backspace(); 36 + eprintln!("T1: {:?}", cb.as_string()); 37 + assert_eq!(cb.as_string(), "a"); 38 + cb.undo(); 39 + eprintln!("T2: {:?}", cb.as_string()); 40 + assert_eq!(cb.as_string(), "ab"); 41 + cb.redo(); 42 + eprintln!("T3: {:?}", cb.as_string()); 43 + assert_eq!(cb.as_string(), "a"); 44 + } 45 + 46 + #[test] 47 + fn debug_backspace_print() { 48 + let mut cb = CodeBuffer::from_text("ab"); 49 + eprintln!("BEFORE: {:?}", cb.as_string()); 50 + cb.caret_line = 0; 51 + cb.caret_col = 2; 52 + cb.backspace(); 53 + eprintln!("AFTER: {:?}", cb.as_string()); 54 + } 55 + 56 + #[test] 57 + fn select_all_and_cut_to_empty() { 58 + let mut cb = CodeBuffer::from_text("hello\nworld"); 59 + cb.select_all(); 60 + let cut = cb.cut_selection_text().unwrap(); 61 + assert_eq!(cut, "hello\nworld"); 62 + assert_eq!(cb.as_string(), ""); 63 + } 64 + 65 + #[test] 66 + fn paste_simple_newline_context() { 67 + let mut cb = CodeBuffer::from_text("a\nb"); 68 + cb.caret_line = 0; 69 + cb.caret_col = 1; 70 + cb.paste_text("Z"); 71 + assert_eq!(cb.as_string(), "aZ\nb"); 72 + }