From b4c5f9a9faac11378935aed1326d6d131c31dc6e Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Mon, 1 Jun 2026 22:09:08 -0700 Subject: [PATCH] feat(ui): scrollable help menu, Esc-to-close, + blush/matrix/wraith themes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The help overlay now scrolls (↑/↓, PgUp/PgDn, Home/End) with a position indicator and only Esc dismisses it, so stray keystrokes can't close a menu that overflows the screen. Adds three bundled vestments (blush, matrix, wraith); they're auto-discovered via Theme::available(), so they appear in the menu and /theme list with no hardcoded entries. Co-Authored-By: Claude Opus 4.6 --- hh/src/app.rs | 26 +++++++++++++++- hh/src/ui.rs | 69 ++++++++++++++++++++++++++++++++++--------- hh/themes/blush.toml | 14 +++++++++ hh/themes/matrix.toml | 14 +++++++++ hh/themes/wraith.toml | 14 +++++++++ 5 files changed, 122 insertions(+), 15 deletions(-) create mode 100644 hh/themes/blush.toml create mode 100644 hh/themes/matrix.toml create mode 100644 hh/themes/wraith.toml diff --git a/hh/src/app.rs b/hh/src/app.rs index b7a9177..e430bf7 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -128,6 +128,8 @@ pub struct App { pub sbx_scroll: usize, /// Whether the help overlay is showing. pub show_help: bool, + /// Vertical scroll offset (rows) into the help overlay when it doesn't fit. + pub help_scroll: u16, /// A reconnect handshake is in flight (Ctrl-R after a disconnect). pub reconnecting: bool, /// Transient error shown as a popup over the clergy (cleared on next keypress). @@ -159,6 +161,7 @@ impl App { chat_scroll: 0, sbx_scroll: 0, show_help: false, + help_scroll: 0, reconnecting: false, error: None, password: String::new(), @@ -610,9 +613,29 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme }); } } else if app.show_help { - app.show_help = false; // any key dismisses the overlay + // While the help overlay is up, arrows / paging scroll it + // (when it's taller than the screen); only Esc closes, so + // stray keystrokes can't dismiss a menu you're still reading. + let max = term + .size() + .map(|s| ui::help_max_scroll(s.width, s.height, &theme)) + .unwrap_or(0); + match k.code { + KeyCode::Up => app.help_scroll = app.help_scroll.saturating_sub(1), + KeyCode::Down => app.help_scroll = (app.help_scroll + 1).min(max), + KeyCode::PageUp => app.help_scroll = app.help_scroll.saturating_sub(10), + KeyCode::PageDown => app.help_scroll = (app.help_scroll + 10).min(max), + KeyCode::Home => app.help_scroll = 0, + KeyCode::End => app.help_scroll = max, + KeyCode::Esc => { + app.show_help = false; // Esc dismisses the overlay + app.help_scroll = 0; + } + _ => {} // ignore other keys so the menu stays put + } } else if k.code == KeyCode::F(1) { app.show_help = true; // F1 from any mode + app.help_scroll = 0; } else if k.code == KeyCode::F(2) { if app.sandbox.is_none() { } else if app.can_drive() { @@ -876,6 +899,7 @@ fn handle_command( let room = &session.room; if line == "/help" || line == "/?" { app.show_help = true; + app.help_scroll = 0; } else if line == "/pw" || line == "/password" { // Show the room password locally (never broadcast). Handy when the // server's password was autogenerated and you need to read it off / share diff --git a/hh/src/ui.rs b/hh/src/ui.rs index 97a2b84..f560990 100644 --- a/hh/src/ui.rs +++ b/hh/src/ui.rs @@ -45,7 +45,7 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) { draw_input(f, rows[2], app, theme); if app.show_help { - draw_help(f, f.area(), theme); + draw_help(f, f.area(), theme, app.help_scroll); } if let Some(msg) = &app.error { draw_error(f, f.area(), theme, msg); @@ -106,7 +106,30 @@ fn centered(percent_x: u16, percent_y: u16, area: Rect) -> Rect { .split(col)[1] } -fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) { +// Popup geometry — shared by the renderer and the scroll-clamp helper so the two +// always agree on how many rows are visible. +const HELP_PCT_X: u16 = 78; +const HELP_PCT_Y: u16 = 90; +fn help_popup(area: Rect) -> Rect { + centered(HELP_PCT_X, HELP_PCT_Y, area) +} + +/// Largest vertical scroll offset for the help overlay given the full terminal +/// area: total wrapped content rows minus the visible viewport (0 if it all fits). +pub fn help_max_scroll(width: u16, height: u16, theme: &Theme) -> u16 { + let w = help_popup(Rect::new(0, 0, width, height)); + let inner_w = w.width.saturating_sub(2); + let inner_h = w.height.saturating_sub(2); + if inner_w == 0 { + return 0; + } + let total = Paragraph::new(help_lines(theme)) + .wrap(Wrap { trim: false }) + .line_count(inner_w) as u16; + total.saturating_sub(inner_h) +} + +fn help_lines(theme: &Theme) -> Vec> { let acc = Style::default() .fg(theme.accent) .add_modifier(Modifier::BOLD); @@ -118,9 +141,15 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) { Span::styled(v.to_string(), dim), ]) }; - let sig = &theme.sigil; + let sig = theme.sigil.clone(); let head = |s: &str| Line::from(Span::styled(format!("{sig} {s}"), acc)); - let lines = vec![ + // List whatever vestments are actually installed, so new themes show up here + // automatically (church · neon · crypt · blush · matrix · wraith · …). + let theme_help = format!( + "change vestments live: {}", + Theme::available().join(" · ") + ); + vec![ head("COMMANDS (type in the input bar)"), kv( "/sbx launch [backend]", @@ -158,10 +187,7 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) { kv("/send ", "offer a file to the room"), kv("/sendd ", "offer a directory (sent as a tar)"), kv("/accept · /reject", "respond to an incoming file offer"), - kv( - "/theme [name]", - "change vestments live: church | neon | crypt", - ), + kv("/theme [name]", &theme_help), kv("/pw", "show this room's password (local only)"), kv("/help", "show / hide this menu"), Line::from(""), @@ -192,27 +218,42 @@ fn draw_help(f: &mut Frame, area: Rect, theme: &Theme) { ), Line::from(""), Line::from(Span::styled( - " malware bless · press any key to close", + " malware bless · ↑/↓ · PgUp/PgDn scroll · Esc closes", Style::default() .fg(theme.dim) .add_modifier(Modifier::ITALIC), )), - ]; - let w = centered(78, 90, area); + ] +} + +fn draw_help(f: &mut Frame, area: Rect, theme: &Theme, scroll: u16) { + let w = help_popup(area); + let inner_w = w.width.saturating_sub(2); + let inner_h = w.height.saturating_sub(2); + let para = Paragraph::new(help_lines(theme)).wrap(Wrap { trim: false }); + // Clamp so you can never scroll past the last line; show a hint when there's + // more below the fold. + let max = (para.line_count(inner_w) as u16).saturating_sub(inner_h); + let off = scroll.min(max); + let title = if max == 0 { + format!(" {0} hack-house — help {0} ", theme.sigil) + } else { + format!(" {0} hack-house — help {0} ({off}/{max} ▼) ", theme.sigil) + }; f.render_widget(Clear, w); - let help = Paragraph::new(lines) + let help = para .style(Style::default().bg(theme.bg)) // fill the popup with the theme surface .block( Block::bordered() .border_style(Style::default().fg(theme.accent)) .title(Span::styled( - format!(" {0} hack-house — help {0} ", theme.sigil), + title, Style::default() .fg(theme.title) .add_modifier(Modifier::BOLD), )), ) - .wrap(Wrap { trim: false }); + .scroll((off, 0)); f.render_widget(help, w); } diff --git a/hh/themes/blush.toml b/hh/themes/blush.toml new file mode 100644 index 0000000..fbe1894 --- /dev/null +++ b/hh/themes/blush.toml @@ -0,0 +1,14 @@ +# blush — soft femme: rose & lavender ink on a warm plum dusk +name = "blush" +bg = "#241622" # warm deep-plum surface (not pure black) +border = "#a76a8f" # dusty mauve chrome +title = "#ffd9ec" # pale blossom +accent = "#ff7fcf" # bubblegum pink — ❀ glyph, prompt +dim = "#9a7088" # muted rose-grey timestamps +me = "#ff9ed8" # your messages — soft pink +other = "#c89bff" # others' messages — lavender +system = "#ffb3d9" # system lines — petal pink +input = "#ff9ed8" +roster_me = "#ff5fb0" # you — vivid rose +roster_width = 22 +sigil = "❀" # blossom diff --git a/hh/themes/matrix.toml b/hh/themes/matrix.toml new file mode 100644 index 0000000..0fa9abe --- /dev/null +++ b/hh/themes/matrix.toml @@ -0,0 +1,14 @@ +# matrix — digital rain: phosphor green cascading on the void +name = "matrix" +bg = "#000600" # near-black with a green undertone +border = "#0a5a1e" # dim terminal-green chrome +title = "#9dffb0" # bright phosphor +accent = "#00ff41" # classic matrix green — glyph, prompt +dim = "#1f7a33" # faded rain trail +me = "#00ff41" # your messages — bright green +other = "#39d353" # others' messages — softer green +system = "#7dffa0" # system lines — pale phosphor +input = "#00ff41" +roster_me = "#aeff00" # you — electric lime +roster_width = 22 +sigil = "ⵣ" # falling glyph diff --git a/hh/themes/wraith.toml b/hh/themes/wraith.toml new file mode 100644 index 0000000..df7eea5 --- /dev/null +++ b/hh/themes/wraith.toml @@ -0,0 +1,14 @@ +# wraith — goth cyberpunk: blood & violet bleeding through obsidian +name = "wraith" +bg = "#0b0710" # obsidian with a violet bruise +border = "#5a2a4a" # dark wine chrome +title = "#e6d2ff" # pale violet bone +accent = "#b026ff" # electric amethyst — ⸸ glyph, prompt +dim = "#6a4a66" # ashen mauve timestamps +me = "#ff2b5e" # your messages — arterial red +other = "#9d4edd" # others' messages — necro violet +system = "#ff4f9a" # system lines — toxic magenta +input = "#ff2b5e" +roster_me = "#ff1744" # you — blood crimson +roster_width = 22 +sigil = "⸸" # inverted-cross dagger