use super::history::History;
use crate::{
shell::{Key, Mods},
ui::{self, Mouse},
};
use pixel8_runtime::{fb::Framebuffer, font, palette::col};
type Snapshot = (Vec<String>, usize, usize);
const AREA_X: i32 = 1;
const AREA_Y: i32 = 9;
const ROWS: usize = ((120 - AREA_Y) / font::GLYPH_H) as usize;
const COLS: usize = 31;
const C_TEXT: u8 = col::WHITE;
const C_KEYWORD: u8 = col::PINK;
const C_STRING: u8 = col::GREEN;
const C_NUMBER: u8 = col::BLUE;
const C_COMMENT: u8 = col::LAVENDER;
const C_TYPE: u8 = col::YELLOW;
const C_MACRO: u8 = col::ORANGE;
const C_PUNCT: u8 = col::LIGHT_GREY;
const KEYWORDS: &[&str] = &[
"as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
"false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
"ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "type",
"unsafe", "use", "where", "while",
];
pub struct CodeEditor {
lines: Vec<String>,
line: usize,
col: usize,
pref_col: usize,
scroll_y: usize,
scroll_x: usize,
anchor: Option<(usize, usize)>,
status: ui::StatusMsg,
history: History<Snapshot>,
frame: u64,
}
impl CodeEditor {
pub fn new() -> Self {
Self {
lines: vec![String::new()],
line: 0,
col: 0,
pref_col: 0,
scroll_y: 0,
scroll_x: 0,
anchor: None,
status: ui::StatusMsg::default(),
history: History::new(),
frame: 0,
}
}
pub fn set_text(&mut self, text: &str) {
let new: Vec<String> = if text.is_empty() {
vec![String::new()]
} else {
text.split('\n').map(str::to_string).collect()
};
if new != self.lines {
self.lines = new;
self.line = 0;
self.col = 0;
self.scroll_y = 0;
self.scroll_x = 0;
self.anchor = None;
self.history.clear();
}
}
pub fn text(&self) -> String {
self.lines.join("\n")
}
pub fn copy(&mut self, code: &str) -> Option<String> {
self.set_text(code);
let text = self.selected_text();
if text.is_empty() {
return None;
}
self.status.set(clip_msg("copied", &text));
Some(text)
}
pub fn cut(&mut self, code: &mut String) -> Option<String> {
self.set_text(code);
let text = self.selected_text();
if text.is_empty() {
return None;
}
self.push_undo();
self.delete_selection();
let snap = self.snapshot();
self.history.commit(&snap);
self.scroll_to_cursor();
*code = self.text();
self.status.set(clip_msg("cut", &text));
Some(text)
}
pub fn paste_text(&mut self, code: &mut String, text: &str) {
self.set_text(code);
if text.is_empty() {
return;
}
self.push_undo();
self.delete_selection();
self.insert_str(text);
let snap = self.snapshot();
self.history.commit(&snap);
self.scroll_to_cursor();
*code = self.text();
self.status.set(clip_msg("pasted", text));
}
pub fn set_status(&mut self, msg: String) {
self.status.set(msg);
}
fn clamp_cursor(&mut self) {
self.line = self.line.min(self.lines.len() - 1);
self.col = self.col.min(self.lines[self.line].chars().count());
}
fn snapshot(&self) -> Snapshot {
(self.lines.clone(), self.line, self.col)
}
fn push_undo(&mut self) {
let snap = self.snapshot();
self.history.begin(&snap);
}
fn restore(&mut self, snap: Snapshot) {
let (lines, line, col) = snap;
self.lines = lines;
self.line = line;
self.col = col;
self.anchor = None;
self.clamp_cursor();
}
fn byte_idx(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(i, _)| i)
.unwrap_or(s.len())
}
fn selection(&self) -> Option<((usize, usize), (usize, usize))> {
let a = self.anchor?;
let b = (self.line, self.col);
if a == b {
return None;
}
Some(if a < b { (a, b) } else { (b, a) })
}
fn delete_selection(&mut self) -> bool {
let Some(((l0, c0), (l1, c1))) = self.selection() else {
return false;
};
let head = self.lines[l0][..Self::byte_idx(&self.lines[l0], c0)].to_string();
let tail = self.lines[l1][Self::byte_idx(&self.lines[l1], c1)..].to_string();
self.lines.splice(l0..=l1, [head + &tail]);
self.line = l0;
self.col = c0;
self.anchor = None;
true
}
fn selected_text(&self) -> String {
let Some(((l0, c0), (l1, c1))) = self.selection() else {
return String::new();
};
if l0 == l1 {
let s = &self.lines[l0];
return s[Self::byte_idx(s, c0)..Self::byte_idx(s, c1)].to_string();
}
let mut out = self.lines[l0][Self::byte_idx(&self.lines[l0], c0)..].to_string();
for l in &self.lines[l0 + 1..l1] {
out.push('\n');
out.push_str(l);
}
out.push('\n');
out.push_str(&self.lines[l1][..Self::byte_idx(&self.lines[l1], c1)]);
out
}
fn insert_str(&mut self, text: &str) {
for c in text.chars() {
if c == '\n' {
let at = Self::byte_idx(&self.lines[self.line], self.col);
let rest = self.lines[self.line].split_off(at);
self.lines.insert(self.line + 1, rest);
self.line += 1;
self.col = 0;
} else {
let at = Self::byte_idx(&self.lines[self.line], self.col);
self.lines[self.line].insert(at, c);
self.col += 1;
}
}
}
fn move_cursor(&mut self, key: Key, mods: Mods) {
if mods.shift {
if self.anchor.is_none() {
self.anchor = Some((self.line, self.col));
}
} else {
self.anchor = None;
}
match key {
Key::Left => {
if self.col > 0 {
self.col -= 1;
} else if self.line > 0 {
self.line -= 1;
self.col = self.lines[self.line].chars().count();
}
self.pref_col = self.col;
}
Key::Right => {
if self.col < self.lines[self.line].chars().count() {
self.col += 1;
} else if self.line + 1 < self.lines.len() {
self.line += 1;
self.col = 0;
}
self.pref_col = self.col;
}
Key::Up => {
self.line = self.line.saturating_sub(1);
self.col = self.pref_col;
self.clamp_cursor();
}
Key::Down => {
self.line = (self.line + 1).min(self.lines.len() - 1);
self.col = self.pref_col;
self.clamp_cursor();
}
Key::Home => {
self.col = 0;
self.pref_col = 0;
}
Key::End => {
self.col = self.lines[self.line].chars().count();
self.pref_col = self.col;
}
Key::PageUp => {
self.line = self.line.saturating_sub(ROWS);
self.col = self.pref_col;
self.clamp_cursor();
}
Key::PageDown => {
self.line = (self.line + ROWS).min(self.lines.len() - 1);
self.col = self.pref_col;
self.clamp_cursor();
}
_ => {}
}
}
pub fn key(&mut self, key: Key, mods: Mods, code: &mut String) {
self.set_text(code);
match key {
Key::Left
| Key::Right
| Key::Up
| Key::Down
| Key::Home
| Key::End
| Key::PageUp
| Key::PageDown => self.move_cursor(key, mods),
Key::Char(c) if mods.ctrl => match c.to_ascii_lowercase() {
'a' => {
self.anchor = Some((0, 0));
self.line = self.lines.len() - 1;
self.col = self.lines[self.line].chars().count();
}
'z' if mods.shift => {
let mut snap = self.snapshot();
if self.history.redo(&mut snap) {
self.restore(snap);
}
}
'z' => {
let mut snap = self.snapshot();
if self.history.undo(&mut snap) {
self.restore(snap);
}
}
'y' => {
let mut snap = self.snapshot();
if self.history.redo(&mut snap) {
self.restore(snap);
}
}
_ => {}
},
Key::Char(c) => {
self.push_undo();
self.delete_selection();
let mut buf = [0u8; 4];
self.insert_str(c.encode_utf8(&mut buf));
self.pref_col = self.col;
}
Key::Tab => {
self.push_undo();
self.delete_selection();
self.insert_str(" ");
}
Key::Enter => {
self.push_undo();
self.delete_selection();
let indent: String = self.lines[self.line]
.chars()
.take_while(|c| *c == ' ')
.take(self.col)
.collect();
self.insert_str(&format!("\n{indent}"));
self.pref_col = self.col;
}
Key::Backspace => {
self.push_undo();
if !self.delete_selection() {
if self.col > 0 {
let at = Self::byte_idx(&self.lines[self.line], self.col - 1);
self.lines[self.line].remove(at);
self.col -= 1;
} else if self.line > 0 {
let cur = self.lines.remove(self.line);
self.line -= 1;
self.col = self.lines[self.line].chars().count();
self.lines[self.line].push_str(&cur);
}
}
self.pref_col = self.col;
}
Key::Delete => {
self.push_undo();
if !self.delete_selection() {
let len = self.lines[self.line].chars().count();
if self.col < len {
let at = Self::byte_idx(&self.lines[self.line], self.col);
self.lines[self.line].remove(at);
} else if self.line + 1 < self.lines.len() {
let next = self.lines.remove(self.line + 1);
self.lines[self.line].push_str(&next);
}
}
}
Key::Escape | Key::CaptureLabel | Key::ToggleStats => {}
}
let snap = self.snapshot();
self.history.commit(&snap);
self.scroll_to_cursor();
*code = self.text();
}
fn scroll_to_cursor(&mut self) {
if self.line < self.scroll_y {
self.scroll_y = self.line;
}
if self.line >= self.scroll_y + ROWS {
self.scroll_y = self.line - ROWS + 1;
}
if self.col < self.scroll_x {
self.scroll_x = self.col;
}
if self.col >= self.scroll_x + COLS {
self.scroll_x = self.col - COLS + 1;
}
}
pub fn tick(&mut self, mouse: &Mouse, code: &str) {
self.status.tick();
self.set_text(code);
self.frame += 1;
let in_area = mouse.y >= AREA_Y && mouse.y < AREA_Y + (ROWS as i32) * font::GLYPH_H;
if (mouse.left_pressed || mouse.left) && in_area {
let l = (self.scroll_y as i32 + (mouse.y - AREA_Y) / font::GLYPH_H).max(0) as usize;
let c = (self.scroll_x as i32 + (mouse.x - AREA_X) / 4).max(0) as usize;
if mouse.left_pressed {
self.anchor = None;
self.line = l.min(self.lines.len() - 1);
self.col = c;
self.clamp_cursor();
self.anchor = Some((self.line, self.col));
} else {
self.line = l.min(self.lines.len() - 1);
self.col = c;
self.clamp_cursor();
}
self.pref_col = self.col;
}
if !mouse.left {
if let Some(a) = self.anchor {
if a == (self.line, self.col) {
self.anchor = None;
}
}
}
}
pub fn draw(&self, fb: &mut Framebuffer, code: &str) {
let lines: Vec<&str> = if code.is_empty() {
vec![""]
} else {
code.split('\n').collect()
};
fb.rectfill(0, 8, 127, 119, col::BLACK);
let mut in_block = false;
for l in lines.iter().take(self.scroll_y) {
in_block = scan_block_state(l, in_block);
}
let sel = self.selection();
for row in 0..ROWS {
let li = self.scroll_y + row;
let Some(line) = lines.get(li) else { break };
let y = AREA_Y + row as i32 * font::GLYPH_H;
if let Some(((l0, c0), (l1, c1))) = sel {
if li >= l0 && li <= l1 {
let len = line.chars().count();
let s = if li == l0 { c0 } else { 0 };
let e = if li == l1 { c1 } else { len + 1 };
let (s, e) = (
s.saturating_sub(self.scroll_x),
e.saturating_sub(self.scroll_x),
);
if e > s {
fb.rectfill(
AREA_X + s as i32 * 4,
y - 1,
(AREA_X + e as i32 * 4 - 1).min(127),
y + font::GLYPH_H - 2,
col::DARK_BLUE,
);
}
}
}
let spans = highlight(line, &mut in_block);
for (start, text, color) in spans {
let vis_start = start as i32 - self.scroll_x as i32;
for (i, ch) in text.chars().enumerate() {
let cx = vis_start + i as i32;
if (0..COLS as i32).contains(&cx) {
fb.print(ch.encode_utf8(&mut [0u8; 4]), AREA_X + cx * 4, y, color);
}
}
}
}
if (self.frame / 8).is_multiple_of(2) {
let cy = self.line as i32 - self.scroll_y as i32;
let cx = self.col as i32 - self.scroll_x as i32;
if (0..ROWS as i32).contains(&cy) && (0..=COLS as i32).contains(&cx) {
fb.rectfill(
AREA_X + cx * 4,
AREA_Y + cy * font::GLYPH_H - 1,
AREA_X + cx * 4 + 3,
AREA_Y + cy * font::GLYPH_H + font::GLYPH_H - 2,
col::RED,
);
}
}
let info = format!("L{}/{} C{}", self.line + 1, lines.len(), self.col + 1);
self.status.show(fb, &info);
}
}
fn clip_msg(verb: &str, text: &str) -> String {
if text.contains('\n') {
format!("{verb} {} lines", text.matches('\n').count() + 1)
} else {
format!("{verb} {} chars", text.chars().count())
}
}
fn scan_block_state(line: &str, mut in_block: bool) -> bool {
let b = line.as_bytes();
let mut i = 0;
while i + 1 < b.len() {
if in_block {
if &b[i..i + 2] == b"*/" {
in_block = false;
i += 2;
continue;
}
} else {
if &b[i..i + 2] == b"//" {
return in_block;
}
if &b[i..i + 2] == b"/*" {
in_block = true;
i += 2;
continue;
}
}
i += 1;
}
in_block
}
fn highlight<'a>(line: &'a str, in_block: &mut bool) -> Vec<(usize, &'a str, u8)> {
let mut out = Vec::new();
let chars: Vec<char> = line.chars().collect();
let n = chars.len();
let mut i = 0;
let slice = |a: usize, b: usize| -> &'a str {
let start = line
.char_indices()
.nth(a)
.map(|(i, _)| i)
.unwrap_or(line.len());
let end = line
.char_indices()
.nth(b)
.map(|(i, _)| i)
.unwrap_or(line.len());
&line[start..end]
};
while i < n {
if *in_block {
let start = i;
while i < n {
if chars[i] == '*' && i + 1 < n && chars[i + 1] == '/' {
i += 2;
*in_block = false;
break;
}
i += 1;
}
out.push((start, slice(start, i), C_COMMENT));
continue;
}
let c = chars[i];
if c == '/' && i + 1 < n && chars[i + 1] == '/' {
out.push((i, slice(i, n), C_COMMENT));
break;
}
if c == '/' && i + 1 < n && chars[i + 1] == '*' {
*in_block = true;
i += 2;
continue;
}
if c == '"' {
let start = i;
i += 1;
while i < n {
if chars[i] == '\\' {
i += 2;
continue;
}
if chars[i] == '"' {
i += 1;
break;
}
i += 1;
}
let i2 = i.min(n);
out.push((start, slice(start, i2), C_STRING));
i = i2;
continue;
}
if c.is_ascii_digit() {
let start = i;
while i < n && (chars[i].is_ascii_alphanumeric() || chars[i] == '.' || chars[i] == '_')
{
i += 1;
}
out.push((start, slice(start, i), C_NUMBER));
continue;
}
if c.is_alphabetic() || c == '_' {
let start = i;
while i < n && (chars[i].is_alphanumeric() || chars[i] == '_') {
i += 1;
}
let word = slice(start, i);
let color = if i < n && chars[i] == '!' {
C_MACRO
} else if KEYWORDS.contains(&word) {
C_KEYWORD
} else if word.chars().next().is_some_and(|c| c.is_uppercase()) {
C_TYPE
} else {
C_TEXT
};
out.push((start, word, color));
continue;
}
if c == '#' {
out.push((i, slice(i, i + 1), C_MACRO));
i += 1;
continue;
}
if c == ' ' {
i += 1;
continue;
}
let start = i;
i += 1;
out.push((start, slice(start, i), C_PUNCT));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn ed_with(text: &str) -> (CodeEditor, String) {
let mut e = CodeEditor::new();
e.set_text(text);
(e, text.to_string())
}
#[test]
fn typing_inserts() {
let (mut e, mut code) = ed_with("");
for c in "fn main".chars() {
e.key(Key::Char(c), Mods::default(), &mut code);
}
assert_eq!(code, "fn main");
}
#[test]
fn enter_auto_indents() {
let (mut e, mut code) = ed_with(" abc");
e.key(Key::End, Mods::default(), &mut code);
e.key(Key::Enter, Mods::default(), &mut code);
assert_eq!(code, " abc\n ");
}
#[test]
fn backspace_joins_lines() {
let (mut e, mut code) = ed_with("ab\ncd");
e.key(Key::Down, Mods::default(), &mut code);
e.key(Key::Home, Mods::default(), &mut code);
e.key(Key::Backspace, Mods::default(), &mut code);
assert_eq!(code, "abcd");
}
#[test]
fn select_all_cut_paste() {
let (mut e, mut code) = ed_with("hello\nworld");
let ctrl = Mods {
ctrl: true,
..Default::default()
};
e.key(Key::Char('a'), ctrl, &mut code); let cut = e.cut(&mut code).unwrap();
assert_eq!(cut, "hello\nworld");
assert_eq!(code, "");
e.paste_text(&mut code, &cut);
assert_eq!(code, "hello\nworld");
}
#[test]
fn copy_reports_size() {
let (mut e, mut code) = ed_with("hello\nworld");
let ctrl = Mods {
ctrl: true,
..Default::default()
};
e.key(Key::Char('a'), ctrl, &mut code); assert_eq!(e.copy(&code).unwrap(), "hello\nworld");
assert_eq!(e.status.current(), Some("copied 2 lines"));
}
#[test]
fn undo_restores() {
let (mut e, mut code) = ed_with("abc");
e.key(Key::End, Mods::default(), &mut code);
e.key(Key::Char('!'), Mods::default(), &mut code);
assert_eq!(code, "abc!");
e.key(
Key::Char('z'),
Mods {
ctrl: true,
..Default::default()
},
&mut code,
);
assert_eq!(code, "abc");
}
#[test]
fn redo_reapplies_an_undone_edit() {
let (mut e, mut code) = ed_with("abc");
let ctrl = Mods {
ctrl: true,
..Default::default()
};
let ctrl_shift = Mods {
ctrl: true,
shift: true,
..Default::default()
};
e.key(Key::End, Mods::default(), &mut code);
e.key(Key::Char('!'), Mods::default(), &mut code);
assert_eq!(code, "abc!");
e.key(Key::Char('z'), ctrl, &mut code); assert_eq!(code, "abc");
e.key(Key::Char('Z'), ctrl_shift, &mut code);
assert_eq!(code, "abc!", "redo reapplies the typed character");
e.key(Key::Char('z'), ctrl, &mut code); e.key(Key::Char('?'), Mods::default(), &mut code);
assert_eq!(code, "abc?");
e.key(Key::Char('Z'), ctrl_shift, &mut code); assert_eq!(code, "abc?");
}
#[test]
fn highlight_classifies() {
let mut in_block = false;
let spans = highlight("let x = \"hi\"; // c", &mut in_block);
let find = |text: &str| spans.iter().find(|(_, t, _)| *t == text).unwrap().2;
assert_eq!(find("let"), C_KEYWORD);
assert_eq!(find("x"), C_TEXT);
assert_eq!(find("\"hi\""), C_STRING);
assert_eq!(find("// c"), C_COMMENT);
}
#[test]
fn block_comment_state_tracks() {
assert!(scan_block_state("a /* b", false));
assert!(!scan_block_state("b */ c", true));
assert!(!scan_block_state("// /* not", false));
}
#[test]
fn paste_scrolls_cursor_into_view() {
let (mut e, mut code) = ed_with("");
let many: String = (0..50)
.map(|i| format!("l{i}"))
.collect::<Vec<_>>()
.join("\n");
e.paste_text(&mut code, &many);
assert!(e.line >= e.scroll_y && e.line < e.scroll_y + ROWS);
}
#[test]
fn status_messages_fit_the_bar() {
use pixel8_runtime::{fb::WIDTH, font::text_width};
let budget = WIDTH - 2;
assert!(text_width(&clip_msg("pasted", &"x\n".repeat(1_000_000))) <= budget);
assert!(text_width(&clip_msg("copied", &"x".repeat(1_000_000))) <= budget);
}
}