aboutsummaryrefslogtreecommitdiff
path: root/src/app.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app.rs')
-rw-r--r--src/app.rs1155
1 files changed, 1155 insertions, 0 deletions
diff --git a/src/app.rs b/src/app.rs
new file mode 100644
index 0000000..4f3aefb
--- /dev/null
+++ b/src/app.rs
@@ -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 &param 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 &param 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,
+ }))
+ }),
+ )
+}