app.rs 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171
  1. // hoardom-app: native e gui emo wrapper for the hoardom tui
  2. // spawns hoardom --tui in a pty and renders it in its own window
  3. // so it shows up with its own icon in the dock (mac) or taskbar (linux)
  4. //
  5. // built with: cargo build --features gui
  6. use eframe::egui::{self, Color32, FontId, Rect, Sense};
  7. use portable_pty::{native_pty_system, CommandBuilder, PtySize};
  8. use vte::{Params, Perform};
  9. use std::io::{Read, Write};
  10. use std::path::PathBuf;
  11. use std::sync::atomic::{AtomicBool, Ordering};
  12. use std::sync::{Arc, Mutex};
  13. use std::thread;
  14. use std::time::Duration;
  15. // ----- constants -----
  16. const FONT_SIZE: f32 = 14.0;
  17. const DEFAULT_COLS: u16 = 120;
  18. const DEFAULT_ROWS: u16 = 35;
  19. const DEFAULT_FG: Color32 = Color32::from_rgb(204, 204, 204);
  20. const DEFAULT_BG: Color32 = Color32::from_rgb(24, 24, 24);
  21. // ----- terminal colors -----
  22. #[derive(Clone, Copy, PartialEq)]
  23. enum TermColor {
  24. Default,
  25. Indexed(u8),
  26. Rgb(u8, u8, u8),
  27. }
  28. fn ansi_color(idx: u8) -> Color32 {
  29. match idx {
  30. 0 => Color32::from_rgb(0, 0, 0),
  31. 1 => Color32::from_rgb(170, 0, 0),
  32. 2 => Color32::from_rgb(0, 170, 0),
  33. 3 => Color32::from_rgb(170, 85, 0),
  34. 4 => Color32::from_rgb(0, 0, 170),
  35. 5 => Color32::from_rgb(170, 0, 170),
  36. 6 => Color32::from_rgb(0, 170, 170),
  37. 7 => Color32::from_rgb(170, 170, 170),
  38. 8 => Color32::from_rgb(85, 85, 85),
  39. 9 => Color32::from_rgb(255, 85, 85),
  40. 10 => Color32::from_rgb(85, 255, 85),
  41. 11 => Color32::from_rgb(255, 255, 85),
  42. 12 => Color32::from_rgb(85, 85, 255),
  43. 13 => Color32::from_rgb(255, 85, 255),
  44. 14 => Color32::from_rgb(85, 255, 255),
  45. 15 => Color32::from_rgb(255, 255, 255),
  46. // 6x6x6 color cube
  47. 16..=231 => {
  48. let idx = (idx - 16) as u16;
  49. let ri = idx / 36;
  50. let gi = (idx % 36) / 6;
  51. let bi = idx % 6;
  52. let v = |i: u16| -> u8 {
  53. if i == 0 { 0 } else { 55 + i as u8 * 40 }
  54. };
  55. Color32::from_rgb(v(ri), v(gi), v(bi))
  56. }
  57. // grayscale ramp
  58. 232..=255 => {
  59. let g = 8 + (idx - 232) * 10;
  60. Color32::from_rgb(g, g, g)
  61. }
  62. }
  63. }
  64. fn resolve_color(c: TermColor, is_fg: bool) -> Color32 {
  65. match c {
  66. TermColor::Default => {
  67. if is_fg { DEFAULT_FG } else { DEFAULT_BG }
  68. }
  69. TermColor::Indexed(i) => ansi_color(i),
  70. TermColor::Rgb(r, g, b) => Color32::from_rgb(r, g, b),
  71. }
  72. }
  73. // ----- terminal cell -----
  74. #[derive(Clone, Copy)]
  75. struct Cell {
  76. ch: char,
  77. fg: TermColor,
  78. bg: TermColor,
  79. bold: bool,
  80. reverse: bool,
  81. }
  82. impl Default for Cell {
  83. fn default() -> Self {
  84. Cell {
  85. ch: ' ',
  86. fg: TermColor::Default,
  87. bg: TermColor::Default,
  88. bold: false,
  89. reverse: false,
  90. }
  91. }
  92. }
  93. impl Cell {
  94. fn resolved_fg(&self) -> Color32 {
  95. if self.reverse {
  96. resolve_color(self.bg, false)
  97. } else {
  98. let c = resolve_color(self.fg, true);
  99. if self.bold {
  100. // brighten bold text a bit
  101. let [r, g, b, a] = c.to_array();
  102. Color32::from_rgba_premultiplied(
  103. r.saturating_add(40),
  104. g.saturating_add(40),
  105. b.saturating_add(40),
  106. a,
  107. )
  108. } else {
  109. c
  110. }
  111. }
  112. }
  113. fn resolved_bg(&self) -> Color32 {
  114. if self.reverse {
  115. resolve_color(self.fg, true)
  116. } else {
  117. resolve_color(self.bg, false)
  118. }
  119. }
  120. }
  121. // ----- terminal grid -----
  122. struct TermGrid {
  123. cells: Vec<Vec<Cell>>,
  124. rows: usize,
  125. cols: usize,
  126. cursor_row: usize,
  127. cursor_col: usize,
  128. cursor_visible: bool,
  129. scroll_top: usize,
  130. scroll_bottom: usize,
  131. // current drawing attributes
  132. attr_fg: TermColor,
  133. attr_bg: TermColor,
  134. attr_bold: bool,
  135. attr_reverse: bool,
  136. // saved cursor
  137. saved_cursor: Option<(usize, usize)>,
  138. // alternate screen buffer
  139. alt_saved: Option<(Vec<Vec<Cell>>, usize, usize)>,
  140. // mouse tracking modes
  141. mouse_normal: bool, // ?1000 - normal tracking (clicks)
  142. mouse_button: bool, // ?1002 - button-event tracking (drag)
  143. mouse_any: bool, // ?1003 - any-event tracking (all motion)
  144. mouse_sgr: bool, // ?1006 - SGR extended coordinates
  145. }
  146. impl TermGrid {
  147. fn new(rows: usize, cols: usize) -> Self {
  148. TermGrid {
  149. cells: vec![vec![Cell::default(); cols]; rows],
  150. rows,
  151. cols,
  152. cursor_row: 0,
  153. cursor_col: 0,
  154. cursor_visible: true,
  155. scroll_top: 0,
  156. scroll_bottom: rows,
  157. attr_fg: TermColor::Default,
  158. attr_bg: TermColor::Default,
  159. attr_bold: false,
  160. attr_reverse: false,
  161. saved_cursor: None,
  162. alt_saved: None,
  163. mouse_normal: false,
  164. mouse_button: false,
  165. mouse_any: false,
  166. mouse_sgr: false,
  167. }
  168. }
  169. fn mouse_enabled(&self) -> bool {
  170. self.mouse_normal || self.mouse_button || self.mouse_any
  171. }
  172. fn resize(&mut self, new_rows: usize, new_cols: usize) {
  173. if new_rows == self.rows && new_cols == self.cols {
  174. return;
  175. }
  176. for row in &mut self.cells {
  177. row.resize(new_cols, Cell::default());
  178. }
  179. while self.cells.len() < new_rows {
  180. self.cells.push(vec![Cell::default(); new_cols]);
  181. }
  182. self.cells.truncate(new_rows);
  183. self.rows = new_rows;
  184. self.cols = new_cols;
  185. self.scroll_top = 0;
  186. self.scroll_bottom = new_rows;
  187. self.cursor_row = self.cursor_row.min(new_rows.saturating_sub(1));
  188. self.cursor_col = self.cursor_col.min(new_cols.saturating_sub(1));
  189. }
  190. fn reset_attrs(&mut self) {
  191. self.attr_fg = TermColor::Default;
  192. self.attr_bg = TermColor::Default;
  193. self.attr_bold = false;
  194. self.attr_reverse = false;
  195. }
  196. fn put_char(&mut self, c: char) {
  197. if self.cursor_col >= self.cols {
  198. self.cursor_col = 0;
  199. self.line_feed();
  200. }
  201. if self.cursor_row < self.rows && self.cursor_col < self.cols {
  202. self.cells[self.cursor_row][self.cursor_col] = Cell {
  203. ch: c,
  204. fg: self.attr_fg,
  205. bg: self.attr_bg,
  206. bold: self.attr_bold,
  207. reverse: self.attr_reverse,
  208. };
  209. }
  210. self.cursor_col += 1;
  211. }
  212. fn line_feed(&mut self) {
  213. if self.cursor_row + 1 >= self.scroll_bottom {
  214. self.scroll_up();
  215. } else {
  216. self.cursor_row += 1;
  217. }
  218. }
  219. fn scroll_up(&mut self) {
  220. if self.scroll_top < self.scroll_bottom && self.scroll_bottom <= self.rows {
  221. self.cells.remove(self.scroll_top);
  222. self.cells
  223. .insert(self.scroll_bottom - 1, vec![Cell::default(); self.cols]);
  224. }
  225. }
  226. fn scroll_down(&mut self) {
  227. if self.scroll_top < self.scroll_bottom && self.scroll_bottom <= self.rows {
  228. self.cells.remove(self.scroll_bottom - 1);
  229. self.cells
  230. .insert(self.scroll_top, vec![Cell::default(); self.cols]);
  231. }
  232. }
  233. fn erase_display(&mut self, mode: u16) {
  234. match mode {
  235. 0 => {
  236. // cursor to end
  237. for c in self.cursor_col..self.cols {
  238. self.cells[self.cursor_row][c] = Cell::default();
  239. }
  240. for r in (self.cursor_row + 1)..self.rows {
  241. for c in 0..self.cols {
  242. self.cells[r][c] = Cell::default();
  243. }
  244. }
  245. }
  246. 1 => {
  247. // start to cursor
  248. for r in 0..self.cursor_row {
  249. for c in 0..self.cols {
  250. self.cells[r][c] = Cell::default();
  251. }
  252. }
  253. for c in 0..=self.cursor_col.min(self.cols.saturating_sub(1)) {
  254. self.cells[self.cursor_row][c] = Cell::default();
  255. }
  256. }
  257. 2 | 3 => {
  258. // whole screen
  259. for r in 0..self.rows {
  260. for c in 0..self.cols {
  261. self.cells[r][c] = Cell::default();
  262. }
  263. }
  264. }
  265. _ => {}
  266. }
  267. }
  268. fn erase_line(&mut self, mode: u16) {
  269. let row = self.cursor_row;
  270. if row >= self.rows {
  271. return;
  272. }
  273. match mode {
  274. 0 => {
  275. for c in self.cursor_col..self.cols {
  276. self.cells[row][c] = Cell::default();
  277. }
  278. }
  279. 1 => {
  280. for c in 0..=self.cursor_col.min(self.cols.saturating_sub(1)) {
  281. self.cells[row][c] = Cell::default();
  282. }
  283. }
  284. 2 => {
  285. for c in 0..self.cols {
  286. self.cells[row][c] = Cell::default();
  287. }
  288. }
  289. _ => {}
  290. }
  291. }
  292. fn erase_chars(&mut self, n: usize) {
  293. let row = self.cursor_row;
  294. if row >= self.rows {
  295. return;
  296. }
  297. for i in 0..n {
  298. let c = self.cursor_col + i;
  299. if c < self.cols {
  300. self.cells[row][c] = Cell::default();
  301. }
  302. }
  303. }
  304. fn delete_chars(&mut self, n: usize) {
  305. let row = self.cursor_row;
  306. if row >= self.rows {
  307. return;
  308. }
  309. for _ in 0..n {
  310. if self.cursor_col < self.cols {
  311. self.cells[row].remove(self.cursor_col);
  312. self.cells[row].push(Cell::default());
  313. }
  314. }
  315. }
  316. fn insert_chars(&mut self, n: usize) {
  317. let row = self.cursor_row;
  318. if row >= self.rows {
  319. return;
  320. }
  321. for _ in 0..n {
  322. if self.cursor_col < self.cols {
  323. self.cells[row].insert(self.cursor_col, Cell::default());
  324. self.cells[row].truncate(self.cols);
  325. }
  326. }
  327. }
  328. fn insert_lines(&mut self, n: usize) {
  329. for _ in 0..n {
  330. if self.cursor_row < self.scroll_bottom {
  331. if self.scroll_bottom <= self.rows {
  332. self.cells.remove(self.scroll_bottom - 1);
  333. }
  334. self.cells
  335. .insert(self.cursor_row, vec![Cell::default(); self.cols]);
  336. }
  337. }
  338. }
  339. fn delete_lines(&mut self, n: usize) {
  340. for _ in 0..n {
  341. if self.cursor_row < self.scroll_bottom && self.cursor_row < self.rows {
  342. self.cells.remove(self.cursor_row);
  343. let insert_at = (self.scroll_bottom - 1).min(self.cells.len());
  344. self.cells
  345. .insert(insert_at, vec![Cell::default(); self.cols]);
  346. }
  347. }
  348. }
  349. fn enter_alt_screen(&mut self) {
  350. self.alt_saved = Some((self.cells.clone(), self.cursor_row, self.cursor_col));
  351. self.erase_display(2);
  352. self.cursor_row = 0;
  353. self.cursor_col = 0;
  354. }
  355. fn leave_alt_screen(&mut self) {
  356. if let Some((cells, row, col)) = self.alt_saved.take() {
  357. self.cells = cells;
  358. self.cursor_row = row;
  359. self.cursor_col = col;
  360. }
  361. }
  362. // SGR - set graphics rendition (colors and attributes)
  363. fn sgr(&mut self, params: &[u16]) {
  364. if params.is_empty() {
  365. self.reset_attrs();
  366. return;
  367. }
  368. let mut i = 0;
  369. while i < params.len() {
  370. match params[i] {
  371. 0 => self.reset_attrs(),
  372. 1 => self.attr_bold = true,
  373. 7 => self.attr_reverse = true,
  374. 22 => self.attr_bold = false,
  375. 27 => self.attr_reverse = false,
  376. 30..=37 => self.attr_fg = TermColor::Indexed(params[i] as u8 - 30),
  377. 38 => {
  378. // extended fg color
  379. if i + 2 < params.len() && params[i + 1] == 5 {
  380. self.attr_fg = TermColor::Indexed(params[i + 2] as u8);
  381. i += 2;
  382. } else if i + 4 < params.len() && params[i + 1] == 2 {
  383. self.attr_fg = TermColor::Rgb(
  384. params[i + 2] as u8,
  385. params[i + 3] as u8,
  386. params[i + 4] as u8,
  387. );
  388. i += 4;
  389. }
  390. }
  391. 39 => self.attr_fg = TermColor::Default,
  392. 40..=47 => self.attr_bg = TermColor::Indexed(params[i] as u8 - 40),
  393. 48 => {
  394. // extended bg color
  395. if i + 2 < params.len() && params[i + 1] == 5 {
  396. self.attr_bg = TermColor::Indexed(params[i + 2] as u8);
  397. i += 2;
  398. } else if i + 4 < params.len() && params[i + 1] == 2 {
  399. self.attr_bg = TermColor::Rgb(
  400. params[i + 2] as u8,
  401. params[i + 3] as u8,
  402. params[i + 4] as u8,
  403. );
  404. i += 4;
  405. }
  406. }
  407. 49 => self.attr_bg = TermColor::Default,
  408. 90..=97 => self.attr_fg = TermColor::Indexed(params[i] as u8 - 90 + 8),
  409. 100..=107 => self.attr_bg = TermColor::Indexed(params[i] as u8 - 100 + 8),
  410. _ => {}
  411. }
  412. i += 1;
  413. }
  414. }
  415. fn handle_csi(&mut self, params: &[u16], intermediates: &[u8], action: char) {
  416. // helper: get param with default
  417. let p = |i: usize, def: u16| -> u16 {
  418. params.get(i).copied().filter(|&v| v > 0).unwrap_or(def)
  419. };
  420. let private = intermediates.contains(&b'?');
  421. match action {
  422. 'A' => {
  423. let n = p(0, 1) as usize;
  424. self.cursor_row = self.cursor_row.saturating_sub(n);
  425. }
  426. 'B' => {
  427. let n = p(0, 1) as usize;
  428. self.cursor_row = (self.cursor_row + n).min(self.rows.saturating_sub(1));
  429. }
  430. 'C' => {
  431. let n = p(0, 1) as usize;
  432. self.cursor_col = (self.cursor_col + n).min(self.cols.saturating_sub(1));
  433. }
  434. 'D' => {
  435. let n = p(0, 1) as usize;
  436. self.cursor_col = self.cursor_col.saturating_sub(n);
  437. }
  438. 'H' | 'f' => {
  439. // cursor position (1-based)
  440. let row = p(0, 1) as usize;
  441. let col = p(1, 1) as usize;
  442. self.cursor_row = row.saturating_sub(1).min(self.rows.saturating_sub(1));
  443. self.cursor_col = col.saturating_sub(1).min(self.cols.saturating_sub(1));
  444. }
  445. 'J' => self.erase_display(p(0, 0)),
  446. 'K' => self.erase_line(p(0, 0)),
  447. 'L' => self.insert_lines(p(0, 1) as usize),
  448. 'M' => self.delete_lines(p(0, 1) as usize),
  449. 'P' => self.delete_chars(p(0, 1) as usize),
  450. 'X' => self.erase_chars(p(0, 1) as usize),
  451. '@' => self.insert_chars(p(0, 1) as usize),
  452. 'G' | '`' => {
  453. let col = p(0, 1) as usize;
  454. self.cursor_col = col.saturating_sub(1).min(self.cols.saturating_sub(1));
  455. }
  456. 'd' => {
  457. let row = p(0, 1) as usize;
  458. self.cursor_row = row.saturating_sub(1).min(self.rows.saturating_sub(1));
  459. }
  460. 'S' => {
  461. for _ in 0..p(0, 1) {
  462. self.scroll_up();
  463. }
  464. }
  465. 'T' => {
  466. for _ in 0..p(0, 1) {
  467. self.scroll_down();
  468. }
  469. }
  470. 'm' => {
  471. if params.is_empty() {
  472. self.sgr(&[0]);
  473. } else {
  474. self.sgr(params);
  475. }
  476. }
  477. 'r' => {
  478. let top = p(0, 1) as usize;
  479. let bottom = p(1, self.rows as u16) as usize;
  480. self.scroll_top = top.saturating_sub(1);
  481. self.scroll_bottom = bottom.min(self.rows);
  482. }
  483. 's' => {
  484. self.saved_cursor = Some((self.cursor_row, self.cursor_col));
  485. }
  486. 'u' => {
  487. if let Some((r, c)) = self.saved_cursor {
  488. self.cursor_row = r.min(self.rows.saturating_sub(1));
  489. self.cursor_col = c.min(self.cols.saturating_sub(1));
  490. }
  491. }
  492. 'h' if private => {
  493. for &param in params {
  494. match param {
  495. 25 => self.cursor_visible = true,
  496. 1000 => self.mouse_normal = true,
  497. 1002 => self.mouse_button = true,
  498. 1003 => self.mouse_any = true,
  499. 1006 => self.mouse_sgr = true,
  500. 1049 => self.enter_alt_screen(),
  501. _ => {}
  502. }
  503. }
  504. }
  505. 'l' if private => {
  506. for &param in params {
  507. match param {
  508. 25 => self.cursor_visible = false,
  509. 1000 => self.mouse_normal = false,
  510. 1002 => self.mouse_button = false,
  511. 1003 => self.mouse_any = false,
  512. 1006 => self.mouse_sgr = false,
  513. 1049 => self.leave_alt_screen(),
  514. _ => {}
  515. }
  516. }
  517. }
  518. _ => {}
  519. }
  520. }
  521. }
  522. // ----- vte perform implementation -----
  523. impl Perform for TermGrid {
  524. fn print(&mut self, c: char) {
  525. self.put_char(c);
  526. }
  527. fn execute(&mut self, byte: u8) {
  528. match byte {
  529. 0x08 => {
  530. // backspace
  531. self.cursor_col = self.cursor_col.saturating_sub(1);
  532. }
  533. 0x09 => {
  534. // tab - next tab stop (every 8 cols)
  535. self.cursor_col = ((self.cursor_col / 8) + 1) * 8;
  536. if self.cursor_col >= self.cols {
  537. self.cursor_col = self.cols.saturating_sub(1);
  538. }
  539. }
  540. 0x0A | 0x0B | 0x0C => {
  541. // line feed
  542. self.line_feed();
  543. }
  544. 0x0D => {
  545. // carriage return
  546. self.cursor_col = 0;
  547. }
  548. _ => {}
  549. }
  550. }
  551. fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], ignore: bool, action: char) {
  552. if ignore {
  553. return;
  554. }
  555. let flat: Vec<u16> = params.iter().map(|sub| sub[0]).collect();
  556. self.handle_csi(&flat, intermediates, action);
  557. }
  558. fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
  559. if !intermediates.is_empty() {
  560. return;
  561. }
  562. match byte {
  563. b'7' => {
  564. self.saved_cursor = Some((self.cursor_row, self.cursor_col));
  565. }
  566. b'8' => {
  567. if let Some((r, c)) = self.saved_cursor {
  568. self.cursor_row = r.min(self.rows.saturating_sub(1));
  569. self.cursor_col = c.min(self.cols.saturating_sub(1));
  570. }
  571. }
  572. b'D' => self.line_feed(),
  573. b'M' => {
  574. // reverse index
  575. if self.cursor_row == self.scroll_top {
  576. self.scroll_down();
  577. } else {
  578. self.cursor_row = self.cursor_row.saturating_sub(1);
  579. }
  580. }
  581. _ => {}
  582. }
  583. }
  584. fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {}
  585. fn hook(&mut self, _params: &Params, _intermediates: &[u8], _ignore: bool, _action: char) {}
  586. fn put(&mut self, _byte: u8) {}
  587. fn unhook(&mut self) {}
  588. }
  589. // ----- keyboard input mapping -----
  590. // map egui keys to terminal escape sequences
  591. fn special_key_bytes(key: &egui::Key, modifiers: &egui::Modifiers) -> Option<Vec<u8>> {
  592. use egui::Key;
  593. match key {
  594. Key::ArrowUp => Some(b"\x1b[A".to_vec()),
  595. Key::ArrowDown => Some(b"\x1b[B".to_vec()),
  596. Key::ArrowRight => Some(b"\x1b[C".to_vec()),
  597. Key::ArrowLeft => Some(b"\x1b[D".to_vec()),
  598. Key::Home => Some(b"\x1b[H".to_vec()),
  599. Key::End => Some(b"\x1b[F".to_vec()),
  600. Key::PageUp => Some(b"\x1b[5~".to_vec()),
  601. Key::PageDown => Some(b"\x1b[6~".to_vec()),
  602. Key::Insert => Some(b"\x1b[2~".to_vec()),
  603. Key::Delete => Some(b"\x1b[3~".to_vec()),
  604. Key::Escape => Some(b"\x1b".to_vec()),
  605. Key::Tab => {
  606. if modifiers.shift {
  607. Some(b"\x1b[Z".to_vec())
  608. } else {
  609. Some(b"\x09".to_vec())
  610. }
  611. }
  612. Key::Backspace => Some(b"\x7f".to_vec()),
  613. Key::Enter => Some(b"\x0d".to_vec()),
  614. Key::F1 => Some(b"\x1bOP".to_vec()),
  615. Key::F2 => Some(b"\x1bOQ".to_vec()),
  616. Key::F3 => Some(b"\x1bOR".to_vec()),
  617. Key::F4 => Some(b"\x1bOS".to_vec()),
  618. Key::F5 => Some(b"\x1b[15~".to_vec()),
  619. Key::F6 => Some(b"\x1b[17~".to_vec()),
  620. Key::F7 => Some(b"\x1b[18~".to_vec()),
  621. Key::F8 => Some(b"\x1b[19~".to_vec()),
  622. Key::F9 => Some(b"\x1b[20~".to_vec()),
  623. Key::F10 => Some(b"\x1b[21~".to_vec()),
  624. Key::F11 => Some(b"\x1b[23~".to_vec()),
  625. Key::F12 => Some(b"\x1b[24~".to_vec()),
  626. _ => None,
  627. }
  628. }
  629. // ctrl+letter -> control character byte
  630. fn ctrl_key_byte(key: &egui::Key) -> Option<u8> {
  631. use egui::Key;
  632. match key {
  633. Key::A => Some(0x01),
  634. Key::B => Some(0x02),
  635. Key::C => Some(0x03),
  636. Key::D => Some(0x04),
  637. Key::E => Some(0x05),
  638. Key::F => Some(0x06),
  639. Key::G => Some(0x07),
  640. Key::H => Some(0x08),
  641. Key::I => Some(0x09),
  642. Key::J => Some(0x0A),
  643. Key::K => Some(0x0B),
  644. Key::L => Some(0x0C),
  645. Key::M => Some(0x0D),
  646. Key::N => Some(0x0E),
  647. Key::O => Some(0x0F),
  648. Key::P => Some(0x10),
  649. Key::Q => Some(0x11),
  650. Key::R => Some(0x12),
  651. Key::S => Some(0x13),
  652. Key::T => Some(0x14),
  653. Key::U => Some(0x15),
  654. Key::V => Some(0x16),
  655. Key::W => Some(0x17),
  656. Key::X => Some(0x18),
  657. Key::Y => Some(0x19),
  658. Key::Z => Some(0x1A),
  659. _ => None,
  660. }
  661. }
  662. // ----- the egui app -----
  663. struct HoardomApp {
  664. grid: Arc<Mutex<TermGrid>>,
  665. pty_writer: Mutex<Box<dyn Write + Send>>,
  666. pty_master: Box<dyn portable_pty::MasterPty + Send>,
  667. child_exited: Arc<AtomicBool>,
  668. cell_width: f32,
  669. cell_height: f32,
  670. current_cols: u16,
  671. current_rows: u16,
  672. last_mouse_button: Option<u8>, // track held mouse button for drag/release
  673. }
  674. impl eframe::App for HoardomApp {
  675. fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
  676. // bail if the child process is gone
  677. if self.child_exited.load(Ordering::Relaxed) {
  678. ctx.send_viewport_cmd(egui::ViewportCommand::Close);
  679. return;
  680. }
  681. // measure cell dimensions on first frame (cant do it in creation callback)
  682. if self.cell_width == 0.0 {
  683. let (cw, ch) = ctx.fonts(|f| {
  684. let fid = FontId::monospace(FONT_SIZE);
  685. let galley = f.layout_no_wrap("M".into(), fid.clone(), DEFAULT_FG);
  686. let row_h = f.row_height(&fid);
  687. (galley.rect.width(), row_h)
  688. });
  689. self.cell_width = cw;
  690. self.cell_height = ch;
  691. }
  692. // handle keyboard input
  693. ctx.input(|input| {
  694. for event in &input.events {
  695. match event {
  696. egui::Event::Text(text) => {
  697. // only pass printable chars (specials handled via Key events)
  698. let filtered: String = text.chars().filter(|c| !c.is_control()).collect();
  699. if !filtered.is_empty() {
  700. if let Ok(mut w) = self.pty_writer.lock() {
  701. let _ = w.write_all(filtered.as_bytes());
  702. }
  703. }
  704. }
  705. egui::Event::Key {
  706. key,
  707. pressed: true,
  708. modifiers,
  709. ..
  710. } => {
  711. if modifiers.ctrl || modifiers.mac_cmd {
  712. if let Some(byte) = ctrl_key_byte(key) {
  713. if let Ok(mut w) = self.pty_writer.lock() {
  714. let _ = w.write_all(&[byte]);
  715. }
  716. }
  717. } else if let Some(bytes) = special_key_bytes(key, modifiers) {
  718. if let Ok(mut w) = self.pty_writer.lock() {
  719. let _ = w.write_all(&bytes);
  720. }
  721. }
  722. }
  723. _ => {}
  724. }
  725. }
  726. });
  727. // handle mouse input
  728. self.handle_mouse(ctx);
  729. // check if window was resized, update pty dimensions
  730. let avail = ctx.available_rect();
  731. if self.cell_width > 0.0 && self.cell_height > 0.0 {
  732. let new_cols = (avail.width() / self.cell_width).floor() as u16;
  733. let new_rows = (avail.height() / self.cell_height).floor() as u16;
  734. let new_cols = new_cols.max(20);
  735. let new_rows = new_rows.max(10);
  736. if new_cols != self.current_cols || new_rows != self.current_rows {
  737. self.current_cols = new_cols;
  738. self.current_rows = new_rows;
  739. let _ = self.pty_master.resize(PtySize {
  740. rows: new_rows,
  741. cols: new_cols,
  742. pixel_width: 0,
  743. pixel_height: 0,
  744. });
  745. if let Ok(mut grid) = self.grid.lock() {
  746. grid.resize(new_rows as usize, new_cols as usize);
  747. }
  748. }
  749. }
  750. // render the terminal grid
  751. egui::CentralPanel::default()
  752. .frame(egui::Frame::default().fill(DEFAULT_BG))
  753. .show(ctx, |ui| {
  754. self.render_grid(ui);
  755. });
  756. ctx.request_repaint_after(Duration::from_millis(16));
  757. }
  758. }
  759. impl HoardomApp {
  760. // translate egui pointer events to terminal mouse sequences
  761. fn handle_mouse(&mut self, ctx: &egui::Context) {
  762. let (mouse_enabled, use_sgr) = {
  763. match self.grid.lock() {
  764. Ok(g) => (g.mouse_enabled(), g.mouse_sgr),
  765. Err(_) => return,
  766. }
  767. };
  768. if !mouse_enabled {
  769. return;
  770. }
  771. let cw = self.cell_width;
  772. let ch = self.cell_height;
  773. if cw <= 0.0 || ch <= 0.0 {
  774. return;
  775. }
  776. let avail = ctx.available_rect();
  777. ctx.input(|input| {
  778. if let Some(pos) = input.pointer.latest_pos() {
  779. let col = ((pos.x - avail.min.x) / cw).floor() as i32;
  780. let row = ((pos.y - avail.min.y) / ch).floor() as i32;
  781. let col = col.max(0) as u16;
  782. let row = row.max(0) as u16;
  783. // scroll events
  784. let scroll_y = input.raw_scroll_delta.y;
  785. if scroll_y != 0.0 {
  786. let button: u8 = if scroll_y > 0.0 { 64 } else { 65 };
  787. let seq = if use_sgr {
  788. format!("\x1b[<{};{};{}M", button, col + 1, row + 1)
  789. } else {
  790. let cb = (button + 32) as char;
  791. let cx = (col + 33).min(255) as u8 as char;
  792. let cy = (row + 33).min(255) as u8 as char;
  793. format!("\x1b[M{}{}{}", cb, cx, cy)
  794. };
  795. if let Ok(mut w) = self.pty_writer.lock() {
  796. let _ = w.write_all(seq.as_bytes());
  797. }
  798. }
  799. // button press
  800. if input.pointer.any_pressed() {
  801. let button: u8 = if input.pointer.button_pressed(egui::PointerButton::Primary) {
  802. 0
  803. } else if input.pointer.button_pressed(egui::PointerButton::Middle) {
  804. 1
  805. } else if input.pointer.button_pressed(egui::PointerButton::Secondary) {
  806. 2
  807. } else {
  808. 0
  809. };
  810. self.last_mouse_button = Some(button);
  811. let seq = if use_sgr {
  812. format!("\x1b[<{};{};{}M", button, col + 1, row + 1)
  813. } else {
  814. let cb = (button + 32) as char;
  815. let cx = (col + 33).min(255) as u8 as char;
  816. let cy = (row + 33).min(255) as u8 as char;
  817. format!("\x1b[M{}{}{}", cb, cx, cy)
  818. };
  819. if let Ok(mut w) = self.pty_writer.lock() {
  820. let _ = w.write_all(seq.as_bytes());
  821. }
  822. }
  823. // button release
  824. if input.pointer.any_released() {
  825. let button = self.last_mouse_button.unwrap_or(0);
  826. self.last_mouse_button = None;
  827. let seq = if use_sgr {
  828. format!("\x1b[<{};{};{}m", button, col + 1, row + 1)
  829. } else {
  830. let cb = (3u8 + 32) as char; // release = button 3 in normal mode
  831. let cx = (col + 33).min(255) as u8 as char;
  832. let cy = (row + 33).min(255) as u8 as char;
  833. format!("\x1b[M{}{}{}", cb, cx, cy)
  834. };
  835. if let Ok(mut w) = self.pty_writer.lock() {
  836. let _ = w.write_all(seq.as_bytes());
  837. }
  838. }
  839. // drag / motion
  840. if input.pointer.is_moving() && self.last_mouse_button.is_some() {
  841. let button = self.last_mouse_button.unwrap_or(0) + 32; // motion flag
  842. let seq = if use_sgr {
  843. format!("\x1b[<{};{};{}M", button, col + 1, row + 1)
  844. } else {
  845. let cb = (button + 32) as char;
  846. let cx = (col + 33).min(255) as u8 as char;
  847. let cy = (row + 33).min(255) as u8 as char;
  848. format!("\x1b[M{}{}{}", cb, cx, cy)
  849. };
  850. if let Ok(mut w) = self.pty_writer.lock() {
  851. let _ = w.write_all(seq.as_bytes());
  852. }
  853. }
  854. }
  855. });
  856. }
  857. fn render_grid(&self, ui: &mut egui::Ui) {
  858. let grid = match self.grid.lock() {
  859. Ok(g) => g,
  860. Err(_) => return,
  861. };
  862. let painter = ui.painter();
  863. let rect = ui.available_rect_before_wrap();
  864. let cw = self.cell_width;
  865. let ch = self.cell_height;
  866. // draw each row - render character by character at exact cell positions
  867. // to keep backgrounds and text perfectly aligned
  868. for row in 0..grid.rows {
  869. let y = rect.min.y + row as f32 * ch;
  870. // draw background spans (batch consecutive same-bg cells)
  871. let mut bg_start = 0usize;
  872. let mut current_bg = grid.cells[row][0].resolved_bg();
  873. for col in 0..=grid.cols {
  874. let cell_bg = if col < grid.cols {
  875. grid.cells[row][col].resolved_bg()
  876. } else {
  877. Color32::TRANSPARENT // sentinel to flush last span
  878. };
  879. if cell_bg != current_bg || col == grid.cols {
  880. // draw the background span
  881. if current_bg != DEFAULT_BG {
  882. let x0 = rect.min.x + bg_start as f32 * cw;
  883. let x1 = rect.min.x + col as f32 * cw;
  884. painter.rect_filled(
  885. Rect::from_min_max(egui::pos2(x0, y), egui::pos2(x1, y + ch)),
  886. 0.0,
  887. current_bg,
  888. );
  889. }
  890. bg_start = col;
  891. current_bg = cell_bg;
  892. }
  893. }
  894. // draw text - render each cell at its exact x position
  895. // this prevents sub-pixel drift that causes bg/text misalignment
  896. for col in 0..grid.cols {
  897. let cell = &grid.cells[row][col];
  898. if cell.ch == ' ' || cell.ch == '\0' {
  899. continue;
  900. }
  901. let x = rect.min.x + col as f32 * cw;
  902. let fg = cell.resolved_fg();
  903. let mut buf = [0u8; 4];
  904. let s = cell.ch.encode_utf8(&mut buf);
  905. painter.text(
  906. egui::pos2(x, y),
  907. egui::Align2::LEFT_TOP,
  908. s,
  909. FontId::monospace(FONT_SIZE),
  910. fg,
  911. );
  912. }
  913. }
  914. // draw cursor
  915. if grid.cursor_visible && grid.cursor_row < grid.rows && grid.cursor_col < grid.cols {
  916. let cx = rect.min.x + grid.cursor_col as f32 * cw;
  917. let cy = rect.min.y + grid.cursor_row as f32 * ch;
  918. painter.rect_filled(
  919. Rect::from_min_size(egui::pos2(cx, cy), egui::vec2(cw, ch)),
  920. 0.0,
  921. Color32::from_rgba_premultiplied(180, 180, 180, 100),
  922. );
  923. }
  924. // reserve the space so egui knows we used it
  925. ui.allocate_exact_size(
  926. egui::vec2(grid.cols as f32 * cw, grid.rows as f32 * ch),
  927. Sense::hover(),
  928. );
  929. }
  930. }
  931. // ----- find the hoardom binary -----
  932. fn find_hoardom() -> PathBuf {
  933. // check same directory as ourselves
  934. if let Ok(exe) = std::env::current_exe() {
  935. if let Some(dir) = exe.parent() {
  936. // check for hoardom next to us
  937. let candidate = dir.join("hoardom");
  938. if candidate.exists() && candidate != exe {
  939. return candidate;
  940. }
  941. // in a mac .app bundle the binary might be named differently
  942. let candidate = dir.join("hoardom-bin");
  943. if candidate.exists() {
  944. return candidate;
  945. }
  946. }
  947. }
  948. // fall back to PATH
  949. PathBuf::from("hoardom")
  950. }
  951. // ----- load app icon -----
  952. fn load_icon() -> egui::IconData {
  953. let png_bytes = include_bytes!("../dist/AppIcon.png");
  954. let img = image::load_from_memory_with_format(png_bytes, image::ImageFormat::Png)
  955. .expect("failed to decode embedded icon")
  956. .into_rgba8();
  957. let (w, h) = img.dimensions();
  958. egui::IconData {
  959. rgba: img.into_raw(),
  960. width: w,
  961. height: h,
  962. }
  963. }
  964. // ----- main -----
  965. fn main() -> eframe::Result<()> {
  966. let hoardom_bin = find_hoardom();
  967. // setup pty
  968. let pty_system = native_pty_system();
  969. let pair = pty_system
  970. .openpty(PtySize {
  971. rows: DEFAULT_ROWS,
  972. cols: DEFAULT_COLS,
  973. pixel_width: 0,
  974. pixel_height: 0,
  975. })
  976. .expect("failed to open pty");
  977. // spawn hoardom --tui in the pty
  978. let mut cmd = CommandBuilder::new(&hoardom_bin);
  979. cmd.arg("--tui");
  980. cmd.env("TERM", "xterm-256color");
  981. let mut child = pair
  982. .slave
  983. .spawn_command(cmd)
  984. .unwrap_or_else(|e| panic!("failed to spawn {:?}: {}", hoardom_bin, e));
  985. // close the slave end in the parent so pty gets proper eof
  986. drop(pair.slave);
  987. let reader = pair
  988. .master
  989. .try_clone_reader()
  990. .expect("failed to clone pty reader");
  991. let writer = pair
  992. .master
  993. .take_writer()
  994. .expect("failed to take pty writer");
  995. let grid = Arc::new(Mutex::new(TermGrid::new(
  996. DEFAULT_ROWS as usize,
  997. DEFAULT_COLS as usize,
  998. )));
  999. let child_exited = Arc::new(AtomicBool::new(false));
  1000. // egui context holder so the reader thread can request repaints
  1001. let ctx_holder: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));
  1002. // reader thread: reads pty output and feeds it through the vt parser
  1003. let grid_clone = grid.clone();
  1004. let exited_clone = child_exited.clone();
  1005. let ctx_clone = ctx_holder.clone();
  1006. thread::spawn(move || {
  1007. let mut parser = vte::Parser::new();
  1008. let mut reader = reader;
  1009. let mut buf = [0u8; 8192];
  1010. loop {
  1011. match reader.read(&mut buf) {
  1012. Ok(0) | Err(_) => {
  1013. exited_clone.store(true, Ordering::Relaxed);
  1014. if let Ok(lock) = ctx_clone.lock() {
  1015. if let Some(ctx) = lock.as_ref() {
  1016. ctx.request_repaint();
  1017. }
  1018. }
  1019. break;
  1020. }
  1021. Ok(n) => {
  1022. if let Ok(mut g) = grid_clone.lock() {
  1023. parser.advance(&mut *g, &buf[..n]);
  1024. }
  1025. if let Ok(lock) = ctx_clone.lock() {
  1026. if let Some(ctx) = lock.as_ref() {
  1027. ctx.request_repaint();
  1028. }
  1029. }
  1030. }
  1031. }
  1032. }
  1033. });
  1034. // child reaper thread
  1035. let exited_clone2 = child_exited.clone();
  1036. thread::spawn(move || {
  1037. let _ = child.wait();
  1038. exited_clone2.store(true, Ordering::Relaxed);
  1039. });
  1040. // calculate initial window size from cell dimensions
  1041. // (rough estimate, refined on first frame)
  1042. let est_width = DEFAULT_COLS as f32 * 8.5 + 20.0;
  1043. let est_height = DEFAULT_ROWS as f32 * 18.0 + 20.0;
  1044. let options = eframe::NativeOptions {
  1045. viewport: egui::ViewportBuilder::default()
  1046. .with_title("hoardom")
  1047. .with_inner_size([est_width, est_height])
  1048. .with_min_inner_size([300.0, 200.0])
  1049. .with_icon(load_icon()),
  1050. ..Default::default()
  1051. };
  1052. eframe::run_native(
  1053. "hoardom",
  1054. options,
  1055. Box::new(move |cc| {
  1056. // store the egui context for the reader thread
  1057. if let Ok(mut holder) = ctx_holder.lock() {
  1058. *holder = Some(cc.egui_ctx.clone());
  1059. }
  1060. cc.egui_ctx.set_visuals(egui::Visuals::dark());
  1061. Ok(Box::new(HoardomApp {
  1062. grid,
  1063. pty_writer: Mutex::new(writer),
  1064. pty_master: pair.master,
  1065. child_exited,
  1066. cell_width: 0.0, // measured on first frame
  1067. cell_height: 0.0,
  1068. current_cols: DEFAULT_COLS,
  1069. current_rows: DEFAULT_ROWS,
  1070. last_mouse_button: None,
  1071. }))
  1072. }),
  1073. )
  1074. }