From 4cc3e2bf861ba04e3924e337b9ac5d3e8d21eb02 Mon Sep 17 00:00:00 2001 From: UMTS at Teleco Date: Sun, 8 Mar 2026 15:09:27 +0100 Subject: sexy --- src/app.rs | 1155 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/cli.rs | 33 +- 2 files changed, 1170 insertions(+), 18 deletions(-) create mode 100644 src/app.rs (limited to 'src') 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>, + 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") +} + +// ----- 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]), + ..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, + })) + }), + ) +} diff --git a/src/cli.rs b/src/cli.rs index 297e4e3..e4f905b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -135,13 +135,16 @@ Mode : --tui Easy to use Terminal based Graphical user interface Basics : --e --environement=PATH Define where .hoardom folder should be - Defaults to /home/USER/.hoardom/ - Stores settings, imported lists, favs, cache etc. -a --all Show all in list even when unavailable - (Unless changed after launch in TUI mode) +-H --fullhelp Show full help --H --fullhelp Show full help", +Example usage : +hoardom --tui Launch Terminal UI. +hoardom idea.com See if idea.com is available +hoardom -a idea1 idea2 See Table of available domains starting with that + + +", env!("CARGO_PKG_VERSION") ); } @@ -157,18 +160,14 @@ Mode : --tui Easy to use Terminal based Graphical user interface Basics : --e --environement=PATH Define where .hoardom folder should be - Defaults to /home/USER/.hoardom/ - Stores settings, imported lists, favs, cache etc. -a --all Show all in list even when unavailable - (Unless changed after launch in TUI mode) +-c --csv=PATH Out in CSV, Path is optional +-l --list=LIST Built in TLD Lists are : {} Advanced : --c --csv=PATH Out in CSV,Path is optional - if path isnt given will be printed to terminal with no logs --l --list=LIST Built in TLD Lists are : {} - Selects which list is applied - (Unless changed after launch in TUI mode) +-e --environement=PATH Define where .hoardom folder should be + Defaults to /home/USER/.hoardom/ + Stores settings, imported lists, favs, cache etc. -i --import-filter=PATH Import a custom toml list for this session -t --top=TLD,TLD Set certain TLDs to show up as first result for when you need a domain in your country or for searching @@ -182,13 +181,11 @@ Advanced : Various : -j --jobs=NUMBER Number of concurrent lookup requests - How many TLDs to look up at the same time (default: 1) + How many TLDs to look up at the same time (default: 32) -D --delay=DELAY Set the global delay in Seconds between lookup requests -R --retry=NUMBER Retry NUMBER amount of times if domain lookup errors out -V --verbose Verbose output for debugging --A --autosearch=FILE Search for names/domains in text file one domain per new line, - lines starting with invalid character for a domain are ignored - (allows for commenting) +-A --autosearch=FILE Search for names/domains in text file one domain per new line -C --no-color Use a monochrome color scheme -U --no-unicode Do not use unicode only plain ASCII -M --no-mouse Disable the mouse integration for TUI -- cgit v1.2.3-70-g09d2