UMTS at Teleco 1 viikko sitten
vanhempi
commit
4cc3e2bf86
11 muutettua tiedostoa jossa 1473 lisäystä ja 46 poistoa
  1. 22 1
      Cargo.toml
  2. 220 0
      Makefile
  3. 4 0
      README.md
  4. BIN
      dist/AppIcon.icns
  5. BIN
      dist/AppIcon.ico
  6. 10 0
      dist/hoardom.desktop
  7. 19 0
      dist/mac-launcher.sh
  8. 3 3
      doc/CLI.md
  9. 25 24
      doc/TUI.md
  10. 1155 0
      src/app.rs
  11. 15 18
      src/cli.rs

+ 22 - 1
Cargo.toml

@@ -1,8 +1,10 @@
 [package]
 name = "hoardom"
-version = "1.0.4"
+version = "1.1.2"
 edition = "2021"
 description = "Domain hoarding made less painful"
+default-run = "hoardom"
+
 
 [features]
 default = ["builtin-whois"]
@@ -17,6 +19,11 @@ system-whois = []
 # Cannot be used together with system-whois
 builtin-whois = []
 
+# native gui wrapper binary (hoardom-app)
+# spawns hoardom --tui in a pty and renders it in its own window
+# so it shows up as its own app with its own dock icon
+gui = ["dep:eframe", "dep:portable-pty", "dep:vte"]
+
 # Configurable values baked into the binary at compile time via build.rs
 [package.metadata.hoardom]
 whois-command = "whois"
@@ -37,3 +44,17 @@ crossterm = "0.28"
 indicatif = "0.17"
 chrono = "0.4"
 futures = "0.3"
+
+# gui wrapper deps (only built with --features gui)
+eframe = { version = "0.30", optional = true }
+portable-pty = { version = "0.8", optional = true }
+vte = { version = "0.14", optional = true }
+
+[[bin]]
+name = "hoardom"
+path = "src/main.rs"
+
+[[bin]]
+name = "hoardom-app"
+path = "src/app.rs"
+required-features = ["gui"]

+ 220 - 0
Makefile

@@ -0,0 +1,220 @@
+# hoardom makefile
+# supports: make, make install, make deb, make pkg, make clean
+
+NAME        := hoardom
+VERSION     := $(shell grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
+DESCRIPTION := Domain hoarding made less painful
+MAINTAINER  := crt
+
+PREFIX      ?= /usr/local
+BINDIR      := $(PREFIX)/bin
+DATADIR     := $(PREFIX)/share
+APPDIR      := $(DATADIR)/applications
+ICONDIR     := $(DATADIR)/icons/hicolor/256x256/apps
+ICON_FILE   := dist/AppIcon.ico
+
+CARGO       ?= cargo
+CARGO_FLAGS ?=
+
+BUILDDIR    := target/release
+BINARY      := $(BUILDDIR)/$(NAME)
+GUI_BINARY  := $(BUILDDIR)/$(NAME)-app
+
+# packaging scratch dirs
+PKG_ROOT    := target/pkg-root
+DEB_ROOT    := target/deb-root
+MAC_ROOT    := target/mac-root
+MAC_APP     := target/mac-root/$(NAME).app
+
+# ---- build ----
+
+.PHONY: all build release debug clean install uninstall deb pkg app
+
+all: release
+
+release:
+	$(CARGO) build --release $(CARGO_FLAGS)
+
+debug:
+	$(CARGO) build $(CARGO_FLAGS)
+
+# build the gui wrapper (requires gui feature)
+app: release
+	$(CARGO) build --release --features gui $(CARGO_FLAGS)
+
+# ---- install (linux / mac) ----
+
+install: release
+	@echo "installing $(NAME) to $(DESTDIR)$(BINDIR)"
+	install -d $(DESTDIR)$(BINDIR)
+	install -m 755 $(BINARY) $(DESTDIR)$(BINDIR)/$(NAME)
+	@# install gui wrapper too if it was built
+	@if [ -f $(GUI_BINARY) ]; then \
+		install -m 755 $(GUI_BINARY) $(DESTDIR)$(BINDIR)/$(NAME)-app; \
+	fi
+ifeq ($(shell uname), Darwin)
+	@echo "installing macOS app bundle"
+	install -d $(DESTDIR)/Applications
+	$(MAKE) _mac_app APP_DEST=$(DESTDIR)/Applications
+else
+	@echo "installing desktop file"
+	install -d $(DESTDIR)$(APPDIR)
+	install -m 644 dist/$(NAME).desktop $(DESTDIR)$(APPDIR)/$(NAME).desktop
+	install -d $(DESTDIR)$(ICONDIR)
+	install -m 644 $(ICON_FILE) $(DESTDIR)$(ICONDIR)/$(NAME).ico
+endif
+	@echo "done, $(NAME) is ready to go"
+
+uninstall:
+	rm -f $(DESTDIR)$(BINDIR)/$(NAME)
+	rm -f $(DESTDIR)$(BINDIR)/$(NAME)-app
+	rm -f $(DESTDIR)$(APPDIR)/$(NAME).desktop
+	rm -f $(DESTDIR)$(ICONDIR)/$(NAME).ico
+ifeq ($(shell uname), Darwin)
+	rm -rf $(DESTDIR)/Applications/$(NAME).app
+endif
+	@echo "uninstalled"
+
+# ---- debian .deb package ----
+
+deb: release
+	@echo "building deb package v$(VERSION)"
+	rm -rf $(DEB_ROOT)
+
+	# binary
+	install -d $(DEB_ROOT)/usr/bin
+	install -m 755 $(BINARY) $(DEB_ROOT)/usr/bin/$(NAME)
+
+	# desktop file + icon
+	install -d $(DEB_ROOT)/usr/share/applications
+	install -m 644 dist/$(NAME).desktop $(DEB_ROOT)/usr/share/applications/$(NAME).desktop
+	install -d $(DEB_ROOT)/usr/share/icons/hicolor/256x256/apps
+	install -m 644 $(ICON_FILE) $(DEB_ROOT)/usr/share/icons/hicolor/256x256/apps/$(NAME).ico
+
+	# control file
+	install -d $(DEB_ROOT)/DEBIAN
+	printf 'Package: $(NAME)\n\
+Version: $(VERSION)\n\
+Section: utils\n\
+Priority: optional\n\
+Architecture: $(shell dpkg --print-architecture 2>/dev/null || echo amd64)\n\
+Maintainer: $(MAINTAINER)\n\
+Description: $(DESCRIPTION)\n\
+ TUI and CLI tool for searching domain availability across TLD lists.\n\
+ Includes favorites, scratchpad, export, and custom list support.\n' > $(DEB_ROOT)/DEBIAN/control
+
+	dpkg-deb --build --root-owner-group $(DEB_ROOT) target/$(NAME)_$(VERSION)_$(shell dpkg --print-architecture 2>/dev/null || echo amd64).deb
+	@echo "deb built: target/$(NAME)_$(VERSION)_*.deb"
+
+# ---- macOS .pkg package ----
+
+pkg: app
+	@echo "building macOS pkg v$(VERSION)"
+	rm -rf $(PKG_ROOT) $(MAC_ROOT)
+
+	# cli binary package
+	install -d $(PKG_ROOT)/cli$(PREFIX)/bin
+	install -m 755 $(BINARY) $(PKG_ROOT)/cli$(PREFIX)/bin/$(NAME)
+
+	pkgbuild \
+		--root $(PKG_ROOT)/cli \
+		--identifier ch.teleco.$(NAME).cli \
+		--version $(VERSION) \
+		--install-location / \
+		target/$(NAME)-cli.pkg
+
+	# app bundle package
+	install -d $(PKG_ROOT)/app
+	$(MAKE) _mac_app APP_DEST=$(PKG_ROOT)/app
+
+	pkgbuild \
+		--component $(PKG_ROOT)/app/$(NAME).app \
+		--identifier ch.teleco.$(NAME).app \
+		--version $(VERSION) \
+		--install-location /Applications \
+		target/$(NAME)-app.pkg
+
+	# distribution xml so the installer actually has permission to write to /Applications
+	printf '<?xml version="1.0" encoding="utf-8"?>\n\
+<installer-gui-script minSpecVersion="1">\n\
+    <title>$(NAME) $(VERSION)</title>\n\
+    <options customize="never" require-scripts="false"/>\n\
+    <domains enable_localSystem="true"/>\n\
+    <choices-outline>\n\
+        <line choice="cli"/>\n\
+        <line choice="app"/>\n\
+    </choices-outline>\n\
+    <choice id="cli" title="CLI Tool">\n\
+        <pkg-ref id="ch.teleco.$(NAME).cli"/>\n\
+    </choice>\n\
+    <choice id="app" title="App Bundle">\n\
+        <pkg-ref id="ch.teleco.$(NAME).app"/>\n\
+    </choice>\n\
+    <pkg-ref id="ch.teleco.$(NAME).cli" version="$(VERSION)" installKBytes="$(shell echo $$(( $$(stat -f%z $(BINARY)) / 1024 )))">#$(NAME)-cli.pkg</pkg-ref>\n\
+    <pkg-ref id="ch.teleco.$(NAME).app" version="$(VERSION)" installKBytes="$(shell echo $$(( $$(stat -f%z $(BINARY)) / 1024 )))">#$(NAME)-app.pkg</pkg-ref>\n\
+</installer-gui-script>\n' > $(PKG_ROOT)/distribution.xml
+
+	productbuild \
+		--distribution $(PKG_ROOT)/distribution.xml \
+		--package-path target \
+		target/$(NAME)-$(VERSION).pkg
+	rm -f target/$(NAME)-cli.pkg target/$(NAME)-app.pkg
+	rm -rf $(PKG_ROOT)
+	@echo "pkg built: target/$(NAME)-$(VERSION).pkg"
+
+# ---- internal: macOS .app bundle ----
+
+_mac_app:
+	@test -n "$(APP_DEST)" || (echo "APP_DEST not set" && exit 1)
+	install -d $(APP_DEST)/$(NAME).app/Contents/MacOS
+	install -d $(APP_DEST)/$(NAME).app/Contents/Resources
+
+	# gui wrapper as the executable (or shell launcher as fallback)
+	@if [ -f $(GUI_BINARY) ]; then \
+		echo "using native gui wrapper"; \
+		install -m 755 $(GUI_BINARY) $(APP_DEST)/$(NAME).app/Contents/MacOS/$(NAME)-app; \
+	else \
+		echo "gui wrapper not built, using shell launcher fallback"; \
+		install -m 755 dist/mac-launcher.sh $(APP_DEST)/$(NAME).app/Contents/MacOS/$(NAME)-app; \
+	fi
+
+	# the actual tui binary (gui wrapper spawns this, shell launcher also needs it)
+	install -m 755 $(BINARY) $(APP_DEST)/$(NAME).app/Contents/MacOS/$(NAME)
+
+	# Info.plist
+	printf '<?xml version="1.0" encoding="UTF-8"?>\n\
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n\
+<plist version="1.0">\n\
+<dict>\n\
+    <key>CFBundleExecutable</key>\n\
+    <string>$(NAME)-app</string>\n\
+    <key>CFBundleIdentifier</key>\n\
+    <string>ch.teleco.$(NAME)</string>\n\
+    <key>CFBundleName</key>\n\
+    <string>$(NAME)</string>\n\
+    <key>CFBundleDisplayName</key>\n\
+    <string>hoardom</string>\n\
+    <key>CFBundleVersion</key>\n\
+    <string>$(VERSION)</string>\n\
+    <key>CFBundleShortVersionString</key>\n\
+    <string>$(VERSION)</string>\n\
+    <key>CFBundlePackageType</key>\n\
+    <string>APPL</string>\n\
+    <key>CFBundleIconFile</key>\n\
+    <string>icon.icns</string>\n\
+    <key>LSMinimumSystemVersion</key>\n\
+    <string>10.12</string>\n\
+    <key>NSHighResolutionCapable</key>\n\
+    <true/>\n\
+</dict>\n\
+</plist>\n' > $(APP_DEST)/$(NAME).app/Contents/Info.plist
+
+	# app icon
+	cp dist/AppIcon.icns $(APP_DEST)/$(NAME).app/Contents/Resources/icon.icns
+
+# ---- clean ----
+
+clean:
+	$(CARGO) clean
+	rm -rf $(PKG_ROOT) $(DEB_ROOT) $(MAC_ROOT)
+	rm -f target/$(NAME)_*.deb target/$(NAME)-*.pkg

+ 4 - 0
README.md

@@ -10,6 +10,10 @@ for example when gambling online just isnt doing it for you anymore)
 
 Seriously for once : It is meant to atleast make the journey you'll have loosing your mind finding a banger domain more tolerable than siting on your computer until 5am scrolling through domain registrars crappy ass domain look up webpanel just to know whats not taken yet.
 
+## Lates Update : tool now has a wrapper app for desktop* folks!
+(No windows support only unix.)
+Use the new make file to either `make pkg` for macass or `make deb` for debian systems. If you are running gentoo or anything else just the usual  `make install`, to uninstall do `make uninstall` obv. (both install and uninstall will require le sudo or root)
+
 ![CUCKKOM](https://git.teleco.ch/crt/hoardom.git/plain/doc/pics/image.png?h=main)
 
 

BIN
dist/AppIcon.icns


BIN
dist/AppIcon.ico


+ 10 - 0
dist/hoardom.desktop

@@ -0,0 +1,10 @@
+[Desktop Entry]
+Name=hoardom
+Comment=Domain hoarding made less painful
+Exec=hoardom-app
+Icon=hoardom
+Terminal=false
+Type=Application
+Categories=Utility;Network;
+Keywords=domain;dns;whois;rdap;tld;
+StartupNotify=false

+ 19 - 0
dist/mac-launcher.sh

@@ -0,0 +1,19 @@
+#!/bin/bash
+# hoardom app launcher - opens Terminal with TUI
+SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
+HOARDOM="/usr/local/bin/hoardom"
+
+# try installed binary first, fall back to bundled copy
+if [ ! -x "$HOARDOM" ]; then
+    HOARDOM="$SELF_DIR/hoardom-bin"
+fi
+
+if [ -x "$HOARDOM" ]; then
+    osascript \
+        -e 'tell application "Terminal"' \
+        -e '  activate' \
+        -e "  do script \"'$HOARDOM' --tui\"" \
+        -e 'end tell'
+else
+    osascript -e 'display dialog "hoardom binary not found" buttons {"OK"} default button "OK"'
+fi

+ 3 - 3
doc/CLI.md

@@ -48,7 +48,7 @@ hoardom coolproject mysite bigidea
 
 | Flag                        | Description                                                       |
 |-----------------------------|-------------------------------------------------------------------|
-| `-j, --jobs=NUMBER`         | Number of concurrent lookup requests (default: 1). Controls how many TLDs are looked up at the same time. Higher values speed up searches but may trigger rate limiting from RDAP/WHOIS servers. Max 99. |
+| `-j, --jobs=NUMBER`         | Number of concurrent lookup requests (default: 32). Controls how many TLDs are looked up at the same time. Higher values speed up searches but may trigger rate limiting from RDAP/WHOIS servers. Max 99. |
 | `-D, --delay=SECONDS`       | Delay in seconds between lookup requests                          |
 | `-R, --retry=NUMBER`        | Retry count on lookup errors (default: 1)                         |
 | `-V, --verbose`             | Verbose output for debugging                                      |
@@ -61,8 +61,8 @@ hoardom coolproject mysite bigidea
 ## Examples
 
 ```bash
-# fast search with 8 concurrent requests
-hoardom -j 8 coolproject
+# fast search with 64 concurrent requests
+hoardom -j 64 coolproject
 
 # search with the Country TLD list
 hoardom -l Country mysite

+ 25 - 24
doc/TUI.md

@@ -53,39 +53,39 @@ if `Clear on Search` is off in settings, results accumulate across searches. pre
 ## keyboard shortcuts
 
 ### global
-| key | what |
-|-----|------|
-| `F1` | toggle help overlay |
-| `F2` | open/close export popup |
-| `Ctrl+C` | quit |
-| `s` | cancel running search |
-| `Tab` / `Shift+Tab` | cycle between panels |
-| `Esc` | close help/dropdown, or clear selection in current panel |
+| key                 | what                                                     |
+|---------------------|----------------------------------------------------------|
+| `F1`                | toggle help overlay                                      |
+| `F2`                | open/close export popup                                  |
+| `Ctrl+C`            | quit                                                     |
+| `s`                 | cancel running search                                    |
+| `Tab` / `Shift+Tab` | cycle between panels                                     |
+| `Esc`               | close help/dropdown, or clear selection in current panel |
 
 ### search bar
-| key | what |
-|-----|------|
-| `Enter` | start the search |
-| typing | works normally when no search is running |
-| `Home` / `End` | jump to start/end of input |
+| key            | what                                     |
+|----------------|------------------------------------------|
+| `Enter`        | start the search                         |
+| typing         | works normally when no search is running |
+| `Home` / `End` | jump to start/end of input               |
 
 ### results
-| key | what |
-|-----|------|
-| `Up` / `Down` | navigate the list |
-| `Enter` | add highlighted domain to favorites |
-| mouse scroll | scroll through results |
+| key           | what                                |
+|---------------|-------------------------------------|
+| `Up` / `Down` | navigate the list                   |
+| `Enter`       | add highlighted domain to favorites |
+| mouse scroll  | scroll through results              |
 
 ### favorites
-| key | what |
-|-----|------|
-| `Up` / `Down` | navigate |
+| key                    | what                        |
+|------------------------|-----------------------------|
+| `Up` / `Down`          | navigate                    |
 | `Backspace` / `Delete` | remove the focused favorite |
 
 ### settings
-| key | what |
-|-----|------|
-| `Up` / `Down` | move between settings rows |
+| key               | what                                            |
+|-------------------|-------------------------------------------------|
+| `Up` / `Down`     | move between settings rows                      |
 | `Enter` / `Space` | toggle checkboxes or open the TLD list dropdown |
 
 ### scratchpad
@@ -108,6 +108,7 @@ theres 4 things in there:
 - **Show Unavailable** checkbox: toggles whether taken domains show with premium details in results
 - **Show Notes Panel** checkbox: toggles the scratchpad panel on the left
 - **Clear on Search** checkbox: if on, results get cleared before each new search. if off they pile up for the true hoarding feeling.
+// todo add the job amount selector here too
 
 oh and settings auto save to config 
 

+ 1155 - 0
src/app.rs

@@ -0,0 +1,1155 @@
+// hoardom-app: native e gui emo wrapper for the hoardom tui
+// spawns hoardom --tui in a pty and renders it in its own window
+// so it shows up with its own icon in the dock (mac) or taskbar (linux)
+//
+// built with: cargo build --features gui
+
+use eframe::egui::{self, Color32, FontId, Rect, Sense};
+use portable_pty::{native_pty_system, CommandBuilder, PtySize};
+use vte::{Params, Perform};
+
+use std::io::{Read, Write};
+use std::path::PathBuf;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex};
+use std::thread;
+use std::time::Duration;
+
+// ----- constants -----
+
+const FONT_SIZE: f32 = 14.0;
+const DEFAULT_COLS: u16 = 120;
+const DEFAULT_ROWS: u16 = 35;
+
+const DEFAULT_FG: Color32 = Color32::from_rgb(204, 204, 204);
+const DEFAULT_BG: Color32 = Color32::from_rgb(24, 24, 24);
+
+// ----- terminal colors -----
+
+#[derive(Clone, Copy, PartialEq)]
+enum TermColor {
+    Default,
+    Indexed(u8),
+    Rgb(u8, u8, u8),
+}
+
+fn ansi_color(idx: u8) -> Color32 {
+    match idx {
+        0 => Color32::from_rgb(0, 0, 0),
+        1 => Color32::from_rgb(170, 0, 0),
+        2 => Color32::from_rgb(0, 170, 0),
+        3 => Color32::from_rgb(170, 85, 0),
+        4 => Color32::from_rgb(0, 0, 170),
+        5 => Color32::from_rgb(170, 0, 170),
+        6 => Color32::from_rgb(0, 170, 170),
+        7 => Color32::from_rgb(170, 170, 170),
+        8 => Color32::from_rgb(85, 85, 85),
+        9 => Color32::from_rgb(255, 85, 85),
+        10 => Color32::from_rgb(85, 255, 85),
+        11 => Color32::from_rgb(255, 255, 85),
+        12 => Color32::from_rgb(85, 85, 255),
+        13 => Color32::from_rgb(255, 85, 255),
+        14 => Color32::from_rgb(85, 255, 255),
+        15 => Color32::from_rgb(255, 255, 255),
+        // 6x6x6 color cube
+        16..=231 => {
+            let idx = (idx - 16) as u16;
+            let ri = idx / 36;
+            let gi = (idx % 36) / 6;
+            let bi = idx % 6;
+            let v = |i: u16| -> u8 {
+                if i == 0 { 0 } else { 55 + i as u8 * 40 }
+            };
+            Color32::from_rgb(v(ri), v(gi), v(bi))
+        }
+        // grayscale ramp
+        232..=255 => {
+            let g = 8 + (idx - 232) * 10;
+            Color32::from_rgb(g, g, g)
+        }
+    }
+}
+
+fn resolve_color(c: TermColor, is_fg: bool) -> Color32 {
+    match c {
+        TermColor::Default => {
+            if is_fg { DEFAULT_FG } else { DEFAULT_BG }
+        }
+        TermColor::Indexed(i) => ansi_color(i),
+        TermColor::Rgb(r, g, b) => Color32::from_rgb(r, g, b),
+    }
+}
+
+// ----- terminal cell -----
+
+#[derive(Clone, Copy)]
+struct Cell {
+    ch: char,
+    fg: TermColor,
+    bg: TermColor,
+    bold: bool,
+    reverse: bool,
+}
+
+impl Default for Cell {
+    fn default() -> Self {
+        Cell {
+            ch: ' ',
+            fg: TermColor::Default,
+            bg: TermColor::Default,
+            bold: false,
+            reverse: false,
+        }
+    }
+}
+
+impl Cell {
+    fn resolved_fg(&self) -> Color32 {
+        if self.reverse {
+            resolve_color(self.bg, false)
+        } else {
+            let c = resolve_color(self.fg, true);
+            if self.bold {
+                // brighten bold text a bit
+                let [r, g, b, a] = c.to_array();
+                Color32::from_rgba_premultiplied(
+                    r.saturating_add(40),
+                    g.saturating_add(40),
+                    b.saturating_add(40),
+                    a,
+                )
+            } else {
+                c
+            }
+        }
+    }
+
+    fn resolved_bg(&self) -> Color32 {
+        if self.reverse {
+            resolve_color(self.fg, true)
+        } else {
+            resolve_color(self.bg, false)
+        }
+    }
+}
+
+// ----- terminal grid -----
+
+struct TermGrid {
+    cells: Vec<Vec<Cell>>,
+    rows: usize,
+    cols: usize,
+    cursor_row: usize,
+    cursor_col: usize,
+    cursor_visible: bool,
+    scroll_top: usize,
+    scroll_bottom: usize,
+
+    // current drawing attributes
+    attr_fg: TermColor,
+    attr_bg: TermColor,
+    attr_bold: bool,
+    attr_reverse: bool,
+
+    // saved cursor
+    saved_cursor: Option<(usize, usize)>,
+
+    // alternate screen buffer
+    alt_saved: Option<(Vec<Vec<Cell>>, usize, usize)>,
+
+    // mouse tracking modes
+    mouse_normal: bool,   // ?1000 - normal tracking (clicks)
+    mouse_button: bool,   // ?1002 - button-event tracking (drag)
+    mouse_any: bool,      // ?1003 - any-event tracking (all motion)
+    mouse_sgr: bool,      // ?1006 - SGR extended coordinates
+}
+
+impl TermGrid {
+    fn new(rows: usize, cols: usize) -> Self {
+        TermGrid {
+            cells: vec![vec![Cell::default(); cols]; rows],
+            rows,
+            cols,
+            cursor_row: 0,
+            cursor_col: 0,
+            cursor_visible: true,
+            scroll_top: 0,
+            scroll_bottom: rows,
+            attr_fg: TermColor::Default,
+            attr_bg: TermColor::Default,
+            attr_bold: false,
+            attr_reverse: false,
+            saved_cursor: None,
+            alt_saved: None,
+            mouse_normal: false,
+            mouse_button: false,
+            mouse_any: false,
+            mouse_sgr: false,
+        }
+    }
+
+    fn mouse_enabled(&self) -> bool {
+        self.mouse_normal || self.mouse_button || self.mouse_any
+    }
+
+    fn resize(&mut self, new_rows: usize, new_cols: usize) {
+        if new_rows == self.rows && new_cols == self.cols {
+            return;
+        }
+        for row in &mut self.cells {
+            row.resize(new_cols, Cell::default());
+        }
+        while self.cells.len() < new_rows {
+            self.cells.push(vec![Cell::default(); new_cols]);
+        }
+        self.cells.truncate(new_rows);
+        self.rows = new_rows;
+        self.cols = new_cols;
+        self.scroll_top = 0;
+        self.scroll_bottom = new_rows;
+        self.cursor_row = self.cursor_row.min(new_rows.saturating_sub(1));
+        self.cursor_col = self.cursor_col.min(new_cols.saturating_sub(1));
+    }
+
+    fn reset_attrs(&mut self) {
+        self.attr_fg = TermColor::Default;
+        self.attr_bg = TermColor::Default;
+        self.attr_bold = false;
+        self.attr_reverse = false;
+    }
+
+    fn put_char(&mut self, c: char) {
+        if self.cursor_col >= self.cols {
+            self.cursor_col = 0;
+            self.line_feed();
+        }
+        if self.cursor_row < self.rows && self.cursor_col < self.cols {
+            self.cells[self.cursor_row][self.cursor_col] = Cell {
+                ch: c,
+                fg: self.attr_fg,
+                bg: self.attr_bg,
+                bold: self.attr_bold,
+                reverse: self.attr_reverse,
+            };
+        }
+        self.cursor_col += 1;
+    }
+
+    fn line_feed(&mut self) {
+        if self.cursor_row + 1 >= self.scroll_bottom {
+            self.scroll_up();
+        } else {
+            self.cursor_row += 1;
+        }
+    }
+
+    fn scroll_up(&mut self) {
+        if self.scroll_top < self.scroll_bottom && self.scroll_bottom <= self.rows {
+            self.cells.remove(self.scroll_top);
+            self.cells
+                .insert(self.scroll_bottom - 1, vec![Cell::default(); self.cols]);
+        }
+    }
+
+    fn scroll_down(&mut self) {
+        if self.scroll_top < self.scroll_bottom && self.scroll_bottom <= self.rows {
+            self.cells.remove(self.scroll_bottom - 1);
+            self.cells
+                .insert(self.scroll_top, vec![Cell::default(); self.cols]);
+        }
+    }
+
+    fn erase_display(&mut self, mode: u16) {
+        match mode {
+            0 => {
+                // cursor to end
+                for c in self.cursor_col..self.cols {
+                    self.cells[self.cursor_row][c] = Cell::default();
+                }
+                for r in (self.cursor_row + 1)..self.rows {
+                    for c in 0..self.cols {
+                        self.cells[r][c] = Cell::default();
+                    }
+                }
+            }
+            1 => {
+                // start to cursor
+                for r in 0..self.cursor_row {
+                    for c in 0..self.cols {
+                        self.cells[r][c] = Cell::default();
+                    }
+                }
+                for c in 0..=self.cursor_col.min(self.cols.saturating_sub(1)) {
+                    self.cells[self.cursor_row][c] = Cell::default();
+                }
+            }
+            2 | 3 => {
+                // whole screen
+                for r in 0..self.rows {
+                    for c in 0..self.cols {
+                        self.cells[r][c] = Cell::default();
+                    }
+                }
+            }
+            _ => {}
+        }
+    }
+
+    fn erase_line(&mut self, mode: u16) {
+        let row = self.cursor_row;
+        if row >= self.rows {
+            return;
+        }
+        match mode {
+            0 => {
+                for c in self.cursor_col..self.cols {
+                    self.cells[row][c] = Cell::default();
+                }
+            }
+            1 => {
+                for c in 0..=self.cursor_col.min(self.cols.saturating_sub(1)) {
+                    self.cells[row][c] = Cell::default();
+                }
+            }
+            2 => {
+                for c in 0..self.cols {
+                    self.cells[row][c] = Cell::default();
+                }
+            }
+            _ => {}
+        }
+    }
+
+    fn erase_chars(&mut self, n: usize) {
+        let row = self.cursor_row;
+        if row >= self.rows {
+            return;
+        }
+        for i in 0..n {
+            let c = self.cursor_col + i;
+            if c < self.cols {
+                self.cells[row][c] = Cell::default();
+            }
+        }
+    }
+
+    fn delete_chars(&mut self, n: usize) {
+        let row = self.cursor_row;
+        if row >= self.rows {
+            return;
+        }
+        for _ in 0..n {
+            if self.cursor_col < self.cols {
+                self.cells[row].remove(self.cursor_col);
+                self.cells[row].push(Cell::default());
+            }
+        }
+    }
+
+    fn insert_chars(&mut self, n: usize) {
+        let row = self.cursor_row;
+        if row >= self.rows {
+            return;
+        }
+        for _ in 0..n {
+            if self.cursor_col < self.cols {
+                self.cells[row].insert(self.cursor_col, Cell::default());
+                self.cells[row].truncate(self.cols);
+            }
+        }
+    }
+
+    fn insert_lines(&mut self, n: usize) {
+        for _ in 0..n {
+            if self.cursor_row < self.scroll_bottom {
+                if self.scroll_bottom <= self.rows {
+                    self.cells.remove(self.scroll_bottom - 1);
+                }
+                self.cells
+                    .insert(self.cursor_row, vec![Cell::default(); self.cols]);
+            }
+        }
+    }
+
+    fn delete_lines(&mut self, n: usize) {
+        for _ in 0..n {
+            if self.cursor_row < self.scroll_bottom && self.cursor_row < self.rows {
+                self.cells.remove(self.cursor_row);
+                let insert_at = (self.scroll_bottom - 1).min(self.cells.len());
+                self.cells
+                    .insert(insert_at, vec![Cell::default(); self.cols]);
+            }
+        }
+    }
+
+    fn enter_alt_screen(&mut self) {
+        self.alt_saved = Some((self.cells.clone(), self.cursor_row, self.cursor_col));
+        self.erase_display(2);
+        self.cursor_row = 0;
+        self.cursor_col = 0;
+    }
+
+    fn leave_alt_screen(&mut self) {
+        if let Some((cells, row, col)) = self.alt_saved.take() {
+            self.cells = cells;
+            self.cursor_row = row;
+            self.cursor_col = col;
+        }
+    }
+
+    // SGR - set graphics rendition (colors and attributes)
+    fn sgr(&mut self, params: &[u16]) {
+        if params.is_empty() {
+            self.reset_attrs();
+            return;
+        }
+        let mut i = 0;
+        while i < params.len() {
+            match params[i] {
+                0 => self.reset_attrs(),
+                1 => self.attr_bold = true,
+                7 => self.attr_reverse = true,
+                22 => self.attr_bold = false,
+                27 => self.attr_reverse = false,
+                30..=37 => self.attr_fg = TermColor::Indexed(params[i] as u8 - 30),
+                38 => {
+                    // extended fg color
+                    if i + 2 < params.len() && params[i + 1] == 5 {
+                        self.attr_fg = TermColor::Indexed(params[i + 2] as u8);
+                        i += 2;
+                    } else if i + 4 < params.len() && params[i + 1] == 2 {
+                        self.attr_fg = TermColor::Rgb(
+                            params[i + 2] as u8,
+                            params[i + 3] as u8,
+                            params[i + 4] as u8,
+                        );
+                        i += 4;
+                    }
+                }
+                39 => self.attr_fg = TermColor::Default,
+                40..=47 => self.attr_bg = TermColor::Indexed(params[i] as u8 - 40),
+                48 => {
+                    // extended bg color
+                    if i + 2 < params.len() && params[i + 1] == 5 {
+                        self.attr_bg = TermColor::Indexed(params[i + 2] as u8);
+                        i += 2;
+                    } else if i + 4 < params.len() && params[i + 1] == 2 {
+                        self.attr_bg = TermColor::Rgb(
+                            params[i + 2] as u8,
+                            params[i + 3] as u8,
+                            params[i + 4] as u8,
+                        );
+                        i += 4;
+                    }
+                }
+                49 => self.attr_bg = TermColor::Default,
+                90..=97 => self.attr_fg = TermColor::Indexed(params[i] as u8 - 90 + 8),
+                100..=107 => self.attr_bg = TermColor::Indexed(params[i] as u8 - 100 + 8),
+                _ => {}
+            }
+            i += 1;
+        }
+    }
+
+    fn handle_csi(&mut self, params: &[u16], intermediates: &[u8], action: char) {
+        // helper: get param with default
+        let p = |i: usize, def: u16| -> u16 {
+            params.get(i).copied().filter(|&v| v > 0).unwrap_or(def)
+        };
+
+        let private = intermediates.contains(&b'?');
+
+        match action {
+            'A' => {
+                let n = p(0, 1) as usize;
+                self.cursor_row = self.cursor_row.saturating_sub(n);
+            }
+            'B' => {
+                let n = p(0, 1) as usize;
+                self.cursor_row = (self.cursor_row + n).min(self.rows.saturating_sub(1));
+            }
+            'C' => {
+                let n = p(0, 1) as usize;
+                self.cursor_col = (self.cursor_col + n).min(self.cols.saturating_sub(1));
+            }
+            'D' => {
+                let n = p(0, 1) as usize;
+                self.cursor_col = self.cursor_col.saturating_sub(n);
+            }
+            'H' | 'f' => {
+                // cursor position (1-based)
+                let row = p(0, 1) as usize;
+                let col = p(1, 1) as usize;
+                self.cursor_row = row.saturating_sub(1).min(self.rows.saturating_sub(1));
+                self.cursor_col = col.saturating_sub(1).min(self.cols.saturating_sub(1));
+            }
+            'J' => self.erase_display(p(0, 0)),
+            'K' => self.erase_line(p(0, 0)),
+            'L' => self.insert_lines(p(0, 1) as usize),
+            'M' => self.delete_lines(p(0, 1) as usize),
+            'P' => self.delete_chars(p(0, 1) as usize),
+            'X' => self.erase_chars(p(0, 1) as usize),
+            '@' => self.insert_chars(p(0, 1) as usize),
+            'G' | '`' => {
+                let col = p(0, 1) as usize;
+                self.cursor_col = col.saturating_sub(1).min(self.cols.saturating_sub(1));
+            }
+            'd' => {
+                let row = p(0, 1) as usize;
+                self.cursor_row = row.saturating_sub(1).min(self.rows.saturating_sub(1));
+            }
+            'S' => {
+                for _ in 0..p(0, 1) {
+                    self.scroll_up();
+                }
+            }
+            'T' => {
+                for _ in 0..p(0, 1) {
+                    self.scroll_down();
+                }
+            }
+            'm' => {
+                if params.is_empty() {
+                    self.sgr(&[0]);
+                } else {
+                    self.sgr(params);
+                }
+            }
+            'r' => {
+                let top = p(0, 1) as usize;
+                let bottom = p(1, self.rows as u16) as usize;
+                self.scroll_top = top.saturating_sub(1);
+                self.scroll_bottom = bottom.min(self.rows);
+            }
+            's' => {
+                self.saved_cursor = Some((self.cursor_row, self.cursor_col));
+            }
+            'u' => {
+                if let Some((r, c)) = self.saved_cursor {
+                    self.cursor_row = r.min(self.rows.saturating_sub(1));
+                    self.cursor_col = c.min(self.cols.saturating_sub(1));
+                }
+            }
+            'h' if private => {
+                for &param in params {
+                    match param {
+                        25 => self.cursor_visible = true,
+                        1000 => self.mouse_normal = true,
+                        1002 => self.mouse_button = true,
+                        1003 => self.mouse_any = true,
+                        1006 => self.mouse_sgr = true,
+                        1049 => self.enter_alt_screen(),
+                        _ => {}
+                    }
+                }
+            }
+            'l' if private => {
+                for &param in params {
+                    match param {
+                        25 => self.cursor_visible = false,
+                        1000 => self.mouse_normal = false,
+                        1002 => self.mouse_button = false,
+                        1003 => self.mouse_any = false,
+                        1006 => self.mouse_sgr = false,
+                        1049 => self.leave_alt_screen(),
+                        _ => {}
+                    }
+                }
+            }
+            _ => {}
+        }
+    }
+}
+
+// ----- vte perform implementation -----
+
+impl Perform for TermGrid {
+    fn print(&mut self, c: char) {
+        self.put_char(c);
+    }
+
+    fn execute(&mut self, byte: u8) {
+        match byte {
+            0x08 => {
+                // backspace
+                self.cursor_col = self.cursor_col.saturating_sub(1);
+            }
+            0x09 => {
+                // tab - next tab stop (every 8 cols)
+                self.cursor_col = ((self.cursor_col / 8) + 1) * 8;
+                if self.cursor_col >= self.cols {
+                    self.cursor_col = self.cols.saturating_sub(1);
+                }
+            }
+            0x0A | 0x0B | 0x0C => {
+                // line feed
+                self.line_feed();
+            }
+            0x0D => {
+                // carriage return
+                self.cursor_col = 0;
+            }
+            _ => {}
+        }
+    }
+
+    fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], ignore: bool, action: char) {
+        if ignore {
+            return;
+        }
+        let flat: Vec<u16> = params.iter().map(|sub| sub[0]).collect();
+        self.handle_csi(&flat, intermediates, action);
+    }
+
+    fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
+        if !intermediates.is_empty() {
+            return;
+        }
+        match byte {
+            b'7' => {
+                self.saved_cursor = Some((self.cursor_row, self.cursor_col));
+            }
+            b'8' => {
+                if let Some((r, c)) = self.saved_cursor {
+                    self.cursor_row = r.min(self.rows.saturating_sub(1));
+                    self.cursor_col = c.min(self.cols.saturating_sub(1));
+                }
+            }
+            b'D' => self.line_feed(),
+            b'M' => {
+                // reverse index
+                if self.cursor_row == self.scroll_top {
+                    self.scroll_down();
+                } else {
+                    self.cursor_row = self.cursor_row.saturating_sub(1);
+                }
+            }
+            _ => {}
+        }
+    }
+
+    fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {}
+    fn hook(&mut self, _params: &Params, _intermediates: &[u8], _ignore: bool, _action: char) {}
+    fn put(&mut self, _byte: u8) {}
+    fn unhook(&mut self) {}
+}
+
+// ----- keyboard input mapping -----
+
+// map egui keys to terminal escape sequences
+fn special_key_bytes(key: &egui::Key, modifiers: &egui::Modifiers) -> Option<Vec<u8>> {
+    use egui::Key;
+    match key {
+        Key::ArrowUp => Some(b"\x1b[A".to_vec()),
+        Key::ArrowDown => Some(b"\x1b[B".to_vec()),
+        Key::ArrowRight => Some(b"\x1b[C".to_vec()),
+        Key::ArrowLeft => Some(b"\x1b[D".to_vec()),
+        Key::Home => Some(b"\x1b[H".to_vec()),
+        Key::End => Some(b"\x1b[F".to_vec()),
+        Key::PageUp => Some(b"\x1b[5~".to_vec()),
+        Key::PageDown => Some(b"\x1b[6~".to_vec()),
+        Key::Insert => Some(b"\x1b[2~".to_vec()),
+        Key::Delete => Some(b"\x1b[3~".to_vec()),
+        Key::Escape => Some(b"\x1b".to_vec()),
+        Key::Tab => {
+            if modifiers.shift {
+                Some(b"\x1b[Z".to_vec())
+            } else {
+                Some(b"\x09".to_vec())
+            }
+        }
+        Key::Backspace => Some(b"\x7f".to_vec()),
+        Key::Enter => Some(b"\x0d".to_vec()),
+        Key::F1 => Some(b"\x1bOP".to_vec()),
+        Key::F2 => Some(b"\x1bOQ".to_vec()),
+        Key::F3 => Some(b"\x1bOR".to_vec()),
+        Key::F4 => Some(b"\x1bOS".to_vec()),
+        Key::F5 => Some(b"\x1b[15~".to_vec()),
+        Key::F6 => Some(b"\x1b[17~".to_vec()),
+        Key::F7 => Some(b"\x1b[18~".to_vec()),
+        Key::F8 => Some(b"\x1b[19~".to_vec()),
+        Key::F9 => Some(b"\x1b[20~".to_vec()),
+        Key::F10 => Some(b"\x1b[21~".to_vec()),
+        Key::F11 => Some(b"\x1b[23~".to_vec()),
+        Key::F12 => Some(b"\x1b[24~".to_vec()),
+        _ => None,
+    }
+}
+
+// ctrl+letter -> control character byte
+fn ctrl_key_byte(key: &egui::Key) -> Option<u8> {
+    use egui::Key;
+    match key {
+        Key::A => Some(0x01),
+        Key::B => Some(0x02),
+        Key::C => Some(0x03),
+        Key::D => Some(0x04),
+        Key::E => Some(0x05),
+        Key::F => Some(0x06),
+        Key::G => Some(0x07),
+        Key::H => Some(0x08),
+        Key::I => Some(0x09),
+        Key::J => Some(0x0A),
+        Key::K => Some(0x0B),
+        Key::L => Some(0x0C),
+        Key::M => Some(0x0D),
+        Key::N => Some(0x0E),
+        Key::O => Some(0x0F),
+        Key::P => Some(0x10),
+        Key::Q => Some(0x11),
+        Key::R => Some(0x12),
+        Key::S => Some(0x13),
+        Key::T => Some(0x14),
+        Key::U => Some(0x15),
+        Key::V => Some(0x16),
+        Key::W => Some(0x17),
+        Key::X => Some(0x18),
+        Key::Y => Some(0x19),
+        Key::Z => Some(0x1A),
+        _ => None,
+    }
+}
+
+// ----- the egui app -----
+
+struct HoardomApp {
+    grid: Arc<Mutex<TermGrid>>,
+    pty_writer: Mutex<Box<dyn Write + Send>>,
+    pty_master: Box<dyn portable_pty::MasterPty + Send>,
+    child_exited: Arc<AtomicBool>,
+    cell_width: f32,
+    cell_height: f32,
+    current_cols: u16,
+    current_rows: u16,
+    last_mouse_button: Option<u8>,  // track held mouse button for drag/release
+}
+
+impl eframe::App for HoardomApp {
+    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
+        // bail if the child process is gone
+        if self.child_exited.load(Ordering::Relaxed) {
+            ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+            return;
+        }
+
+        // measure cell dimensions on first frame (cant do it in creation callback)
+        if self.cell_width == 0.0 {
+            let (cw, ch) = ctx.fonts(|f| {
+                let fid = FontId::monospace(FONT_SIZE);
+                let galley = f.layout_no_wrap("M".into(), fid.clone(), DEFAULT_FG);
+                let row_h = f.row_height(&fid);
+                (galley.rect.width(), row_h)
+            });
+            self.cell_width = cw;
+            self.cell_height = ch;
+        }
+
+        // handle keyboard input
+        ctx.input(|input| {
+            for event in &input.events {
+                match event {
+                    egui::Event::Text(text) => {
+                        // only pass printable chars (specials handled via Key events)
+                        let filtered: String = text.chars().filter(|c| !c.is_control()).collect();
+                        if !filtered.is_empty() {
+                            if let Ok(mut w) = self.pty_writer.lock() {
+                                let _ = w.write_all(filtered.as_bytes());
+                            }
+                        }
+                    }
+                    egui::Event::Key {
+                        key,
+                        pressed: true,
+                        modifiers,
+                        ..
+                    } => {
+                        if modifiers.ctrl || modifiers.mac_cmd {
+                            if let Some(byte) = ctrl_key_byte(key) {
+                                if let Ok(mut w) = self.pty_writer.lock() {
+                                    let _ = w.write_all(&[byte]);
+                                }
+                            }
+                        } else if let Some(bytes) = special_key_bytes(key, modifiers) {
+                            if let Ok(mut w) = self.pty_writer.lock() {
+                                let _ = w.write_all(&bytes);
+                            }
+                        }
+                    }
+                    _ => {}
+                }
+            }
+        });
+
+        // handle mouse input
+        self.handle_mouse(ctx);
+
+        // check if window was resized, update pty dimensions
+        let avail = ctx.available_rect();
+        if self.cell_width > 0.0 && self.cell_height > 0.0 {
+            let new_cols = (avail.width() / self.cell_width).floor() as u16;
+            let new_rows = (avail.height() / self.cell_height).floor() as u16;
+            let new_cols = new_cols.max(20);
+            let new_rows = new_rows.max(10);
+
+            if new_cols != self.current_cols || new_rows != self.current_rows {
+                self.current_cols = new_cols;
+                self.current_rows = new_rows;
+                let _ = self.pty_master.resize(PtySize {
+                    rows: new_rows,
+                    cols: new_cols,
+                    pixel_width: 0,
+                    pixel_height: 0,
+                });
+                if let Ok(mut grid) = self.grid.lock() {
+                    grid.resize(new_rows as usize, new_cols as usize);
+                }
+            }
+        }
+
+        // render the terminal grid
+        egui::CentralPanel::default()
+            .frame(egui::Frame::default().fill(DEFAULT_BG))
+            .show(ctx, |ui| {
+                self.render_grid(ui);
+            });
+
+        ctx.request_repaint_after(Duration::from_millis(16));
+    }
+}
+
+impl HoardomApp {
+    // translate egui pointer events to terminal mouse sequences
+    fn handle_mouse(&mut self, ctx: &egui::Context) {
+        let (mouse_enabled, use_sgr) = {
+            match self.grid.lock() {
+                Ok(g) => (g.mouse_enabled(), g.mouse_sgr),
+                Err(_) => return,
+            }
+        };
+        if !mouse_enabled {
+            return;
+        }
+
+        let cw = self.cell_width;
+        let ch = self.cell_height;
+        if cw <= 0.0 || ch <= 0.0 {
+            return;
+        }
+
+        let avail = ctx.available_rect();
+
+        ctx.input(|input| {
+            if let Some(pos) = input.pointer.latest_pos() {
+                let col = ((pos.x - avail.min.x) / cw).floor() as i32;
+                let row = ((pos.y - avail.min.y) / ch).floor() as i32;
+                let col = col.max(0) as u16;
+                let row = row.max(0) as u16;
+
+                // scroll events
+                let scroll_y = input.raw_scroll_delta.y;
+                if scroll_y != 0.0 {
+                    let button: u8 = if scroll_y > 0.0 { 64 } else { 65 };
+                    let seq = if use_sgr {
+                        format!("\x1b[<{};{};{}M", button, col + 1, row + 1)
+                    } else {
+                        let cb = (button + 32) as char;
+                        let cx = (col + 33).min(255) as u8 as char;
+                        let cy = (row + 33).min(255) as u8 as char;
+                        format!("\x1b[M{}{}{}", cb, cx, cy)
+                    };
+                    if let Ok(mut w) = self.pty_writer.lock() {
+                        let _ = w.write_all(seq.as_bytes());
+                    }
+                }
+
+                // button press
+                if input.pointer.any_pressed() {
+                    let button: u8 = if input.pointer.button_pressed(egui::PointerButton::Primary) {
+                        0
+                    } else if input.pointer.button_pressed(egui::PointerButton::Middle) {
+                        1
+                    } else if input.pointer.button_pressed(egui::PointerButton::Secondary) {
+                        2
+                    } else {
+                        0
+                    };
+                    self.last_mouse_button = Some(button);
+                    let seq = if use_sgr {
+                        format!("\x1b[<{};{};{}M", button, col + 1, row + 1)
+                    } else {
+                        let cb = (button + 32) as char;
+                        let cx = (col + 33).min(255) as u8 as char;
+                        let cy = (row + 33).min(255) as u8 as char;
+                        format!("\x1b[M{}{}{}", cb, cx, cy)
+                    };
+                    if let Ok(mut w) = self.pty_writer.lock() {
+                        let _ = w.write_all(seq.as_bytes());
+                    }
+                }
+
+                // button release
+                if input.pointer.any_released() {
+                    let button = self.last_mouse_button.unwrap_or(0);
+                    self.last_mouse_button = None;
+                    let seq = if use_sgr {
+                        format!("\x1b[<{};{};{}m", button, col + 1, row + 1)
+                    } else {
+                        let cb = (3u8 + 32) as char; // release = button 3 in normal mode
+                        let cx = (col + 33).min(255) as u8 as char;
+                        let cy = (row + 33).min(255) as u8 as char;
+                        format!("\x1b[M{}{}{}", cb, cx, cy)
+                    };
+                    if let Ok(mut w) = self.pty_writer.lock() {
+                        let _ = w.write_all(seq.as_bytes());
+                    }
+                }
+
+                // drag / motion
+                if input.pointer.is_moving() && self.last_mouse_button.is_some() {
+                    let button = self.last_mouse_button.unwrap_or(0) + 32; // motion flag
+                    let seq = if use_sgr {
+                        format!("\x1b[<{};{};{}M", button, col + 1, row + 1)
+                    } else {
+                        let cb = (button + 32) as char;
+                        let cx = (col + 33).min(255) as u8 as char;
+                        let cy = (row + 33).min(255) as u8 as char;
+                        format!("\x1b[M{}{}{}", cb, cx, cy)
+                    };
+                    if let Ok(mut w) = self.pty_writer.lock() {
+                        let _ = w.write_all(seq.as_bytes());
+                    }
+                }
+            }
+        });
+    }
+
+    fn render_grid(&self, ui: &mut egui::Ui) {
+        let grid = match self.grid.lock() {
+            Ok(g) => g,
+            Err(_) => return,
+        };
+
+        let painter = ui.painter();
+        let rect = ui.available_rect_before_wrap();
+        let cw = self.cell_width;
+        let ch = self.cell_height;
+
+        // draw each row - render character by character at exact cell positions
+        // to keep backgrounds and text perfectly aligned
+        for row in 0..grid.rows {
+            let y = rect.min.y + row as f32 * ch;
+
+            // draw background spans (batch consecutive same-bg cells)
+            let mut bg_start = 0usize;
+            let mut current_bg = grid.cells[row][0].resolved_bg();
+
+            for col in 0..=grid.cols {
+                let cell_bg = if col < grid.cols {
+                    grid.cells[row][col].resolved_bg()
+                } else {
+                    Color32::TRANSPARENT // sentinel to flush last span
+                };
+
+                if cell_bg != current_bg || col == grid.cols {
+                    // draw the background span
+                    if current_bg != DEFAULT_BG {
+                        let x0 = rect.min.x + bg_start as f32 * cw;
+                        let x1 = rect.min.x + col as f32 * cw;
+                        painter.rect_filled(
+                            Rect::from_min_max(egui::pos2(x0, y), egui::pos2(x1, y + ch)),
+                            0.0,
+                            current_bg,
+                        );
+                    }
+                    bg_start = col;
+                    current_bg = cell_bg;
+                }
+            }
+
+            // draw text - render each cell at its exact x position
+            // this prevents sub-pixel drift that causes bg/text misalignment
+            for col in 0..grid.cols {
+                let cell = &grid.cells[row][col];
+                if cell.ch == ' ' || cell.ch == '\0' {
+                    continue;
+                }
+                let x = rect.min.x + col as f32 * cw;
+                let fg = cell.resolved_fg();
+                let mut buf = [0u8; 4];
+                let s = cell.ch.encode_utf8(&mut buf);
+                painter.text(
+                    egui::pos2(x, y),
+                    egui::Align2::LEFT_TOP,
+                    s,
+                    FontId::monospace(FONT_SIZE),
+                    fg,
+                );
+            }
+        }
+
+        // draw cursor
+        if grid.cursor_visible && grid.cursor_row < grid.rows && grid.cursor_col < grid.cols {
+            let cx = rect.min.x + grid.cursor_col as f32 * cw;
+            let cy = rect.min.y + grid.cursor_row as f32 * ch;
+            painter.rect_filled(
+                Rect::from_min_size(egui::pos2(cx, cy), egui::vec2(cw, ch)),
+                0.0,
+                Color32::from_rgba_premultiplied(180, 180, 180, 100),
+            );
+        }
+
+        // reserve the space so egui knows we used it
+        ui.allocate_exact_size(
+            egui::vec2(grid.cols as f32 * cw, grid.rows as f32 * ch),
+            Sense::hover(),
+        );
+    }
+}
+
+// ----- find the hoardom binary -----
+
+fn find_hoardom() -> PathBuf {
+    // check same directory as ourselves
+    if let Ok(exe) = std::env::current_exe() {
+        if let Some(dir) = exe.parent() {
+            // check for hoardom next to us
+            let candidate = dir.join("hoardom");
+            if candidate.exists() && candidate != exe {
+                return candidate;
+            }
+            // in a mac .app bundle the binary might be named differently
+            let candidate = dir.join("hoardom-bin");
+            if candidate.exists() {
+                return candidate;
+            }
+        }
+    }
+    // fall back to PATH
+    PathBuf::from("hoardom")
+}
+
+// ----- main -----
+
+fn main() -> eframe::Result<()> {
+    let hoardom_bin = find_hoardom();
+
+    // setup pty
+    let pty_system = native_pty_system();
+    let pair = pty_system
+        .openpty(PtySize {
+            rows: DEFAULT_ROWS,
+            cols: DEFAULT_COLS,
+            pixel_width: 0,
+            pixel_height: 0,
+        })
+        .expect("failed to open pty");
+
+    // spawn hoardom --tui in the pty
+    let mut cmd = CommandBuilder::new(&hoardom_bin);
+    cmd.arg("--tui");
+    cmd.env("TERM", "xterm-256color");
+
+    let mut child = pair
+        .slave
+        .spawn_command(cmd)
+        .unwrap_or_else(|e| panic!("failed to spawn {:?}: {}", hoardom_bin, e));
+
+    // close the slave end in the parent so pty gets proper eof
+    drop(pair.slave);
+
+    let reader = pair
+        .master
+        .try_clone_reader()
+        .expect("failed to clone pty reader");
+    let writer = pair
+        .master
+        .take_writer()
+        .expect("failed to take pty writer");
+
+    let grid = Arc::new(Mutex::new(TermGrid::new(
+        DEFAULT_ROWS as usize,
+        DEFAULT_COLS as usize,
+    )));
+    let child_exited = Arc::new(AtomicBool::new(false));
+
+    // egui context holder so the reader thread can request repaints
+    let ctx_holder: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));
+
+    // reader thread: reads pty output and feeds it through the vt parser
+    let grid_clone = grid.clone();
+    let exited_clone = child_exited.clone();
+    let ctx_clone = ctx_holder.clone();
+    thread::spawn(move || {
+        let mut parser = vte::Parser::new();
+        let mut reader = reader;
+        let mut buf = [0u8; 8192];
+        loop {
+            match reader.read(&mut buf) {
+                Ok(0) | Err(_) => {
+                    exited_clone.store(true, Ordering::Relaxed);
+                    if let Ok(lock) = ctx_clone.lock() {
+                        if let Some(ctx) = lock.as_ref() {
+                            ctx.request_repaint();
+                        }
+                    }
+                    break;
+                }
+                Ok(n) => {
+                    if let Ok(mut g) = grid_clone.lock() {
+                        parser.advance(&mut *g, &buf[..n]);
+                    }
+                    if let Ok(lock) = ctx_clone.lock() {
+                        if let Some(ctx) = lock.as_ref() {
+                            ctx.request_repaint();
+                        }
+                    }
+                }
+            }
+        }
+    });
+
+    // child reaper thread
+    let exited_clone2 = child_exited.clone();
+    thread::spawn(move || {
+        let _ = child.wait();
+        exited_clone2.store(true, Ordering::Relaxed);
+    });
+
+    // calculate initial window size from cell dimensions
+    // (rough estimate, refined on first frame)
+    let est_width = DEFAULT_COLS as f32 * 8.5 + 20.0;
+    let est_height = DEFAULT_ROWS as f32 * 18.0 + 20.0;
+
+    let options = eframe::NativeOptions {
+        viewport: egui::ViewportBuilder::default()
+            .with_title("hoardom")
+            .with_inner_size([est_width, est_height])
+            .with_min_inner_size([300.0, 200.0]),
+        ..Default::default()
+    };
+
+    eframe::run_native(
+        "hoardom",
+        options,
+        Box::new(move |cc| {
+            // store the egui context for the reader thread
+            if let Ok(mut holder) = ctx_holder.lock() {
+                *holder = Some(cc.egui_ctx.clone());
+            }
+
+            cc.egui_ctx.set_visuals(egui::Visuals::dark());
+
+            Ok(Box::new(HoardomApp {
+                grid,
+                pty_writer: Mutex::new(writer),
+                pty_master: pair.master,
+                child_exited,
+                cell_width: 0.0,  // measured on first frame
+                cell_height: 0.0,
+                current_cols: DEFAULT_COLS,
+                current_rows: DEFAULT_ROWS,
+                last_mouse_button: None,
+            }))
+        }),
+    )
+}

+ 15 - 18
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