UMTS at Teleco 1 săptămână în urmă
părinte
comite
4cc3e2bf86
11 a modificat fișierele cu 1473 adăugiri și 46 ștergeri
  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]
 [package]
 name = "hoardom"
 name = "hoardom"
-version = "1.0.4"
+version = "1.1.2"
 edition = "2021"
 edition = "2021"
 description = "Domain hoarding made less painful"
 description = "Domain hoarding made less painful"
+default-run = "hoardom"
+
 
 
 [features]
 [features]
 default = ["builtin-whois"]
 default = ["builtin-whois"]
@@ -17,6 +19,11 @@ system-whois = []
 # Cannot be used together with system-whois
 # Cannot be used together with system-whois
 builtin-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
 # Configurable values baked into the binary at compile time via build.rs
 [package.metadata.hoardom]
 [package.metadata.hoardom]
 whois-command = "whois"
 whois-command = "whois"
@@ -37,3 +44,17 @@ crossterm = "0.28"
 indicatif = "0.17"
 indicatif = "0.17"
 chrono = "0.4"
 chrono = "0.4"
 futures = "0.3"
 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.
 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)
 ![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                                                       |
 | 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                          |
 | `-D, --delay=SECONDS`       | Delay in seconds between lookup requests                          |
 | `-R, --retry=NUMBER`        | Retry count on lookup errors (default: 1)                         |
 | `-R, --retry=NUMBER`        | Retry count on lookup errors (default: 1)                         |
 | `-V, --verbose`             | Verbose output for debugging                                      |
 | `-V, --verbose`             | Verbose output for debugging                                      |
@@ -61,8 +61,8 @@ hoardom coolproject mysite bigidea
 ## Examples
 ## Examples
 
 
 ```bash
 ```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
 # search with the Country TLD list
 hoardom -l Country mysite
 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
 ## keyboard shortcuts
 
 
 ### global
 ### 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
 ### 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
 ### 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
 ### favorites
-| key | what |
-|-----|------|
-| `Up` / `Down` | navigate |
+| key                    | what                        |
+|------------------------|-----------------------------|
+| `Up` / `Down`          | navigate                    |
 | `Backspace` / `Delete` | remove the focused favorite |
 | `Backspace` / `Delete` | remove the focused favorite |
 
 
 ### settings
 ### 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 |
 | `Enter` / `Space` | toggle checkboxes or open the TLD list dropdown |
 
 
 ### scratchpad
 ### scratchpad
@@ -108,6 +108,7 @@ theres 4 things in there:
 - **Show Unavailable** checkbox: toggles whether taken domains show with premium details in results
 - **Show Unavailable** checkbox: toggles whether taken domains show with premium details in results
 - **Show Notes Panel** checkbox: toggles the scratchpad panel on the left
 - **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.
 - **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 
 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
 --tui                            Easy to use Terminal based Graphical user interface
 
 
 Basics :
 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
 -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")
         env!("CARGO_PKG_VERSION")
     );
     );
 }
 }
@@ -157,18 +160,14 @@ Mode :
 --tui                            Easy to use Terminal based Graphical user interface
 --tui                            Easy to use Terminal based Graphical user interface
 
 
 Basics :
 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
 -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 :
 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
 -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
 -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
                                  for when you need a domain in your country or for searching
@@ -182,13 +181,11 @@ Advanced :
 
 
 Various :
 Various :
 -j --jobs=NUMBER                 Number of concurrent lookup requests
 -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
 -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
 -R --retry=NUMBER                Retry NUMBER amount of times if domain lookup errors out
 -V --verbose                     Verbose output for debugging
 -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
 -C --no-color                    Use a monochrome color scheme
 -U --no-unicode                  Do not use unicode only plain ASCII
 -U --no-unicode                  Do not use unicode only plain ASCII
 -M --no-mouse                    Disable the mouse integration for TUI
 -M --no-mouse                    Disable the mouse integration for TUI