1use leptos::html;
6use leptos::prelude::*;
7
8#[derive(Clone, Copy, PartialEq, Eq, Debug)]
9enum Color {
10 Default,
11 Indexed(u8),
12 Rgb(u8, u8, u8),
13}
14
15#[derive(Clone, Copy, PartialEq, Eq)]
16struct Cell {
17 glyph: char,
18 fg: Color,
19 bg: Color,
20 bold: bool,
21 inverse: bool,
22}
23
24impl Cell {
25 fn blank() -> Self {
26 Self {
27 glyph: ' ',
28 fg: Color::Default,
29 bg: Color::Default,
30 bold: false,
31 inverse: false,
32 }
33 }
34}
35
36#[derive(Clone, Copy, PartialEq, Eq)]
37enum Parse {
38 Ground,
39 Escape,
40 Csi,
41}
42
43#[derive(Clone)]
46pub struct TerminalGrid {
47 cols: usize,
48 rows: usize,
49 cells: Vec<Cell>,
50 row: usize,
51 col: usize,
52 fg: Color,
53 bg: Color,
54 bold: bool,
55 inverse: bool,
56 state: Parse,
57 params: Vec<u16>,
58 acc: Option<u16>,
59 private: bool,
60 saved_cursor: Option<(usize, usize)>,
61 scroll_top: usize,
62 scroll_bottom: usize,
63 alt: Option<(Vec<Cell>, usize, usize)>,
64}
65
66impl TerminalGrid {
67 fn new(cols: usize, rows: usize) -> Self {
68 let cols = cols.max(1);
69 let rows = rows.max(1);
70 Self {
71 cols,
72 rows,
73 cells: vec![Cell::blank(); cols * rows],
74 row: 0,
75 col: 0,
76 fg: Color::Default,
77 bg: Color::Default,
78 bold: false,
79 inverse: false,
80 state: Parse::Ground,
81 params: Vec::new(),
82 acc: None,
83 private: false,
84 saved_cursor: None,
85 scroll_top: 0,
86 scroll_bottom: rows - 1,
87 alt: None,
88 }
89 }
90
91 fn clear_row(&mut self, row: usize) {
92 let start = row * self.cols;
93 for cell in &mut self.cells[start..start + self.cols] {
94 *cell = Cell::blank();
95 }
96 }
97
98 fn scroll_region_up(&mut self) {
99 for row in self.scroll_top..self.scroll_bottom {
100 let (dst, src) = (row * self.cols, (row + 1) * self.cols);
101 self.cells.copy_within(src..src + self.cols, dst);
102 }
103 self.clear_row(self.scroll_bottom);
104 }
105
106 fn newline(&mut self) {
107 if self.row >= self.scroll_bottom {
108 self.scroll_region_up();
109 } else {
110 self.row += 1;
111 }
112 }
113
114 fn put(&mut self, glyph: char) {
115 if self.col >= self.cols {
116 self.col = 0;
117 self.newline();
118 }
119 let index = self.row * self.cols + self.col;
120 self.cells[index] = Cell {
121 glyph,
122 fg: self.fg,
123 bg: self.bg,
124 bold: self.bold,
125 inverse: self.inverse,
126 };
127 self.col += 1;
128 }
129
130 fn param(&self, index: usize, fallback: u16) -> u16 {
131 self.params.get(index).copied().unwrap_or(fallback)
132 }
133
134 fn apply_sgr(&mut self) {
135 if self.params.is_empty() {
136 self.params.push(0);
137 }
138 let params = self.params.clone();
139 let mut index = 0;
140 while index < params.len() {
141 match params[index] {
142 0 => {
143 self.fg = Color::Default;
144 self.bg = Color::Default;
145 self.bold = false;
146 self.inverse = false;
147 }
148 1 => self.bold = true,
149 7 => self.inverse = true,
150 22 => self.bold = false,
151 27 => self.inverse = false,
152 30..=37 => self.fg = Color::Indexed((params[index] - 30) as u8),
153 39 => self.fg = Color::Default,
154 40..=47 => self.bg = Color::Indexed((params[index] - 40) as u8),
155 49 => self.bg = Color::Default,
156 90..=97 => self.fg = Color::Indexed((params[index] - 90 + 8) as u8),
157 100..=107 => self.bg = Color::Indexed((params[index] - 100 + 8) as u8),
158 38 | 48 => {
159 let target_fg = params[index] == 38;
160 let color = match params.get(index + 1) {
161 Some(5) => {
162 let value = params.get(index + 2).copied().unwrap_or(0) as u8;
163 index += 2;
164 Color::Indexed(value)
165 }
166 Some(2) => {
167 let red = params.get(index + 2).copied().unwrap_or(0) as u8;
168 let green = params.get(index + 3).copied().unwrap_or(0) as u8;
169 let blue = params.get(index + 4).copied().unwrap_or(0) as u8;
170 index += 4;
171 Color::Rgb(red, green, blue)
172 }
173 _ => Color::Default,
174 };
175 if target_fg {
176 self.fg = color;
177 } else {
178 self.bg = color;
179 }
180 }
181 _ => {}
182 }
183 index += 1;
184 }
185 }
186
187 fn erase_line(&mut self) {
188 let start = self.row * self.cols;
189 let len = self.cells.len();
190 let (from, to) = match self.param(0, 0) {
191 1 => (start, start + self.col + 1),
192 2 => (start, start + self.cols),
193 _ => (start + self.col, start + self.cols),
194 };
195 for cell in &mut self.cells[from..to.min(len)] {
196 *cell = Cell::blank();
197 }
198 }
199
200 fn erase_display(&mut self) {
201 let cursor = self.row * self.cols + self.col;
202 let len = self.cells.len();
203 let (from, to) = match self.param(0, 0) {
204 1 => (0, cursor + 1),
205 2 => (0, len),
206 _ => (cursor, len),
207 };
208 for cell in &mut self.cells[from..to.min(len)] {
209 *cell = Cell::blank();
210 }
211 }
212
213 fn enter_alt(&mut self) {
214 if self.alt.is_none() {
215 let saved =
216 std::mem::replace(&mut self.cells, vec![Cell::blank(); self.cols * self.rows]);
217 self.alt = Some((saved, self.row, self.col));
218 self.row = 0;
219 self.col = 0;
220 }
221 }
222
223 fn exit_alt(&mut self) {
224 if let Some((cells, row, col)) = self.alt.take() {
225 self.cells = cells;
226 self.row = row.min(self.rows - 1);
227 self.col = col.min(self.cols - 1);
228 }
229 }
230
231 fn execute(&mut self, final_byte: char) {
232 match final_byte {
233 'm' => self.apply_sgr(),
234 'A' => self.row = self.row.saturating_sub(self.param(0, 1).max(1) as usize),
235 'B' => self.row = (self.row + self.param(0, 1).max(1) as usize).min(self.rows - 1),
236 'C' => self.col = (self.col + self.param(0, 1).max(1) as usize).min(self.cols - 1),
237 'D' => self.col = self.col.saturating_sub(self.param(0, 1).max(1) as usize),
238 'G' => {
239 self.col = (self.param(0, 1) as usize)
240 .saturating_sub(1)
241 .min(self.cols - 1)
242 }
243 'H' | 'f' => {
244 self.row = (self.param(0, 1) as usize)
245 .saturating_sub(1)
246 .min(self.rows - 1);
247 self.col = (self.param(1, 1) as usize)
248 .saturating_sub(1)
249 .min(self.cols - 1);
250 }
251 'J' => self.erase_display(),
252 'K' => self.erase_line(),
253 'r' => {
254 let top = (self.param(0, 1) as usize).saturating_sub(1);
255 let bottom = (self.param(1, self.rows as u16) as usize)
256 .saturating_sub(1)
257 .min(self.rows - 1);
258 if top < bottom {
259 self.scroll_top = top;
260 self.scroll_bottom = bottom;
261 }
262 }
263 's' => self.saved_cursor = Some((self.row, self.col)),
264 'u' => {
265 if let Some((row, col)) = self.saved_cursor {
266 self.row = row.min(self.rows - 1);
267 self.col = col.min(self.cols - 1);
268 }
269 }
270 'h' if self.private => {
271 if matches!(self.param(0, 0), 47 | 1047 | 1049) {
272 self.enter_alt();
273 }
274 }
275 'l' if self.private => {
276 if matches!(self.param(0, 0), 47 | 1047 | 1049) {
277 self.exit_alt();
278 }
279 }
280 _ => {}
281 }
282 }
283
284 pub fn feed(&mut self, text: &str) {
287 for glyph in text.chars() {
288 match self.state {
289 Parse::Ground => match glyph {
290 '\u{1b}' => self.state = Parse::Escape,
291 '\n' => {
292 self.col = 0;
293 self.newline();
294 }
295 '\r' => self.col = 0,
296 '\t' => self.col = (((self.col / 8) + 1) * 8).min(self.cols - 1),
297 '\u{8}' => self.col = self.col.saturating_sub(1),
298 glyph if !glyph.is_control() => self.put(glyph),
299 _ => {}
300 },
301 Parse::Escape => match glyph {
302 '[' => {
303 self.state = Parse::Csi;
304 self.params.clear();
305 self.acc = None;
306 self.private = false;
307 }
308 '7' => {
309 self.saved_cursor = Some((self.row, self.col));
310 self.state = Parse::Ground;
311 }
312 '8' => {
313 if let Some((row, col)) = self.saved_cursor {
314 self.row = row.min(self.rows - 1);
315 self.col = col.min(self.cols - 1);
316 }
317 self.state = Parse::Ground;
318 }
319 _ => self.state = Parse::Ground,
320 },
321 Parse::Csi => match glyph {
322 '?' => self.private = true,
323 '0'..='9' => {
324 let digit = glyph as u16 - '0' as u16;
325 self.acc = Some(self.acc.unwrap_or(0) * 10 + digit);
326 }
327 ';' => self.params.push(self.acc.take().unwrap_or(0)),
328 '\u{40}'..='\u{7e}' => {
329 if let Some(value) = self.acc.take() {
330 self.params.push(value);
331 }
332 self.execute(glyph);
333 self.state = Parse::Ground;
334 }
335 _ => {}
336 },
337 }
338 }
339 }
340}
341
342#[derive(Clone, Copy)]
345pub struct TerminalHandle {
346 grid: RwSignal<TerminalGrid>,
347}
348
349impl TerminalHandle {
350 pub fn feed(&self, text: &str) {
352 self.grid.update(|grid| grid.feed(text));
353 }
354
355 pub fn reset(&self, cols: usize, rows: usize) {
357 self.grid.set(TerminalGrid::new(cols, rows));
358 }
359}
360
361pub fn terminal_grid(cols: usize, rows: usize) -> TerminalHandle {
363 TerminalHandle {
364 grid: RwSignal::new(TerminalGrid::new(cols, rows)),
365 }
366}
367
368const PALETTE: [&str; 16] = [
369 "#3b3b3b", "#f87171", "#4ade80", "#fbbf24", "#60a5fa", "#c084fc", "#22d3ee", "#d4d4d4",
370 "#6b7280", "#fca5a5", "#86efac", "#fde68a", "#93c5fd", "#d8b4fe", "#67e8f9", "#ffffff",
371];
372
373fn cube_channel(value: u8) -> u8 {
374 if value == 0 { 0 } else { 55 + value * 40 }
375}
376
377fn color(value: Color, default: &str) -> String {
378 match value {
379 Color::Default => default.to_string(),
380 Color::Indexed(index) if index < 16 => PALETTE[index as usize].to_string(),
381 Color::Indexed(index) if index < 232 => {
382 let index = index - 16;
383 let red = cube_channel(index / 36);
384 let green = cube_channel((index / 6) % 6);
385 let blue = cube_channel(index % 6);
386 format!("#{red:02x}{green:02x}{blue:02x}")
387 }
388 Color::Indexed(index) => {
389 let level = 8 + (index - 232) * 10;
390 format!("#{level:02x}{level:02x}{level:02x}")
391 }
392 Color::Rgb(red, green, blue) => format!("#{red:02x}{green:02x}{blue:02x}"),
393 }
394}
395
396fn cell_style(cell: Cell) -> String {
397 let mut foreground = color(cell.fg, "var(--nightshade-text)");
398 let mut background = color(cell.bg, "transparent");
399 if cell.inverse {
400 if background == "transparent" {
401 background = "var(--nightshade-text)".to_string();
402 foreground = "var(--nightshade-bg)".to_string();
403 } else {
404 std::mem::swap(&mut foreground, &mut background);
405 }
406 }
407 let weight = if cell.bold { "font-weight:700;" } else { "" };
408 format!("color:{foreground};background:{background};{weight}")
409}
410
411fn key_to_bytes(event: &web_sys::KeyboardEvent) -> Option<String> {
412 let key = event.key();
413 if event.ctrl_key() && key.len() == 1 {
414 let character = key.chars().next()?.to_ascii_lowercase();
415 if character.is_ascii_alphabetic() {
416 return Some(((character as u8 - b'a' + 1) as char).to_string());
417 }
418 }
419 match key.as_str() {
420 "Enter" => Some("\r".to_string()),
421 "Backspace" => Some("\u{7f}".to_string()),
422 "Tab" => Some("\t".to_string()),
423 "Escape" => Some("\u{1b}".to_string()),
424 "ArrowUp" => Some("\u{1b}[A".to_string()),
425 "ArrowDown" => Some("\u{1b}[B".to_string()),
426 "ArrowRight" => Some("\u{1b}[C".to_string()),
427 "ArrowLeft" => Some("\u{1b}[D".to_string()),
428 other if other.chars().count() == 1 => Some(other.to_string()),
429 _ => None,
430 }
431}
432
433#[component]
437pub fn AnsiTerminal(
438 handle: TerminalHandle,
439 #[prop(optional)] on_key: Option<Callback<String>>,
440) -> impl IntoView {
441 let body_ref = NodeRef::<html::Div>::new();
442 let grid = handle.grid;
443
444 view! {
445 <div
446 class="nightshade-ansi"
447 tabindex="0"
448 on:keydown=move |event: web_sys::KeyboardEvent| {
449 if let Some(callback) = on_key
450 && let Some(bytes) = key_to_bytes(&event)
451 {
452 event.prevent_default();
453 callback.run(bytes);
454 }
455 }
456 >
457 <div class="nightshade-ansi-body" node_ref=body_ref>
458 {move || {
459 let grid = grid.get();
460 let cols = grid.cols;
461 (0..grid.rows)
462 .map(|row| {
463 let cells = &grid.cells[row * cols..row * cols + cols];
464 let mut runs: Vec<(String, String)> = Vec::new();
465 for cell in cells {
466 let style = cell_style(*cell);
467 match runs.last_mut() {
468 Some((last_style, text)) if *last_style == style => {
469 text.push(cell.glyph);
470 }
471 _ => runs.push((style, cell.glyph.to_string())),
472 }
473 }
474 let spans = runs
475 .into_iter()
476 .map(|(style, text)| view! { <span style=style>{text}</span> })
477 .collect_view();
478 view! { <div class="nightshade-ansi-row">{spans}</div> }
479 })
480 .collect_view()
481 }}
482 </div>
483 </div>
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::{Color, TerminalGrid};
490
491 fn text_at(grid: &TerminalGrid, row: usize) -> String {
492 grid.cells[row * grid.cols..row * grid.cols + grid.cols]
493 .iter()
494 .map(|cell| cell.glyph)
495 .collect::<String>()
496 .trim_end()
497 .to_string()
498 }
499
500 #[test]
501 fn plain_text_and_newlines_write_rows() {
502 let mut grid = TerminalGrid::new(20, 4);
503 grid.feed("hello\nworld");
504 assert_eq!(text_at(&grid, 0), "hello");
505 assert_eq!(text_at(&grid, 1), "world");
506 }
507
508 #[test]
509 fn sgr_sets_and_resets_color() {
510 let mut grid = TerminalGrid::new(20, 4);
511 grid.feed("\u{1b}[32mgreen\u{1b}[0m.");
512 assert_eq!(grid.cells[0].fg, Color::Indexed(2));
513 assert_eq!(grid.cells[5].fg, Color::Default);
514 }
515
516 #[test]
517 fn sgr_256_and_truecolor() {
518 let mut grid = TerminalGrid::new(20, 2);
519 grid.feed("\u{1b}[38;5;196mA\u{1b}[38;2;10;20;30mB");
520 assert_eq!(grid.cells[0].fg, Color::Indexed(196));
521 assert_eq!(grid.cells[1].fg, Color::Rgb(10, 20, 30));
522 }
523
524 #[test]
525 fn cursor_save_restore_returns_to_saved_position() {
526 let mut grid = TerminalGrid::new(10, 4);
527 grid.feed("\u{1b}7abc\u{1b}8Z");
528 assert_eq!(grid.cells[0].glyph, 'Z');
529 assert_eq!(grid.cells[1].glyph, 'b');
530 }
531
532 #[test]
533 fn scroll_region_limits_scrolling() {
534 let mut grid = TerminalGrid::new(4, 4);
535 grid.feed("\u{1b}[1;2r");
536 grid.feed("a\nb\nc\nd");
537 assert_eq!(grid.scroll_top, 0);
538 assert_eq!(grid.scroll_bottom, 1);
539 }
540
541 #[test]
542 fn alt_screen_saves_and_restores() {
543 let mut grid = TerminalGrid::new(6, 2);
544 grid.feed("main");
545 grid.feed("\u{1b}[?1049h");
546 assert_eq!(text_at(&grid, 0), "");
547 grid.feed("\u{1b}[?1049l");
548 assert_eq!(text_at(&grid, 0), "main");
549 }
550}