| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171 |
- // 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")
- }
- // ----- 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<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])
- .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,
- }))
- }),
- )
- }
|