···88 - `docs/roadmap/editor_livecoding.md`: Livecoding editor plan (TIC‑80 UI vibes): CODE + CONSOLE only.
99 - `docs/roadmap/editor_shortcuts.md`: Implementation checklist for PageUp/Down, Shift variants, Ctrl+Home/End, and block indent/outdent.
1010 - `docs/roadmap/editor_shortcuts_phase2.md`: Plan for word navigation/edit, smart line bounds, and document selection (Ctrl/Cmd+Shift+Home/End).
1111+ - `docs/roadmap/editor_syntax_highlighting.md`: Plan and TODOs for Lua syntax highlighting with CODE_THEME colors.
1112 - `docs/roadmap/todos_code_review.md`: Rolling TODOs from code review (high/medium/low priority) with checkboxes.
1213 - `docs/roadmap/todos_from_ai_review.md`: Rolling TODOs from AI code review (2025-08-27).
1314
+88
docs/roadmap/editor_syntax_highlighting.md
···11+22+# Editor Syntax Highlighting — Plan & TODOs (CODE View)
33+44+Status: planning (tests-first, incremental)
55+66+This document is the implementation plan and checklist for adding syntax highlighting to the CODE view with TIC‑80 parity in spirit and practical constraints of the 240×136 framebuffer. We will implement Lua first (TIC‑80 default), use CODE_THEME colors, and integrate with the existing renderer, selection, and caret.
77+88+## Goals
99+1010+- Lua (5.3) highlighting with a pragmatic tokenizer:
1111+ - Keywords, identifiers, numbers, strings (short/long), comments (line/block), signs/punctuation, API names.
1212+ - Multiline constructs (long strings/comments) carry state across lines.
1313+- Theme‑driven colors from CODE_THEME:
1414+ - `BG`, `FG`, `STRING`, `NUMBER`, `KEYWORD`, `API`, `COMMENT`, `SIGN`, `SELECT`, `CURSOR`.
1515+- Low overhead: tokenize only visible lines (plus minimal context) with per‑line state cache and dirty invalidation on edits.
1616+- Selection/caret precedence: keep current selection overlay and caret behavior; highlighting is suppressed when selection inverts glyphs.
1717+1818+## Token Kinds and Color Map
1919+2020+- `Whitespace` (no color change; use previous).
2121+- `Identifier` → `FG` (unless API or keyword).
2222+- `Keyword` (Lua 5.3 set): `and, break, do, else, elseif, end, false, for, function, goto, if, in, local, nil, not, or, repeat, return, then, true, until, while` → `KEYWORD`.
2323+- `Number` → `NUMBER` (support decimal, hex `0x..`, floats with exponent; pragmatic subset).
2424+- `StringShort` (single/double quoted with escapes) → `STRING`.
2525+- `StringLong` (Lua long brackets `[[ .. ]]`, `[=[ .. ]=]` nesting level) → `STRING`.
2626+- `CommentLine` (`-- ...`) → `COMMENT`.
2727+- `CommentBlock` (`--[[ .. ]]`, equal‑sign variants) → `COMMENT`.
2828+- `API` (known TIC‑80 API names: `cls, pix, line, rect, rectb, circ, circb, elli, ellib, tri, trib, clip, print, peek, poke, memcpy, memset, fft*, vqt*` …) → `API`.
2929+- `Sign` (operators, punctuation) → `SIGN`.
3030+3131+## Architecture
3232+3333+- Module: `editor/highlight.rs`
3434+ - `struct LineTok { runs: Vec<(col_start, col_end, Kind)>; state_out: State }`
3535+ - `enum State { Normal, InLongString { level: u32 }, InBlockComment { level: u32 } }`
3636+ - `fn lex_line(text: &str, state_in: State) -> LineTok`
3737+ - Color resolver: `fn color_for(kind: Kind, theme: &Theme) -> u8`
3838+3939+- Cache and invalidation
4040+ - Store per‑line `State` and compact token runs in `CodeBuffer` (or sibling `HighlighterCache`).
4141+ - On edit: mark changed line..end as dirty; recompute forward until state stabilizes (no change) or visible limit.
4242+4343+- Rendering hooks (in `CodeBuffer::draw`)
4444+ - Compute visible line range; request `LineTok` for each visible line.
4545+ - Before drawing monospace glyphs, set color per token run.
4646+ - Selection overlay: if selected, use current selection path (shadow + fill + dark glyph) and skip token color.
4747+ - Caret overlay unchanged.
4848+4949+- Theme load
5050+ - Parse CODE_THEME from TIC-80 `config.tic` already embedded; map palette indices directly (Sweetie16 with white=12, greys 13/14/15).
5151+5252+## Tests (TDD)
5353+5454+- Tokenization unit tests (`highlight_tests.rs`)
5555+ - Keywords vs identifiers; numbers (int/float/hex); strings short/long across lines; comments line/block with bracket levels.
5656+ - State propagation across lines for long strings/comments.
5757+5858+- Rendering tests (framebuffer)
5959+ - Draw a small snippet and assert pixel colors at representative positions:
6060+ - keyword, number, string, comment, API function call, sign.
6161+ - Verify selection overlay precedence (colored tokens become dark under selection).
6262+6363+- Performance sanity
6464+ - Tokenize visible range only; tests ensure caches invalidate on edits and recompute affected lines (without exhaustively re‑lexing entire file).
6565+6666+## TODO Checklist
6767+6868+- [ ] Skeleton module `editor/highlight.rs` (tokens, state, API).
6969+- [ ] Theme plumbing: expose CODE_THEME colors (BG/FG/STRING/NUMBER/KEYWORD/API/COMMENT/SIGN).
7070+- [ ] Lua tokenizer (short strings, numbers, keywords, comments, signs, identifiers).
7171+- [ ] Long strings / block comments with `[[` and `[=[` nesting; cross‑line state.
7272+- [ ] API name set and classification.
7373+- [ ] Highlighter cache in editor: dirty range strategy + forward recompute.
7474+- [ ] Integrate with `CodeBuffer::draw` (colors for tokens; selection/caret precedence).
7575+- [ ] Unit tests: tokenization coverage.
7676+- [ ] Framebuffer tests: colored pixels for representative tokens; selection precedence.
7777+- [ ] Config flag to disable highlighting (optional; default on).
7878+- [ ] Documentation updates (implementation status, testing catalog).
7979+8080+## Out of Scope (later phases)
8181+8282+- Language modes beyond Lua (Wren, Squirrel, MoonScript, JS/Python): pluggable lexers.
8383+- Identifier‑based semantic highlighting (locals/upvalues vs globals).
8484+- Multi‑threaded/background lexing (current size/perf doesn’t require it).
8585+8686+---
8787+8888+Implementation will follow this plan and check off items as they land with tests.
+48-9
tic80_rust/src/editor/code.rs
···198198 #[allow(clippy::missing_const_for_fn)]
199199 pub fn end(&mut self) {
200200 self.reset_caret_blink();
201201- self.caret_col = self.line_len(self.caret_line);
201201+ // Move to visual end of line (exclude trailing newline)
202202+ let len_vis = if self.caret_line >= self.rope.len_lines() { 0 } else {
203203+ let seg = self.rope.line(self.caret_line);
204204+ let len = seg.len_chars();
205205+ if len > 0 && seg.chars().last() == Some('\n') { len - 1 } else { len }
206206+ };
207207+ self.caret_col = len_vis;
208208+ self.desired_col = Some(self.caret_col);
202209 }
203210204211 #[must_use]
···233240234241 pub fn move_right(&mut self) {
235242 self.reset_caret_blink();
236236- let len = self.line_len(self.caret_line);
243243+ let len = {
244244+ let seg = self.rope.line(self.caret_line);
245245+ let l = seg.len_chars();
246246+ if l > 0 && seg.chars().last() == Some('\n') { l - 1 } else { l }
247247+ };
237248 if self.caret_col < len {
238249 self.caret_col += 1;
239250 } else if self.caret_line + 1 < self.line_count() {
···248259 let want = self.desired_col.unwrap_or(self.caret_col);
249260 if self.caret_line > 0 {
250261 self.caret_line -= 1;
251251- self.caret_col = self.line_len(self.caret_line).min(want);
262262+ let len_vis = {
263263+ let seg = self.rope.line(self.caret_line);
264264+ let l = seg.len_chars(); if l > 0 && seg.chars().last() == Some('\n') { l - 1 } else { l }
265265+ };
266266+ self.caret_col = len_vis.min(want);
252267 }
253268 self.desired_col = Some(want);
254269 }
···258273 let want = self.desired_col.unwrap_or(self.caret_col);
259274 if self.caret_line + 1 < self.line_count() {
260275 self.caret_line += 1;
261261- self.caret_col = self.line_len(self.caret_line).min(want);
276276+ let len_vis = {
277277+ let seg = self.rope.line(self.caret_line);
278278+ let l = seg.len_chars(); if l > 0 && seg.chars().last() == Some('\n') { l - 1 } else { l }
279279+ };
280280+ self.caret_col = len_vis.min(want);
262281 }
263282 self.desired_col = Some(want);
264283 }
···367386 if lc == 0 { return; }
368387 let delta = vis.min(lc.saturating_sub(1) - self.caret_line);
369388 self.caret_line += delta;
370370- self.caret_col = self.line_len(self.caret_line).min(want);
389389+ let len_vis = {
390390+ let seg = self.rope.line(self.caret_line);
391391+ let l = seg.len_chars(); if l > 0 && seg.chars().last() == Some('\n') { l - 1 } else { l }
392392+ };
393393+ self.caret_col = len_vis.min(want);
371394 self.desired_col = Some(want);
372395 }
373396 pub fn page_up(&mut self, vis: usize) {
···376399 let want = self.caret_col;
377400 let delta = vis.min(self.caret_line);
378401 self.caret_line -= delta;
379379- self.caret_col = self.line_len(self.caret_line).min(want);
402402+ let len_vis = {
403403+ let seg = self.rope.line(self.caret_line);
404404+ let l = seg.len_chars(); if l > 0 && seg.chars().last() == Some('\n') { l - 1 } else { l }
405405+ };
406406+ self.caret_col = len_vis.min(want);
380407 self.desired_col = Some(want);
381408 }
382409···502529 // Draw characters cell-by-cell, applying selection overlays where needed
503530 let line_char_start = self.rope.line_to_char(line_idx);
504531 let sel = self.selection_range_idx();
532532+ // Syntax highlighting: get token runs and map to per-column colors
533533+ let mut col_colors: Vec<u8> = vec![crate::editor::highlight::default_theme().fg; line.chars().count()];
534534+ {
535535+ use crate::editor::highlight as hl;
536536+ let toks = hl::lex_line(&line, hl::State::Normal);
537537+ let theme = hl::default_theme();
538538+ for (a, b, k) in toks.runs {
539539+ let color = hl::color_for(k, theme);
540540+ let end = b.min(col_colors.len());
541541+ if a < end { col_colors[a..end].fill(color); }
542542+ }
543543+ }
505544 for (i_vis, ch) in vis.chars().enumerate() {
506545 let cell_x = area.x + gutter_w + gap + i32::try_from(i_vis).unwrap_or(0) * 6;
507546 let cell_y = gutter_y;
···516555 let s = ch.to_string();
517556 let _ = fb.print_text(&s, cell_x, cell_y, 15, true, 1, true);
518557 } else {
519519- // Normal glyph (no selection overlay)
558558+ // Normal glyph (no selection overlay): use token color
520559 let s = ch.to_string();
521521- // TIC default text color (no syntax) is white (12), 6px tall
522522- let _ = fb.print_text(&s, cell_x, cell_y, 12, true, 1, true);
560560+ let color = *col_colors.get(i_vis).unwrap_or(&12u8);
561561+ let _ = fb.print_text(&s, cell_x, cell_y, color, true, 1, true);
523562 }
524563 }
525564 // Extra selection cell for newline (EOL) when selected
+173
tic80_rust/src/editor/highlight.rs
···11+22+#![allow(
33+ clippy::cognitive_complexity,
44+ clippy::single_match_else,
55+ clippy::must_use_candidate,
66+ clippy::missing_const_for_fn,
77+ clippy::derivable_impls,
88+ clippy::use_self,
99+ unused_assignments,
1010+ unused_mut
1111+)]
1212+1313+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
1414+pub enum Kind {
1515+ Whitespace,
1616+ Identifier,
1717+ Keyword,
1818+ Number,
1919+ StringShort,
2020+ StringLong,
2121+ CommentLine,
2222+ CommentBlock,
2323+ Api,
2424+ Sign,
2525+}
2626+2727+#[derive(Clone, Debug, PartialEq, Eq)]
2828+pub enum State {
2929+ Normal,
3030+ InLongString { level: u32 },
3131+ InBlockComment { level: u32 },
3232+}
3333+3434+impl Default for State { fn default() -> Self { Self::Normal } }
3535+3636+#[derive(Clone, Debug, Default)]
3737+pub struct LineTok {
3838+ pub runs: Vec<(usize, usize, Kind)>, // [start,end) in columns
3939+ pub state_out: State,
4040+}
4141+4242+#[derive(Clone, Copy, Debug)]
4343+pub struct Theme {
4444+ pub bg: u8,
4545+ pub fg: u8,
4646+ pub string: u8,
4747+ pub number: u8,
4848+ pub keyword: u8,
4949+ pub api: u8,
5050+ pub comment: u8,
5151+ pub sign: u8,
5252+}
5353+5454+#[must_use]
5555+pub const fn default_theme() -> Theme {
5656+ Theme { bg: 15, fg: 12, string: 4, number: 11, keyword: 3, api: 5, comment: 14, sign: 13 }
5757+}
5858+5959+const fn is_ident_start(c: char) -> bool { c == '_' || c.is_ascii_alphabetic() }
6060+const fn is_ident_continue(c: char) -> bool { c == '_' || c.is_ascii_alphanumeric() }
6161+6262+fn is_keyword(s: &str) -> bool {
6363+ matches!(s,
6464+ "and"|"break"|"do"|"else"|"elseif"|"end"|"false"|"for"|"function"|
6565+ "goto"|"if"|"in"|"local"|"nil"|"not"|"or"|"repeat"|"return"|
6666+ "then"|"true"|"until"|"while")
6767+}
6868+6969+fn is_api(s: &str) -> bool {
7070+ matches!(s,
7171+ "cls"|"pix"|"line"|"rect"|"rectb"|"circ"|"circb"|"elli"|"ellib"|
7272+ "tri"|"trib"|"clip"|"print"|"peek"|"poke"|"memcpy"|"memset"|
7373+ "fft"|"ffts"|"fftr"|"fftrs"|"vqt"|"vqts"|"vqtr"|"vqtrs")
7474+}
7575+7676+#[must_use]
7777+pub fn lex_line(text: &str, mut state: State) -> LineTok {
7878+ let mut out = LineTok::default();
7979+ let chars: Vec<char> = text.chars().collect();
8080+ let mut i = 0usize;
8181+ // Handle continuing states
8282+ match state {
8383+ State::InLongString { level } => {
8484+ // search for ]=...=]
8585+ while i < chars.len() {
8686+ if chars[i] == ']' {
8787+ // check = level then ]
8888+ let mut j = 0; while i + 1 + j < chars.len() && j < level as usize && chars[i+1 + j] == '=' { j += 1; }
8989+ if j == level as usize && i + 1 + j < chars.len() && chars[i+1 + j] == ']' {
9090+ // include closing delimiter
9191+ out.runs.push((0, i + 2 + j, Kind::StringLong));
9292+ i += 2 + j; state = State::Normal; break;
9393+ }
9494+ }
9595+ i += 1;
9696+ }
9797+ if matches!(state, State::InLongString { .. }) { out.runs.push((0, chars.len(), Kind::StringLong)); out.state_out = state; return out; }
9898+ }
9999+ State::InBlockComment { level } => {
100100+ while i < chars.len() {
101101+ if chars[i] == ']' {
102102+ let mut j = 0; while i + 1 + j < chars.len() && j < level as usize && chars[i+1 + j] == '=' { j += 1; }
103103+ if j == level as usize && i + 1 + j < chars.len() && chars[i+1 + j] == ']' { out.runs.push((0, i + 2 + j, Kind::CommentBlock)); i += 2 + j; state = State::Normal; break; }
104104+ }
105105+ i += 1;
106106+ }
107107+ if matches!(state, State::InBlockComment { .. }) { out.runs.push((0, chars.len(), Kind::CommentBlock)); out.state_out = state; return out; }
108108+ }
109109+ State::Normal => {}
110110+ }
111111+112112+ // Normal scanning
113113+ i = 0;
114114+ let push_run = |start: usize, end: usize, kind: Kind, out: &mut LineTok| { if end > start { out.runs.push((start, end, kind)); } };
115115+ while i < chars.len() {
116116+ let c = chars[i];
117117+ // whitespace
118118+ if c.is_whitespace() { let start = i; while i < chars.len() && chars[i].is_whitespace() { i += 1; } push_run(start, i, Kind::Whitespace, &mut out); continue; }
119119+ // comment
120120+ if c == '-' && i + 1 < chars.len() && chars[i+1] == '-' {
121121+ // block comment?
122122+ if i + 3 < chars.len() && chars[i+2] == '[' {
123123+ let mut j = i + 3; let mut level = 0u32; while j < chars.len() && chars[j] == '=' { level += 1; j += 1; }
124124+ if j < chars.len() && chars[j] == '[' {
125125+ let mut k = j + 1; let mut closed = None;
126126+ while k < chars.len() { if chars[k] == ']' {
127127+ let mut eq = 0usize; while k + 1 + eq < chars.len() && eq < level as usize && chars[k+1+eq] == '=' { eq += 1; }
128128+ if eq == level as usize && k + 1 + eq < chars.len() && chars[k+1+eq] == ']' { closed = Some(k + 2 + eq); break; }
129129+ } k += 1; }
130130+ match closed { Some(end) => { push_run(i, end, Kind::CommentBlock, &mut out); i = end; continue; }
131131+ None => { push_run(i, chars.len(), Kind::CommentBlock, &mut out); out.state_out = State::InBlockComment { level }; return out; } }
132132+ }
133133+ }
134134+ push_run(i, chars.len(), Kind::CommentLine, &mut out); i = chars.len(); break;
135135+ }
136136+ // short strings
137137+ if c == '"' || c == '\'' {
138138+ let quote = c; let start = i; i += 1;
139139+ while i < chars.len() {
140140+ if chars[i] == '\\' { i += 2; continue; }
141141+ if chars[i] == quote { i += 1; break; }
142142+ i += 1;
143143+ }
144144+ push_run(start, i, Kind::StringShort, &mut out); continue;
145145+ }
146146+ // long string
147147+ if c == '[' { let mut j = i + 1; let mut level = 0u32; while j < chars.len() && chars[j] == '=' { level += 1; j += 1; } if j < chars.len() && chars[j] == '[' {
148148+ let start = i; let mut k = j + 1; let mut closed = None; while k < chars.len() { if chars[k] == ']' {
149149+ let mut eq = 0usize; while k + 1 + eq < chars.len() && eq < level as usize && chars[k+1+eq] == '=' { eq += 1; }
150150+ if eq == level as usize && k + 1 + eq < chars.len() && chars[k+1+eq] == ']' { closed = Some(k + 2 + eq); break; }
151151+ } k += 1; } match closed { Some(end) => { push_run(start, end, Kind::StringLong, &mut out); i = end; continue; } None => { push_run(start, chars.len(), Kind::StringLong, &mut out); out.state_out = State::InLongString { level }; return out; } } } }
152152+ // number
153153+ if c.is_ascii_digit() { let start = i; i += 1; while i < chars.len() && (chars[i].is_ascii_hexdigit() || matches!(chars[i], '.'|'x'|'X'|'e'|'E'|'+'|'-'|'_')) { i += 1; } push_run(start, i, Kind::Number, &mut out); continue; }
154154+ // identifier / keyword / api
155155+ if is_ident_start(c) { let start = i; i += 1; while i < chars.len() && is_ident_continue(chars[i]) { i += 1; } let s: String = chars[start..i].iter().copied().collect(); let kind = if is_keyword(&s) { Kind::Keyword } else if is_api(&s) { Kind::Api } else { Kind::Identifier }; push_run(start, i, kind, &mut out); continue; }
156156+ // sign
157157+ push_run(i, i+1, Kind::Sign, &mut out); i += 1;
158158+ }
159159+ out.state_out = state; out
160160+}
161161+162162+#[must_use]
163163+pub const fn color_for(kind: Kind, th: Theme) -> u8 {
164164+ match kind {
165165+ Kind::Whitespace | Kind::Identifier => th.fg,
166166+ Kind::Keyword => th.keyword,
167167+ Kind::Number => th.number,
168168+ Kind::StringShort | Kind::StringLong => th.string,
169169+ Kind::CommentLine | Kind::CommentBlock => th.comment,
170170+ Kind::Api => th.api,
171171+ Kind::Sign => th.sign,
172172+ }
173173+}
+1
tic80_rust/src/lib.rs
···3636pub mod editor {
3737 pub mod code;
3838 pub mod ui;
3939+ pub mod highlight;
3940}
40414142pub mod util {
+19
tic80_rust/tests/highlight_lexer_tests.rs
···11+22+use tic80_rust::editor::highlight as hl;
33+44+#[test]
55+fn lex_keywords_ident_numbers() {
66+ let line = "local x = 42";
77+ let tok = hl::lex_line(line, hl::State::Normal);
88+ assert!(tok.runs.iter().any(|r| matches!(r, (0, 5, hl::Kind::Keyword))));
99+ assert!(tok.runs.iter().any(|r| matches!(r, (6, 7, hl::Kind::Identifier))));
1010+ assert!(tok.runs.iter().any(|r| matches!(r, (10, 12, hl::Kind::Number))));
1111+}
1212+1313+#[test]
1414+fn lex_strings_and_comments() {
1515+ let line = "print(\"hi\") -- ok";
1616+ let tok = hl::lex_line(line, hl::State::Normal);
1717+ assert!(tok.runs.iter().any(|(_,_,k)| matches!(k, hl::Kind::StringShort)));
1818+ assert!(tok.runs.iter().any(|(_,_,k)| matches!(k, hl::Kind::CommentLine)));
1919+}