// 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>, 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>, 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 = 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> { 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 { 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>, pty_writer: Mutex>, pty_master: Box, child_exited: Arc, cell_width: f32, cell_height: f32, current_cols: u16, current_rows: u16, last_mouse_button: Option, // 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") } // ----- load app icon ----- fn load_icon() -> egui::IconData { let png_bytes = include_bytes!("../dist/AppIcon.png"); let img = image::load_from_memory_with_format(png_bytes, image::ImageFormat::Png) .expect("failed to decode embedded icon") .into_rgba8(); let (w, h) = img.dimensions(); egui::IconData { rgba: img.into_raw(), width: w, height: h, } } // ----- 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>> = 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]) .with_icon(load_icon()), ..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, })) }), ) }