feat(ui): scrollable help menu, Esc-to-close, + blush/matrix/wraith themes

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 <noreply@anthropic.com>
This commit is contained in:
leetcrypt 2026-06-01 22:09:08 -07:00
parent 5e8a409ec2
commit b4c5f9a9fa
5 changed files with 122 additions and 15 deletions

View File

@ -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

View File

@ -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<Line<'static>> {
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 <file>", "offer a file to the room"),
kv("/sendd <dir>", "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);
}

14
hh/themes/blush.toml Normal file
View File

@ -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

14
hh/themes/matrix.toml Normal file
View File

@ -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

14
hh/themes/wraith.toml Normal file
View File

@ -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