|
@@ -0,0 +1,1155 @@
|
|
|
|
|
+// hoardom-app: native e gui emo wrapper for the hoardom tui
|
|
|
|
|
+// spawns hoardom --tui in a pty and renders it in its own window
|
|
|
|
|
+// so it shows up with its own icon in the dock (mac) or taskbar (linux)
|
|
|
|
|
+//
|
|
|
|
|
+// built with: cargo build --features gui
|
|
|
|
|
+
|
|
|
|
|
+use eframe::egui::{self, Color32, FontId, Rect, Sense};
|
|
|
|
|
+use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
|
|
|
|
+use vte::{Params, Perform};
|
|
|
|
|
+
|
|
|
|
|
+use std::io::{Read, Write};
|
|
|
|
|
+use std::path::PathBuf;
|
|
|
|
|
+use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
|
+use std::sync::{Arc, Mutex};
|
|
|
|
|
+use std::thread;
|
|
|
|
|
+use std::time::Duration;
|
|
|
|
|
+
|
|
|
|
|
+// ----- constants -----
|
|
|
|
|
+
|
|
|
|
|
+const FONT_SIZE: f32 = 14.0;
|
|
|
|
|
+const DEFAULT_COLS: u16 = 120;
|
|
|
|
|
+const DEFAULT_ROWS: u16 = 35;
|
|
|
|
|
+
|
|
|
|
|
+const DEFAULT_FG: Color32 = Color32::from_rgb(204, 204, 204);
|
|
|
|
|
+const DEFAULT_BG: Color32 = Color32::from_rgb(24, 24, 24);
|
|
|
|
|
+
|
|
|
|
|
+// ----- terminal colors -----
|
|
|
|
|
+
|
|
|
|
|
+#[derive(Clone, Copy, PartialEq)]
|
|
|
|
|
+enum TermColor {
|
|
|
|
|
+ Default,
|
|
|
|
|
+ Indexed(u8),
|
|
|
|
|
+ Rgb(u8, u8, u8),
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn ansi_color(idx: u8) -> Color32 {
|
|
|
|
|
+ match idx {
|
|
|
|
|
+ 0 => Color32::from_rgb(0, 0, 0),
|
|
|
|
|
+ 1 => Color32::from_rgb(170, 0, 0),
|
|
|
|
|
+ 2 => Color32::from_rgb(0, 170, 0),
|
|
|
|
|
+ 3 => Color32::from_rgb(170, 85, 0),
|
|
|
|
|
+ 4 => Color32::from_rgb(0, 0, 170),
|
|
|
|
|
+ 5 => Color32::from_rgb(170, 0, 170),
|
|
|
|
|
+ 6 => Color32::from_rgb(0, 170, 170),
|
|
|
|
|
+ 7 => Color32::from_rgb(170, 170, 170),
|
|
|
|
|
+ 8 => Color32::from_rgb(85, 85, 85),
|
|
|
|
|
+ 9 => Color32::from_rgb(255, 85, 85),
|
|
|
|
|
+ 10 => Color32::from_rgb(85, 255, 85),
|
|
|
|
|
+ 11 => Color32::from_rgb(255, 255, 85),
|
|
|
|
|
+ 12 => Color32::from_rgb(85, 85, 255),
|
|
|
|
|
+ 13 => Color32::from_rgb(255, 85, 255),
|
|
|
|
|
+ 14 => Color32::from_rgb(85, 255, 255),
|
|
|
|
|
+ 15 => Color32::from_rgb(255, 255, 255),
|
|
|
|
|
+ // 6x6x6 color cube
|
|
|
|
|
+ 16..=231 => {
|
|
|
|
|
+ let idx = (idx - 16) as u16;
|
|
|
|
|
+ let ri = idx / 36;
|
|
|
|
|
+ let gi = (idx % 36) / 6;
|
|
|
|
|
+ let bi = idx % 6;
|
|
|
|
|
+ let v = |i: u16| -> u8 {
|
|
|
|
|
+ if i == 0 { 0 } else { 55 + i as u8 * 40 }
|
|
|
|
|
+ };
|
|
|
|
|
+ Color32::from_rgb(v(ri), v(gi), v(bi))
|
|
|
|
|
+ }
|
|
|
|
|
+ // grayscale ramp
|
|
|
|
|
+ 232..=255 => {
|
|
|
|
|
+ let g = 8 + (idx - 232) * 10;
|
|
|
|
|
+ Color32::from_rgb(g, g, g)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn resolve_color(c: TermColor, is_fg: bool) -> Color32 {
|
|
|
|
|
+ match c {
|
|
|
|
|
+ TermColor::Default => {
|
|
|
|
|
+ if is_fg { DEFAULT_FG } else { DEFAULT_BG }
|
|
|
|
|
+ }
|
|
|
|
|
+ TermColor::Indexed(i) => ansi_color(i),
|
|
|
|
|
+ TermColor::Rgb(r, g, b) => Color32::from_rgb(r, g, b),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ----- terminal cell -----
|
|
|
|
|
+
|
|
|
|
|
+#[derive(Clone, Copy)]
|
|
|
|
|
+struct Cell {
|
|
|
|
|
+ ch: char,
|
|
|
|
|
+ fg: TermColor,
|
|
|
|
|
+ bg: TermColor,
|
|
|
|
|
+ bold: bool,
|
|
|
|
|
+ reverse: bool,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl Default for Cell {
|
|
|
|
|
+ fn default() -> Self {
|
|
|
|
|
+ Cell {
|
|
|
|
|
+ ch: ' ',
|
|
|
|
|
+ fg: TermColor::Default,
|
|
|
|
|
+ bg: TermColor::Default,
|
|
|
|
|
+ bold: false,
|
|
|
|
|
+ reverse: false,
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl Cell {
|
|
|
|
|
+ fn resolved_fg(&self) -> Color32 {
|
|
|
|
|
+ if self.reverse {
|
|
|
|
|
+ resolve_color(self.bg, false)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ let c = resolve_color(self.fg, true);
|
|
|
|
|
+ if self.bold {
|
|
|
|
|
+ // brighten bold text a bit
|
|
|
|
|
+ let [r, g, b, a] = c.to_array();
|
|
|
|
|
+ Color32::from_rgba_premultiplied(
|
|
|
|
|
+ r.saturating_add(40),
|
|
|
|
|
+ g.saturating_add(40),
|
|
|
|
|
+ b.saturating_add(40),
|
|
|
|
|
+ a,
|
|
|
|
|
+ )
|
|
|
|
|
+ } else {
|
|
|
|
|
+ c
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn resolved_bg(&self) -> Color32 {
|
|
|
|
|
+ if self.reverse {
|
|
|
|
|
+ resolve_color(self.fg, true)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ resolve_color(self.bg, false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ----- terminal grid -----
|
|
|
|
|
+
|
|
|
|
|
+struct TermGrid {
|
|
|
|
|
+ cells: Vec<Vec<Cell>>,
|
|
|
|
|
+ rows: usize,
|
|
|
|
|
+ cols: usize,
|
|
|
|
|
+ cursor_row: usize,
|
|
|
|
|
+ cursor_col: usize,
|
|
|
|
|
+ cursor_visible: bool,
|
|
|
|
|
+ scroll_top: usize,
|
|
|
|
|
+ scroll_bottom: usize,
|
|
|
|
|
+
|
|
|
|
|
+ // current drawing attributes
|
|
|
|
|
+ attr_fg: TermColor,
|
|
|
|
|
+ attr_bg: TermColor,
|
|
|
|
|
+ attr_bold: bool,
|
|
|
|
|
+ attr_reverse: bool,
|
|
|
|
|
+
|
|
|
|
|
+ // saved cursor
|
|
|
|
|
+ saved_cursor: Option<(usize, usize)>,
|
|
|
|
|
+
|
|
|
|
|
+ // alternate screen buffer
|
|
|
|
|
+ alt_saved: Option<(Vec<Vec<Cell>>, usize, usize)>,
|
|
|
|
|
+
|
|
|
|
|
+ // mouse tracking modes
|
|
|
|
|
+ mouse_normal: bool, // ?1000 - normal tracking (clicks)
|
|
|
|
|
+ mouse_button: bool, // ?1002 - button-event tracking (drag)
|
|
|
|
|
+ mouse_any: bool, // ?1003 - any-event tracking (all motion)
|
|
|
|
|
+ mouse_sgr: bool, // ?1006 - SGR extended coordinates
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl TermGrid {
|
|
|
|
|
+ fn new(rows: usize, cols: usize) -> Self {
|
|
|
|
|
+ TermGrid {
|
|
|
|
|
+ cells: vec![vec![Cell::default(); cols]; rows],
|
|
|
|
|
+ rows,
|
|
|
|
|
+ cols,
|
|
|
|
|
+ cursor_row: 0,
|
|
|
|
|
+ cursor_col: 0,
|
|
|
|
|
+ cursor_visible: true,
|
|
|
|
|
+ scroll_top: 0,
|
|
|
|
|
+ scroll_bottom: rows,
|
|
|
|
|
+ attr_fg: TermColor::Default,
|
|
|
|
|
+ attr_bg: TermColor::Default,
|
|
|
|
|
+ attr_bold: false,
|
|
|
|
|
+ attr_reverse: false,
|
|
|
|
|
+ saved_cursor: None,
|
|
|
|
|
+ alt_saved: None,
|
|
|
|
|
+ mouse_normal: false,
|
|
|
|
|
+ mouse_button: false,
|
|
|
|
|
+ mouse_any: false,
|
|
|
|
|
+ mouse_sgr: false,
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn mouse_enabled(&self) -> bool {
|
|
|
|
|
+ self.mouse_normal || self.mouse_button || self.mouse_any
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn resize(&mut self, new_rows: usize, new_cols: usize) {
|
|
|
|
|
+ if new_rows == self.rows && new_cols == self.cols {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ for row in &mut self.cells {
|
|
|
|
|
+ row.resize(new_cols, Cell::default());
|
|
|
|
|
+ }
|
|
|
|
|
+ while self.cells.len() < new_rows {
|
|
|
|
|
+ self.cells.push(vec![Cell::default(); new_cols]);
|
|
|
|
|
+ }
|
|
|
|
|
+ self.cells.truncate(new_rows);
|
|
|
|
|
+ self.rows = new_rows;
|
|
|
|
|
+ self.cols = new_cols;
|
|
|
|
|
+ self.scroll_top = 0;
|
|
|
|
|
+ self.scroll_bottom = new_rows;
|
|
|
|
|
+ self.cursor_row = self.cursor_row.min(new_rows.saturating_sub(1));
|
|
|
|
|
+ self.cursor_col = self.cursor_col.min(new_cols.saturating_sub(1));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn reset_attrs(&mut self) {
|
|
|
|
|
+ self.attr_fg = TermColor::Default;
|
|
|
|
|
+ self.attr_bg = TermColor::Default;
|
|
|
|
|
+ self.attr_bold = false;
|
|
|
|
|
+ self.attr_reverse = false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn put_char(&mut self, c: char) {
|
|
|
|
|
+ if self.cursor_col >= self.cols {
|
|
|
|
|
+ self.cursor_col = 0;
|
|
|
|
|
+ self.line_feed();
|
|
|
|
|
+ }
|
|
|
|
|
+ if self.cursor_row < self.rows && self.cursor_col < self.cols {
|
|
|
|
|
+ self.cells[self.cursor_row][self.cursor_col] = Cell {
|
|
|
|
|
+ ch: c,
|
|
|
|
|
+ fg: self.attr_fg,
|
|
|
|
|
+ bg: self.attr_bg,
|
|
|
|
|
+ bold: self.attr_bold,
|
|
|
|
|
+ reverse: self.attr_reverse,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+ self.cursor_col += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn line_feed(&mut self) {
|
|
|
|
|
+ if self.cursor_row + 1 >= self.scroll_bottom {
|
|
|
|
|
+ self.scroll_up();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ self.cursor_row += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn scroll_up(&mut self) {
|
|
|
|
|
+ if self.scroll_top < self.scroll_bottom && self.scroll_bottom <= self.rows {
|
|
|
|
|
+ self.cells.remove(self.scroll_top);
|
|
|
|
|
+ self.cells
|
|
|
|
|
+ .insert(self.scroll_bottom - 1, vec![Cell::default(); self.cols]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn scroll_down(&mut self) {
|
|
|
|
|
+ if self.scroll_top < self.scroll_bottom && self.scroll_bottom <= self.rows {
|
|
|
|
|
+ self.cells.remove(self.scroll_bottom - 1);
|
|
|
|
|
+ self.cells
|
|
|
|
|
+ .insert(self.scroll_top, vec![Cell::default(); self.cols]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn erase_display(&mut self, mode: u16) {
|
|
|
|
|
+ match mode {
|
|
|
|
|
+ 0 => {
|
|
|
|
|
+ // cursor to end
|
|
|
|
|
+ for c in self.cursor_col..self.cols {
|
|
|
|
|
+ self.cells[self.cursor_row][c] = Cell::default();
|
|
|
|
|
+ }
|
|
|
|
|
+ for r in (self.cursor_row + 1)..self.rows {
|
|
|
|
|
+ for c in 0..self.cols {
|
|
|
|
|
+ self.cells[r][c] = Cell::default();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ 1 => {
|
|
|
|
|
+ // start to cursor
|
|
|
|
|
+ for r in 0..self.cursor_row {
|
|
|
|
|
+ for c in 0..self.cols {
|
|
|
|
|
+ self.cells[r][c] = Cell::default();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for c in 0..=self.cursor_col.min(self.cols.saturating_sub(1)) {
|
|
|
|
|
+ self.cells[self.cursor_row][c] = Cell::default();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ 2 | 3 => {
|
|
|
|
|
+ // whole screen
|
|
|
|
|
+ for r in 0..self.rows {
|
|
|
|
|
+ for c in 0..self.cols {
|
|
|
|
|
+ self.cells[r][c] = Cell::default();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn erase_line(&mut self, mode: u16) {
|
|
|
|
|
+ let row = self.cursor_row;
|
|
|
|
|
+ if row >= self.rows {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ match mode {
|
|
|
|
|
+ 0 => {
|
|
|
|
|
+ for c in self.cursor_col..self.cols {
|
|
|
|
|
+ self.cells[row][c] = Cell::default();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ 1 => {
|
|
|
|
|
+ for c in 0..=self.cursor_col.min(self.cols.saturating_sub(1)) {
|
|
|
|
|
+ self.cells[row][c] = Cell::default();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ 2 => {
|
|
|
|
|
+ for c in 0..self.cols {
|
|
|
|
|
+ self.cells[row][c] = Cell::default();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn erase_chars(&mut self, n: usize) {
|
|
|
|
|
+ let row = self.cursor_row;
|
|
|
|
|
+ if row >= self.rows {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ for i in 0..n {
|
|
|
|
|
+ let c = self.cursor_col + i;
|
|
|
|
|
+ if c < self.cols {
|
|
|
|
|
+ self.cells[row][c] = Cell::default();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn delete_chars(&mut self, n: usize) {
|
|
|
|
|
+ let row = self.cursor_row;
|
|
|
|
|
+ if row >= self.rows {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ for _ in 0..n {
|
|
|
|
|
+ if self.cursor_col < self.cols {
|
|
|
|
|
+ self.cells[row].remove(self.cursor_col);
|
|
|
|
|
+ self.cells[row].push(Cell::default());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn insert_chars(&mut self, n: usize) {
|
|
|
|
|
+ let row = self.cursor_row;
|
|
|
|
|
+ if row >= self.rows {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ for _ in 0..n {
|
|
|
|
|
+ if self.cursor_col < self.cols {
|
|
|
|
|
+ self.cells[row].insert(self.cursor_col, Cell::default());
|
|
|
|
|
+ self.cells[row].truncate(self.cols);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn insert_lines(&mut self, n: usize) {
|
|
|
|
|
+ for _ in 0..n {
|
|
|
|
|
+ if self.cursor_row < self.scroll_bottom {
|
|
|
|
|
+ if self.scroll_bottom <= self.rows {
|
|
|
|
|
+ self.cells.remove(self.scroll_bottom - 1);
|
|
|
|
|
+ }
|
|
|
|
|
+ self.cells
|
|
|
|
|
+ .insert(self.cursor_row, vec![Cell::default(); self.cols]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn delete_lines(&mut self, n: usize) {
|
|
|
|
|
+ for _ in 0..n {
|
|
|
|
|
+ if self.cursor_row < self.scroll_bottom && self.cursor_row < self.rows {
|
|
|
|
|
+ self.cells.remove(self.cursor_row);
|
|
|
|
|
+ let insert_at = (self.scroll_bottom - 1).min(self.cells.len());
|
|
|
|
|
+ self.cells
|
|
|
|
|
+ .insert(insert_at, vec![Cell::default(); self.cols]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn enter_alt_screen(&mut self) {
|
|
|
|
|
+ self.alt_saved = Some((self.cells.clone(), self.cursor_row, self.cursor_col));
|
|
|
|
|
+ self.erase_display(2);
|
|
|
|
|
+ self.cursor_row = 0;
|
|
|
|
|
+ self.cursor_col = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn leave_alt_screen(&mut self) {
|
|
|
|
|
+ if let Some((cells, row, col)) = self.alt_saved.take() {
|
|
|
|
|
+ self.cells = cells;
|
|
|
|
|
+ self.cursor_row = row;
|
|
|
|
|
+ self.cursor_col = col;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // SGR - set graphics rendition (colors and attributes)
|
|
|
|
|
+ fn sgr(&mut self, params: &[u16]) {
|
|
|
|
|
+ if params.is_empty() {
|
|
|
|
|
+ self.reset_attrs();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ let mut i = 0;
|
|
|
|
|
+ while i < params.len() {
|
|
|
|
|
+ match params[i] {
|
|
|
|
|
+ 0 => self.reset_attrs(),
|
|
|
|
|
+ 1 => self.attr_bold = true,
|
|
|
|
|
+ 7 => self.attr_reverse = true,
|
|
|
|
|
+ 22 => self.attr_bold = false,
|
|
|
|
|
+ 27 => self.attr_reverse = false,
|
|
|
|
|
+ 30..=37 => self.attr_fg = TermColor::Indexed(params[i] as u8 - 30),
|
|
|
|
|
+ 38 => {
|
|
|
|
|
+ // extended fg color
|
|
|
|
|
+ if i + 2 < params.len() && params[i + 1] == 5 {
|
|
|
|
|
+ self.attr_fg = TermColor::Indexed(params[i + 2] as u8);
|
|
|
|
|
+ i += 2;
|
|
|
|
|
+ } else if i + 4 < params.len() && params[i + 1] == 2 {
|
|
|
|
|
+ self.attr_fg = TermColor::Rgb(
|
|
|
|
|
+ params[i + 2] as u8,
|
|
|
|
|
+ params[i + 3] as u8,
|
|
|
|
|
+ params[i + 4] as u8,
|
|
|
|
|
+ );
|
|
|
|
|
+ i += 4;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ 39 => self.attr_fg = TermColor::Default,
|
|
|
|
|
+ 40..=47 => self.attr_bg = TermColor::Indexed(params[i] as u8 - 40),
|
|
|
|
|
+ 48 => {
|
|
|
|
|
+ // extended bg color
|
|
|
|
|
+ if i + 2 < params.len() && params[i + 1] == 5 {
|
|
|
|
|
+ self.attr_bg = TermColor::Indexed(params[i + 2] as u8);
|
|
|
|
|
+ i += 2;
|
|
|
|
|
+ } else if i + 4 < params.len() && params[i + 1] == 2 {
|
|
|
|
|
+ self.attr_bg = TermColor::Rgb(
|
|
|
|
|
+ params[i + 2] as u8,
|
|
|
|
|
+ params[i + 3] as u8,
|
|
|
|
|
+ params[i + 4] as u8,
|
|
|
|
|
+ );
|
|
|
|
|
+ i += 4;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ 49 => self.attr_bg = TermColor::Default,
|
|
|
|
|
+ 90..=97 => self.attr_fg = TermColor::Indexed(params[i] as u8 - 90 + 8),
|
|
|
|
|
+ 100..=107 => self.attr_bg = TermColor::Indexed(params[i] as u8 - 100 + 8),
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+ i += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn handle_csi(&mut self, params: &[u16], intermediates: &[u8], action: char) {
|
|
|
|
|
+ // helper: get param with default
|
|
|
|
|
+ let p = |i: usize, def: u16| -> u16 {
|
|
|
|
|
+ params.get(i).copied().filter(|&v| v > 0).unwrap_or(def)
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let private = intermediates.contains(&b'?');
|
|
|
|
|
+
|
|
|
|
|
+ match action {
|
|
|
|
|
+ 'A' => {
|
|
|
|
|
+ let n = p(0, 1) as usize;
|
|
|
|
|
+ self.cursor_row = self.cursor_row.saturating_sub(n);
|
|
|
|
|
+ }
|
|
|
|
|
+ 'B' => {
|
|
|
|
|
+ let n = p(0, 1) as usize;
|
|
|
|
|
+ self.cursor_row = (self.cursor_row + n).min(self.rows.saturating_sub(1));
|
|
|
|
|
+ }
|
|
|
|
|
+ 'C' => {
|
|
|
|
|
+ let n = p(0, 1) as usize;
|
|
|
|
|
+ self.cursor_col = (self.cursor_col + n).min(self.cols.saturating_sub(1));
|
|
|
|
|
+ }
|
|
|
|
|
+ 'D' => {
|
|
|
|
|
+ let n = p(0, 1) as usize;
|
|
|
|
|
+ self.cursor_col = self.cursor_col.saturating_sub(n);
|
|
|
|
|
+ }
|
|
|
|
|
+ 'H' | 'f' => {
|
|
|
|
|
+ // cursor position (1-based)
|
|
|
|
|
+ let row = p(0, 1) as usize;
|
|
|
|
|
+ let col = p(1, 1) as usize;
|
|
|
|
|
+ self.cursor_row = row.saturating_sub(1).min(self.rows.saturating_sub(1));
|
|
|
|
|
+ self.cursor_col = col.saturating_sub(1).min(self.cols.saturating_sub(1));
|
|
|
|
|
+ }
|
|
|
|
|
+ 'J' => self.erase_display(p(0, 0)),
|
|
|
|
|
+ 'K' => self.erase_line(p(0, 0)),
|
|
|
|
|
+ 'L' => self.insert_lines(p(0, 1) as usize),
|
|
|
|
|
+ 'M' => self.delete_lines(p(0, 1) as usize),
|
|
|
|
|
+ 'P' => self.delete_chars(p(0, 1) as usize),
|
|
|
|
|
+ 'X' => self.erase_chars(p(0, 1) as usize),
|
|
|
|
|
+ '@' => self.insert_chars(p(0, 1) as usize),
|
|
|
|
|
+ 'G' | '`' => {
|
|
|
|
|
+ let col = p(0, 1) as usize;
|
|
|
|
|
+ self.cursor_col = col.saturating_sub(1).min(self.cols.saturating_sub(1));
|
|
|
|
|
+ }
|
|
|
|
|
+ 'd' => {
|
|
|
|
|
+ let row = p(0, 1) as usize;
|
|
|
|
|
+ self.cursor_row = row.saturating_sub(1).min(self.rows.saturating_sub(1));
|
|
|
|
|
+ }
|
|
|
|
|
+ 'S' => {
|
|
|
|
|
+ for _ in 0..p(0, 1) {
|
|
|
|
|
+ self.scroll_up();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ 'T' => {
|
|
|
|
|
+ for _ in 0..p(0, 1) {
|
|
|
|
|
+ self.scroll_down();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ 'm' => {
|
|
|
|
|
+ if params.is_empty() {
|
|
|
|
|
+ self.sgr(&[0]);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ self.sgr(params);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ 'r' => {
|
|
|
|
|
+ let top = p(0, 1) as usize;
|
|
|
|
|
+ let bottom = p(1, self.rows as u16) as usize;
|
|
|
|
|
+ self.scroll_top = top.saturating_sub(1);
|
|
|
|
|
+ self.scroll_bottom = bottom.min(self.rows);
|
|
|
|
|
+ }
|
|
|
|
|
+ 's' => {
|
|
|
|
|
+ self.saved_cursor = Some((self.cursor_row, self.cursor_col));
|
|
|
|
|
+ }
|
|
|
|
|
+ 'u' => {
|
|
|
|
|
+ if let Some((r, c)) = self.saved_cursor {
|
|
|
|
|
+ self.cursor_row = r.min(self.rows.saturating_sub(1));
|
|
|
|
|
+ self.cursor_col = c.min(self.cols.saturating_sub(1));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ 'h' if private => {
|
|
|
|
|
+ for ¶m in params {
|
|
|
|
|
+ match param {
|
|
|
|
|
+ 25 => self.cursor_visible = true,
|
|
|
|
|
+ 1000 => self.mouse_normal = true,
|
|
|
|
|
+ 1002 => self.mouse_button = true,
|
|
|
|
|
+ 1003 => self.mouse_any = true,
|
|
|
|
|
+ 1006 => self.mouse_sgr = true,
|
|
|
|
|
+ 1049 => self.enter_alt_screen(),
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ 'l' if private => {
|
|
|
|
|
+ for ¶m in params {
|
|
|
|
|
+ match param {
|
|
|
|
|
+ 25 => self.cursor_visible = false,
|
|
|
|
|
+ 1000 => self.mouse_normal = false,
|
|
|
|
|
+ 1002 => self.mouse_button = false,
|
|
|
|
|
+ 1003 => self.mouse_any = false,
|
|
|
|
|
+ 1006 => self.mouse_sgr = false,
|
|
|
|
|
+ 1049 => self.leave_alt_screen(),
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ----- vte perform implementation -----
|
|
|
|
|
+
|
|
|
|
|
+impl Perform for TermGrid {
|
|
|
|
|
+ fn print(&mut self, c: char) {
|
|
|
|
|
+ self.put_char(c);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn execute(&mut self, byte: u8) {
|
|
|
|
|
+ match byte {
|
|
|
|
|
+ 0x08 => {
|
|
|
|
|
+ // backspace
|
|
|
|
|
+ self.cursor_col = self.cursor_col.saturating_sub(1);
|
|
|
|
|
+ }
|
|
|
|
|
+ 0x09 => {
|
|
|
|
|
+ // tab - next tab stop (every 8 cols)
|
|
|
|
|
+ self.cursor_col = ((self.cursor_col / 8) + 1) * 8;
|
|
|
|
|
+ if self.cursor_col >= self.cols {
|
|
|
|
|
+ self.cursor_col = self.cols.saturating_sub(1);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ 0x0A | 0x0B | 0x0C => {
|
|
|
|
|
+ // line feed
|
|
|
|
|
+ self.line_feed();
|
|
|
|
|
+ }
|
|
|
|
|
+ 0x0D => {
|
|
|
|
|
+ // carriage return
|
|
|
|
|
+ self.cursor_col = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], ignore: bool, action: char) {
|
|
|
|
|
+ if ignore {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ let flat: Vec<u16> = params.iter().map(|sub| sub[0]).collect();
|
|
|
|
|
+ self.handle_csi(&flat, intermediates, action);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
|
|
|
|
|
+ if !intermediates.is_empty() {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ match byte {
|
|
|
|
|
+ b'7' => {
|
|
|
|
|
+ self.saved_cursor = Some((self.cursor_row, self.cursor_col));
|
|
|
|
|
+ }
|
|
|
|
|
+ b'8' => {
|
|
|
|
|
+ if let Some((r, c)) = self.saved_cursor {
|
|
|
|
|
+ self.cursor_row = r.min(self.rows.saturating_sub(1));
|
|
|
|
|
+ self.cursor_col = c.min(self.cols.saturating_sub(1));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ b'D' => self.line_feed(),
|
|
|
|
|
+ b'M' => {
|
|
|
|
|
+ // reverse index
|
|
|
|
|
+ if self.cursor_row == self.scroll_top {
|
|
|
|
|
+ self.scroll_down();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ self.cursor_row = self.cursor_row.saturating_sub(1);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {}
|
|
|
|
|
+ fn hook(&mut self, _params: &Params, _intermediates: &[u8], _ignore: bool, _action: char) {}
|
|
|
|
|
+ fn put(&mut self, _byte: u8) {}
|
|
|
|
|
+ fn unhook(&mut self) {}
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ----- keyboard input mapping -----
|
|
|
|
|
+
|
|
|
|
|
+// map egui keys to terminal escape sequences
|
|
|
|
|
+fn special_key_bytes(key: &egui::Key, modifiers: &egui::Modifiers) -> Option<Vec<u8>> {
|
|
|
|
|
+ use egui::Key;
|
|
|
|
|
+ match key {
|
|
|
|
|
+ Key::ArrowUp => Some(b"\x1b[A".to_vec()),
|
|
|
|
|
+ Key::ArrowDown => Some(b"\x1b[B".to_vec()),
|
|
|
|
|
+ Key::ArrowRight => Some(b"\x1b[C".to_vec()),
|
|
|
|
|
+ Key::ArrowLeft => Some(b"\x1b[D".to_vec()),
|
|
|
|
|
+ Key::Home => Some(b"\x1b[H".to_vec()),
|
|
|
|
|
+ Key::End => Some(b"\x1b[F".to_vec()),
|
|
|
|
|
+ Key::PageUp => Some(b"\x1b[5~".to_vec()),
|
|
|
|
|
+ Key::PageDown => Some(b"\x1b[6~".to_vec()),
|
|
|
|
|
+ Key::Insert => Some(b"\x1b[2~".to_vec()),
|
|
|
|
|
+ Key::Delete => Some(b"\x1b[3~".to_vec()),
|
|
|
|
|
+ Key::Escape => Some(b"\x1b".to_vec()),
|
|
|
|
|
+ Key::Tab => {
|
|
|
|
|
+ if modifiers.shift {
|
|
|
|
|
+ Some(b"\x1b[Z".to_vec())
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Some(b"\x09".to_vec())
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Key::Backspace => Some(b"\x7f".to_vec()),
|
|
|
|
|
+ Key::Enter => Some(b"\x0d".to_vec()),
|
|
|
|
|
+ Key::F1 => Some(b"\x1bOP".to_vec()),
|
|
|
|
|
+ Key::F2 => Some(b"\x1bOQ".to_vec()),
|
|
|
|
|
+ Key::F3 => Some(b"\x1bOR".to_vec()),
|
|
|
|
|
+ Key::F4 => Some(b"\x1bOS".to_vec()),
|
|
|
|
|
+ Key::F5 => Some(b"\x1b[15~".to_vec()),
|
|
|
|
|
+ Key::F6 => Some(b"\x1b[17~".to_vec()),
|
|
|
|
|
+ Key::F7 => Some(b"\x1b[18~".to_vec()),
|
|
|
|
|
+ Key::F8 => Some(b"\x1b[19~".to_vec()),
|
|
|
|
|
+ Key::F9 => Some(b"\x1b[20~".to_vec()),
|
|
|
|
|
+ Key::F10 => Some(b"\x1b[21~".to_vec()),
|
|
|
|
|
+ Key::F11 => Some(b"\x1b[23~".to_vec()),
|
|
|
|
|
+ Key::F12 => Some(b"\x1b[24~".to_vec()),
|
|
|
|
|
+ _ => None,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ctrl+letter -> control character byte
|
|
|
|
|
+fn ctrl_key_byte(key: &egui::Key) -> Option<u8> {
|
|
|
|
|
+ use egui::Key;
|
|
|
|
|
+ match key {
|
|
|
|
|
+ Key::A => Some(0x01),
|
|
|
|
|
+ Key::B => Some(0x02),
|
|
|
|
|
+ Key::C => Some(0x03),
|
|
|
|
|
+ Key::D => Some(0x04),
|
|
|
|
|
+ Key::E => Some(0x05),
|
|
|
|
|
+ Key::F => Some(0x06),
|
|
|
|
|
+ Key::G => Some(0x07),
|
|
|
|
|
+ Key::H => Some(0x08),
|
|
|
|
|
+ Key::I => Some(0x09),
|
|
|
|
|
+ Key::J => Some(0x0A),
|
|
|
|
|
+ Key::K => Some(0x0B),
|
|
|
|
|
+ Key::L => Some(0x0C),
|
|
|
|
|
+ Key::M => Some(0x0D),
|
|
|
|
|
+ Key::N => Some(0x0E),
|
|
|
|
|
+ Key::O => Some(0x0F),
|
|
|
|
|
+ Key::P => Some(0x10),
|
|
|
|
|
+ Key::Q => Some(0x11),
|
|
|
|
|
+ Key::R => Some(0x12),
|
|
|
|
|
+ Key::S => Some(0x13),
|
|
|
|
|
+ Key::T => Some(0x14),
|
|
|
|
|
+ Key::U => Some(0x15),
|
|
|
|
|
+ Key::V => Some(0x16),
|
|
|
|
|
+ Key::W => Some(0x17),
|
|
|
|
|
+ Key::X => Some(0x18),
|
|
|
|
|
+ Key::Y => Some(0x19),
|
|
|
|
|
+ Key::Z => Some(0x1A),
|
|
|
|
|
+ _ => None,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ----- the egui app -----
|
|
|
|
|
+
|
|
|
|
|
+struct HoardomApp {
|
|
|
|
|
+ grid: Arc<Mutex<TermGrid>>,
|
|
|
|
|
+ pty_writer: Mutex<Box<dyn Write + Send>>,
|
|
|
|
|
+ pty_master: Box<dyn portable_pty::MasterPty + Send>,
|
|
|
|
|
+ child_exited: Arc<AtomicBool>,
|
|
|
|
|
+ cell_width: f32,
|
|
|
|
|
+ cell_height: f32,
|
|
|
|
|
+ current_cols: u16,
|
|
|
|
|
+ current_rows: u16,
|
|
|
|
|
+ last_mouse_button: Option<u8>, // track held mouse button for drag/release
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl eframe::App for HoardomApp {
|
|
|
|
|
+ fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
|
|
|
+ // bail if the child process is gone
|
|
|
|
|
+ if self.child_exited.load(Ordering::Relaxed) {
|
|
|
|
|
+ ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // measure cell dimensions on first frame (cant do it in creation callback)
|
|
|
|
|
+ if self.cell_width == 0.0 {
|
|
|
|
|
+ let (cw, ch) = ctx.fonts(|f| {
|
|
|
|
|
+ let fid = FontId::monospace(FONT_SIZE);
|
|
|
|
|
+ let galley = f.layout_no_wrap("M".into(), fid.clone(), DEFAULT_FG);
|
|
|
|
|
+ let row_h = f.row_height(&fid);
|
|
|
|
|
+ (galley.rect.width(), row_h)
|
|
|
|
|
+ });
|
|
|
|
|
+ self.cell_width = cw;
|
|
|
|
|
+ self.cell_height = ch;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // handle keyboard input
|
|
|
|
|
+ ctx.input(|input| {
|
|
|
|
|
+ for event in &input.events {
|
|
|
|
|
+ match event {
|
|
|
|
|
+ egui::Event::Text(text) => {
|
|
|
|
|
+ // only pass printable chars (specials handled via Key events)
|
|
|
|
|
+ let filtered: String = text.chars().filter(|c| !c.is_control()).collect();
|
|
|
|
|
+ if !filtered.is_empty() {
|
|
|
|
|
+ if let Ok(mut w) = self.pty_writer.lock() {
|
|
|
|
|
+ let _ = w.write_all(filtered.as_bytes());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ egui::Event::Key {
|
|
|
|
|
+ key,
|
|
|
|
|
+ pressed: true,
|
|
|
|
|
+ modifiers,
|
|
|
|
|
+ ..
|
|
|
|
|
+ } => {
|
|
|
|
|
+ if modifiers.ctrl || modifiers.mac_cmd {
|
|
|
|
|
+ if let Some(byte) = ctrl_key_byte(key) {
|
|
|
|
|
+ if let Ok(mut w) = self.pty_writer.lock() {
|
|
|
|
|
+ let _ = w.write_all(&[byte]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if let Some(bytes) = special_key_bytes(key, modifiers) {
|
|
|
|
|
+ if let Ok(mut w) = self.pty_writer.lock() {
|
|
|
|
|
+ let _ = w.write_all(&bytes);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => {}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // handle mouse input
|
|
|
|
|
+ self.handle_mouse(ctx);
|
|
|
|
|
+
|
|
|
|
|
+ // check if window was resized, update pty dimensions
|
|
|
|
|
+ let avail = ctx.available_rect();
|
|
|
|
|
+ if self.cell_width > 0.0 && self.cell_height > 0.0 {
|
|
|
|
|
+ let new_cols = (avail.width() / self.cell_width).floor() as u16;
|
|
|
|
|
+ let new_rows = (avail.height() / self.cell_height).floor() as u16;
|
|
|
|
|
+ let new_cols = new_cols.max(20);
|
|
|
|
|
+ let new_rows = new_rows.max(10);
|
|
|
|
|
+
|
|
|
|
|
+ if new_cols != self.current_cols || new_rows != self.current_rows {
|
|
|
|
|
+ self.current_cols = new_cols;
|
|
|
|
|
+ self.current_rows = new_rows;
|
|
|
|
|
+ let _ = self.pty_master.resize(PtySize {
|
|
|
|
|
+ rows: new_rows,
|
|
|
|
|
+ cols: new_cols,
|
|
|
|
|
+ pixel_width: 0,
|
|
|
|
|
+ pixel_height: 0,
|
|
|
|
|
+ });
|
|
|
|
|
+ if let Ok(mut grid) = self.grid.lock() {
|
|
|
|
|
+ grid.resize(new_rows as usize, new_cols as usize);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // render the terminal grid
|
|
|
|
|
+ egui::CentralPanel::default()
|
|
|
|
|
+ .frame(egui::Frame::default().fill(DEFAULT_BG))
|
|
|
|
|
+ .show(ctx, |ui| {
|
|
|
|
|
+ self.render_grid(ui);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ ctx.request_repaint_after(Duration::from_millis(16));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl HoardomApp {
|
|
|
|
|
+ // translate egui pointer events to terminal mouse sequences
|
|
|
|
|
+ fn handle_mouse(&mut self, ctx: &egui::Context) {
|
|
|
|
|
+ let (mouse_enabled, use_sgr) = {
|
|
|
|
|
+ match self.grid.lock() {
|
|
|
|
|
+ Ok(g) => (g.mouse_enabled(), g.mouse_sgr),
|
|
|
|
|
+ Err(_) => return,
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ if !mouse_enabled {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let cw = self.cell_width;
|
|
|
|
|
+ let ch = self.cell_height;
|
|
|
|
|
+ if cw <= 0.0 || ch <= 0.0 {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let avail = ctx.available_rect();
|
|
|
|
|
+
|
|
|
|
|
+ ctx.input(|input| {
|
|
|
|
|
+ if let Some(pos) = input.pointer.latest_pos() {
|
|
|
|
|
+ let col = ((pos.x - avail.min.x) / cw).floor() as i32;
|
|
|
|
|
+ let row = ((pos.y - avail.min.y) / ch).floor() as i32;
|
|
|
|
|
+ let col = col.max(0) as u16;
|
|
|
|
|
+ let row = row.max(0) as u16;
|
|
|
|
|
+
|
|
|
|
|
+ // scroll events
|
|
|
|
|
+ let scroll_y = input.raw_scroll_delta.y;
|
|
|
|
|
+ if scroll_y != 0.0 {
|
|
|
|
|
+ let button: u8 = if scroll_y > 0.0 { 64 } else { 65 };
|
|
|
|
|
+ let seq = if use_sgr {
|
|
|
|
|
+ format!("\x1b[<{};{};{}M", button, col + 1, row + 1)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ let cb = (button + 32) as char;
|
|
|
|
|
+ let cx = (col + 33).min(255) as u8 as char;
|
|
|
|
|
+ let cy = (row + 33).min(255) as u8 as char;
|
|
|
|
|
+ format!("\x1b[M{}{}{}", cb, cx, cy)
|
|
|
|
|
+ };
|
|
|
|
|
+ if let Ok(mut w) = self.pty_writer.lock() {
|
|
|
|
|
+ let _ = w.write_all(seq.as_bytes());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // button press
|
|
|
|
|
+ if input.pointer.any_pressed() {
|
|
|
|
|
+ let button: u8 = if input.pointer.button_pressed(egui::PointerButton::Primary) {
|
|
|
|
|
+ 0
|
|
|
|
|
+ } else if input.pointer.button_pressed(egui::PointerButton::Middle) {
|
|
|
|
|
+ 1
|
|
|
|
|
+ } else if input.pointer.button_pressed(egui::PointerButton::Secondary) {
|
|
|
|
|
+ 2
|
|
|
|
|
+ } else {
|
|
|
|
|
+ 0
|
|
|
|
|
+ };
|
|
|
|
|
+ self.last_mouse_button = Some(button);
|
|
|
|
|
+ let seq = if use_sgr {
|
|
|
|
|
+ format!("\x1b[<{};{};{}M", button, col + 1, row + 1)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ let cb = (button + 32) as char;
|
|
|
|
|
+ let cx = (col + 33).min(255) as u8 as char;
|
|
|
|
|
+ let cy = (row + 33).min(255) as u8 as char;
|
|
|
|
|
+ format!("\x1b[M{}{}{}", cb, cx, cy)
|
|
|
|
|
+ };
|
|
|
|
|
+ if let Ok(mut w) = self.pty_writer.lock() {
|
|
|
|
|
+ let _ = w.write_all(seq.as_bytes());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // button release
|
|
|
|
|
+ if input.pointer.any_released() {
|
|
|
|
|
+ let button = self.last_mouse_button.unwrap_or(0);
|
|
|
|
|
+ self.last_mouse_button = None;
|
|
|
|
|
+ let seq = if use_sgr {
|
|
|
|
|
+ format!("\x1b[<{};{};{}m", button, col + 1, row + 1)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ let cb = (3u8 + 32) as char; // release = button 3 in normal mode
|
|
|
|
|
+ let cx = (col + 33).min(255) as u8 as char;
|
|
|
|
|
+ let cy = (row + 33).min(255) as u8 as char;
|
|
|
|
|
+ format!("\x1b[M{}{}{}", cb, cx, cy)
|
|
|
|
|
+ };
|
|
|
|
|
+ if let Ok(mut w) = self.pty_writer.lock() {
|
|
|
|
|
+ let _ = w.write_all(seq.as_bytes());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // drag / motion
|
|
|
|
|
+ if input.pointer.is_moving() && self.last_mouse_button.is_some() {
|
|
|
|
|
+ let button = self.last_mouse_button.unwrap_or(0) + 32; // motion flag
|
|
|
|
|
+ let seq = if use_sgr {
|
|
|
|
|
+ format!("\x1b[<{};{};{}M", button, col + 1, row + 1)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ let cb = (button + 32) as char;
|
|
|
|
|
+ let cx = (col + 33).min(255) as u8 as char;
|
|
|
|
|
+ let cy = (row + 33).min(255) as u8 as char;
|
|
|
|
|
+ format!("\x1b[M{}{}{}", cb, cx, cy)
|
|
|
|
|
+ };
|
|
|
|
|
+ if let Ok(mut w) = self.pty_writer.lock() {
|
|
|
|
|
+ let _ = w.write_all(seq.as_bytes());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fn render_grid(&self, ui: &mut egui::Ui) {
|
|
|
|
|
+ let grid = match self.grid.lock() {
|
|
|
|
|
+ Ok(g) => g,
|
|
|
|
|
+ Err(_) => return,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let painter = ui.painter();
|
|
|
|
|
+ let rect = ui.available_rect_before_wrap();
|
|
|
|
|
+ let cw = self.cell_width;
|
|
|
|
|
+ let ch = self.cell_height;
|
|
|
|
|
+
|
|
|
|
|
+ // draw each row - render character by character at exact cell positions
|
|
|
|
|
+ // to keep backgrounds and text perfectly aligned
|
|
|
|
|
+ for row in 0..grid.rows {
|
|
|
|
|
+ let y = rect.min.y + row as f32 * ch;
|
|
|
|
|
+
|
|
|
|
|
+ // draw background spans (batch consecutive same-bg cells)
|
|
|
|
|
+ let mut bg_start = 0usize;
|
|
|
|
|
+ let mut current_bg = grid.cells[row][0].resolved_bg();
|
|
|
|
|
+
|
|
|
|
|
+ for col in 0..=grid.cols {
|
|
|
|
|
+ let cell_bg = if col < grid.cols {
|
|
|
|
|
+ grid.cells[row][col].resolved_bg()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Color32::TRANSPARENT // sentinel to flush last span
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if cell_bg != current_bg || col == grid.cols {
|
|
|
|
|
+ // draw the background span
|
|
|
|
|
+ if current_bg != DEFAULT_BG {
|
|
|
|
|
+ let x0 = rect.min.x + bg_start as f32 * cw;
|
|
|
|
|
+ let x1 = rect.min.x + col as f32 * cw;
|
|
|
|
|
+ painter.rect_filled(
|
|
|
|
|
+ Rect::from_min_max(egui::pos2(x0, y), egui::pos2(x1, y + ch)),
|
|
|
|
|
+ 0.0,
|
|
|
|
|
+ current_bg,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ bg_start = col;
|
|
|
|
|
+ current_bg = cell_bg;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // draw text - render each cell at its exact x position
|
|
|
|
|
+ // this prevents sub-pixel drift that causes bg/text misalignment
|
|
|
|
|
+ for col in 0..grid.cols {
|
|
|
|
|
+ let cell = &grid.cells[row][col];
|
|
|
|
|
+ if cell.ch == ' ' || cell.ch == '\0' {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ let x = rect.min.x + col as f32 * cw;
|
|
|
|
|
+ let fg = cell.resolved_fg();
|
|
|
|
|
+ let mut buf = [0u8; 4];
|
|
|
|
|
+ let s = cell.ch.encode_utf8(&mut buf);
|
|
|
|
|
+ painter.text(
|
|
|
|
|
+ egui::pos2(x, y),
|
|
|
|
|
+ egui::Align2::LEFT_TOP,
|
|
|
|
|
+ s,
|
|
|
|
|
+ FontId::monospace(FONT_SIZE),
|
|
|
|
|
+ fg,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // draw cursor
|
|
|
|
|
+ if grid.cursor_visible && grid.cursor_row < grid.rows && grid.cursor_col < grid.cols {
|
|
|
|
|
+ let cx = rect.min.x + grid.cursor_col as f32 * cw;
|
|
|
|
|
+ let cy = rect.min.y + grid.cursor_row as f32 * ch;
|
|
|
|
|
+ painter.rect_filled(
|
|
|
|
|
+ Rect::from_min_size(egui::pos2(cx, cy), egui::vec2(cw, ch)),
|
|
|
|
|
+ 0.0,
|
|
|
|
|
+ Color32::from_rgba_premultiplied(180, 180, 180, 100),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // reserve the space so egui knows we used it
|
|
|
|
|
+ ui.allocate_exact_size(
|
|
|
|
|
+ egui::vec2(grid.cols as f32 * cw, grid.rows as f32 * ch),
|
|
|
|
|
+ Sense::hover(),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ----- find the hoardom binary -----
|
|
|
|
|
+
|
|
|
|
|
+fn find_hoardom() -> PathBuf {
|
|
|
|
|
+ // check same directory as ourselves
|
|
|
|
|
+ if let Ok(exe) = std::env::current_exe() {
|
|
|
|
|
+ if let Some(dir) = exe.parent() {
|
|
|
|
|
+ // check for hoardom next to us
|
|
|
|
|
+ let candidate = dir.join("hoardom");
|
|
|
|
|
+ if candidate.exists() && candidate != exe {
|
|
|
|
|
+ return candidate;
|
|
|
|
|
+ }
|
|
|
|
|
+ // in a mac .app bundle the binary might be named differently
|
|
|
|
|
+ let candidate = dir.join("hoardom-bin");
|
|
|
|
|
+ if candidate.exists() {
|
|
|
|
|
+ return candidate;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // fall back to PATH
|
|
|
|
|
+ PathBuf::from("hoardom")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ----- main -----
|
|
|
|
|
+
|
|
|
|
|
+fn main() -> eframe::Result<()> {
|
|
|
|
|
+ let hoardom_bin = find_hoardom();
|
|
|
|
|
+
|
|
|
|
|
+ // setup pty
|
|
|
|
|
+ let pty_system = native_pty_system();
|
|
|
|
|
+ let pair = pty_system
|
|
|
|
|
+ .openpty(PtySize {
|
|
|
|
|
+ rows: DEFAULT_ROWS,
|
|
|
|
|
+ cols: DEFAULT_COLS,
|
|
|
|
|
+ pixel_width: 0,
|
|
|
|
|
+ pixel_height: 0,
|
|
|
|
|
+ })
|
|
|
|
|
+ .expect("failed to open pty");
|
|
|
|
|
+
|
|
|
|
|
+ // spawn hoardom --tui in the pty
|
|
|
|
|
+ let mut cmd = CommandBuilder::new(&hoardom_bin);
|
|
|
|
|
+ cmd.arg("--tui");
|
|
|
|
|
+ cmd.env("TERM", "xterm-256color");
|
|
|
|
|
+
|
|
|
|
|
+ let mut child = pair
|
|
|
|
|
+ .slave
|
|
|
|
|
+ .spawn_command(cmd)
|
|
|
|
|
+ .unwrap_or_else(|e| panic!("failed to spawn {:?}: {}", hoardom_bin, e));
|
|
|
|
|
+
|
|
|
|
|
+ // close the slave end in the parent so pty gets proper eof
|
|
|
|
|
+ drop(pair.slave);
|
|
|
|
|
+
|
|
|
|
|
+ let reader = pair
|
|
|
|
|
+ .master
|
|
|
|
|
+ .try_clone_reader()
|
|
|
|
|
+ .expect("failed to clone pty reader");
|
|
|
|
|
+ let writer = pair
|
|
|
|
|
+ .master
|
|
|
|
|
+ .take_writer()
|
|
|
|
|
+ .expect("failed to take pty writer");
|
|
|
|
|
+
|
|
|
|
|
+ let grid = Arc::new(Mutex::new(TermGrid::new(
|
|
|
|
|
+ DEFAULT_ROWS as usize,
|
|
|
|
|
+ DEFAULT_COLS as usize,
|
|
|
|
|
+ )));
|
|
|
|
|
+ let child_exited = Arc::new(AtomicBool::new(false));
|
|
|
|
|
+
|
|
|
|
|
+ // egui context holder so the reader thread can request repaints
|
|
|
|
|
+ let ctx_holder: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));
|
|
|
|
|
+
|
|
|
|
|
+ // reader thread: reads pty output and feeds it through the vt parser
|
|
|
|
|
+ let grid_clone = grid.clone();
|
|
|
|
|
+ let exited_clone = child_exited.clone();
|
|
|
|
|
+ let ctx_clone = ctx_holder.clone();
|
|
|
|
|
+ thread::spawn(move || {
|
|
|
|
|
+ let mut parser = vte::Parser::new();
|
|
|
|
|
+ let mut reader = reader;
|
|
|
|
|
+ let mut buf = [0u8; 8192];
|
|
|
|
|
+ loop {
|
|
|
|
|
+ match reader.read(&mut buf) {
|
|
|
|
|
+ Ok(0) | Err(_) => {
|
|
|
|
|
+ exited_clone.store(true, Ordering::Relaxed);
|
|
|
|
|
+ if let Ok(lock) = ctx_clone.lock() {
|
|
|
|
|
+ if let Some(ctx) = lock.as_ref() {
|
|
|
|
|
+ ctx.request_repaint();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(n) => {
|
|
|
|
|
+ if let Ok(mut g) = grid_clone.lock() {
|
|
|
|
|
+ parser.advance(&mut *g, &buf[..n]);
|
|
|
|
|
+ }
|
|
|
|
|
+ if let Ok(lock) = ctx_clone.lock() {
|
|
|
|
|
+ if let Some(ctx) = lock.as_ref() {
|
|
|
|
|
+ ctx.request_repaint();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // child reaper thread
|
|
|
|
|
+ let exited_clone2 = child_exited.clone();
|
|
|
|
|
+ thread::spawn(move || {
|
|
|
|
|
+ let _ = child.wait();
|
|
|
|
|
+ exited_clone2.store(true, Ordering::Relaxed);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // calculate initial window size from cell dimensions
|
|
|
|
|
+ // (rough estimate, refined on first frame)
|
|
|
|
|
+ let est_width = DEFAULT_COLS as f32 * 8.5 + 20.0;
|
|
|
|
|
+ let est_height = DEFAULT_ROWS as f32 * 18.0 + 20.0;
|
|
|
|
|
+
|
|
|
|
|
+ let options = eframe::NativeOptions {
|
|
|
|
|
+ viewport: egui::ViewportBuilder::default()
|
|
|
|
|
+ .with_title("hoardom")
|
|
|
|
|
+ .with_inner_size([est_width, est_height])
|
|
|
|
|
+ .with_min_inner_size([300.0, 200.0]),
|
|
|
|
|
+ ..Default::default()
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ eframe::run_native(
|
|
|
|
|
+ "hoardom",
|
|
|
|
|
+ options,
|
|
|
|
|
+ Box::new(move |cc| {
|
|
|
|
|
+ // store the egui context for the reader thread
|
|
|
|
|
+ if let Ok(mut holder) = ctx_holder.lock() {
|
|
|
|
|
+ *holder = Some(cc.egui_ctx.clone());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ cc.egui_ctx.set_visuals(egui::Visuals::dark());
|
|
|
|
|
+
|
|
|
|
|
+ Ok(Box::new(HoardomApp {
|
|
|
|
|
+ grid,
|
|
|
|
|
+ pty_writer: Mutex::new(writer),
|
|
|
|
|
+ pty_master: pair.master,
|
|
|
|
|
+ child_exited,
|
|
|
|
|
+ cell_width: 0.0, // measured on first frame
|
|
|
|
|
+ cell_height: 0.0,
|
|
|
|
|
+ current_cols: DEFAULT_COLS,
|
|
|
|
|
+ current_rows: DEFAULT_ROWS,
|
|
|
|
|
+ last_mouse_button: None,
|
|
|
|
|
+ }))
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+}
|