crates/gunnar-ui-web/src/hint.rs
//! **One line, and a "?" for the rest** — the rule every page is held to, on
//! the host side of the wasm boundary (owner, 2026-10-04: "long hard-coded
//! texts → one line + ?").
//!
//! A paragraph under every panel is read once and skipped ever after, and it
//! pushes the one line somebody came for below the fold. So a page says ONE
//! line, and whatever else is worth saying waits behind the "?" of
//! `ui::Hint` (`<Hint line=… more=…/>`), or in a tooltip (`title=`, `why=`),
//! which the reader asks for.
//!
//! The rule is a scan rather than a review because the next long sentence
//! will be typed into a page by somebody who has not read this: every string
//! literal in `src/pages/*.rs` longer than [`ONE_LINE`] characters must be the
//! argument of `more=`, `title=` or `why=`. Comments are not scanned — they are
//! for the next reader of the code, not of the page.
/// The longest literal a page may put on screen as is: about one line of a
/// panel's hint at its own width.
pub const ONE_LINE: usize = 110;
/// The attributes whose text the reader has to ASK for.
pub const ASKED_FOR: &[&str] = &["more=", "title=", "why="];
/// Every string literal in `src` that a page would put on screen unasked and
/// that is longer than [`ONE_LINE`], as `(line number, the text)`.
///
/// A small lexer, not a regex: a `//` inside a URL is not a comment, a `"`
/// inside a char literal is not a string, and a `\` at a line's end continues
/// the literal without its leading blanks — exactly as rustc reads them.
pub fn long_unasked(src: &str) -> Vec<(usize, String)> {
let b: Vec<char> = src.chars().collect();
let mut out = Vec::new();
let mut i = 0;
let mut line = 1;
let mut code_before = String::new();
while i < b.len() {
let c = b[i];
if c == '\n' {
line += 1;
}
// Comments.
if c == '/' && b.get(i + 1) == Some(&'/') {
while i < b.len() && b[i] != '\n' {
i += 1;
}
continue;
}
if c == '/' && b.get(i + 1) == Some(&'*') {
i += 2;
while i + 1 < b.len() && !(b[i] == '*' && b[i + 1] == '/') {
if b[i] == '\n' {
line += 1;
}
i += 1;
}
i += 2;
continue;
}
// Char literals ('"', '\'', 'x') and lifetimes ('a).
if c == '\'' {
if b.get(i + 1) == Some(&'\\') && b.get(i + 3) == Some(&'\'') {
i += 4;
continue;
}
if b.get(i + 2) == Some(&'\'') {
i += 3;
continue;
}
i += 1;
continue;
}
// Raw strings: r"…", r#"…"#.
if c == 'r' && (b.get(i + 1) == Some(&'"') || b.get(i + 1) == Some(&'#')) && !b.get(i.wrapping_sub(1)).is_some_and(|p| p.is_alphanumeric() || *p == '_') {
let mut j = i + 1;
let mut hashes = 0;
while b.get(j) == Some(&'#') {
hashes += 1;
j += 1;
}
if b.get(j) == Some(&'"') {
let start_line = line;
let mut text = String::new();
j += 1;
loop {
if j >= b.len() {
break;
}
if b[j] == '"' && (0..hashes).all(|k| b.get(j + 1 + k) == Some(&'#')) {
j += 1 + hashes;
break;
}
if b[j] == '\n' {
line += 1;
}
text.push(b[j]);
j += 1;
}
judge(&mut out, &code_before, start_line, text);
code_before.clear();
i = j;
continue;
}
}
if c == '"' {
let start_line = line;
let mut text = String::new();
let mut j = i + 1;
while j < b.len() && b[j] != '"' {
if b[j] == '\\' {
match b.get(j + 1) {
// A continued line: the newline and the next line's
// leading blanks are not part of the literal.
Some('\n') => {
line += 1;
j += 2;
while b.get(j).is_some_and(|c| c.is_whitespace()) {
if b[j] == '\n' {
line += 1;
}
j += 1;
}
continue;
}
Some(&e) => {
text.push(e);
j += 2;
continue;
}
None => break,
}
}
if b[j] == '\n' {
line += 1;
}
text.push(b[j]);
j += 1;
}
judge(&mut out, &code_before, start_line, text);
code_before.clear();
i = j + 1;
continue;
}
if !c.is_whitespace() {
code_before.push(c);
if code_before.len() > 64 {
code_before.drain(..code_before.len() - 64);
}
}
i += 1;
}
out
}
fn judge(out: &mut Vec<(usize, String)>, code_before: &str, line: usize, text: String) {
if text.chars().count() <= ONE_LINE {
return;
}
if ASKED_FOR.iter().any(|a| code_before.ends_with(a)) {
return;
}
out.push((line, text));
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ **Every page says one line, and the rest only when asked.** A long
/// literal anywhere in a page — a hint, an empty state, a `format!` — is
/// a paragraph on screen; it goes behind `<Hint more=…/>`'s "?" or into a
/// tooltip.
#[test]
fn pages_say_one_line_and_hide_the_rest() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/pages");
let mut found = Vec::new();
let mut files: Vec<_> = std::fs::read_dir(&dir).expect("src/pages").filter_map(|e| e.ok()).map(|e| e.path()).collect();
files.sort();
for p in files.into_iter().filter(|p| p.extension().is_some_and(|x| x == "rs")) {
let src = std::fs::read_to_string(&p).unwrap();
for (line, text) in long_unasked(&src) {
found.push(format!("{}:{line}: {} chars: {:.60}…", p.file_name().unwrap().to_string_lossy(), text.chars().count(), text));
}
}
assert!(found.is_empty(), "long texts on screen, unasked ({}):\n{}", found.len(), found.join("\n"));
}
/// The lexer reads literals as rustc does: comments are not literals, a
/// `"` in a char literal opens nothing, a continued line loses its
/// leading blanks, and an asked-for argument is let through.
#[test]
fn the_scanner_reads_literals_as_rustc_does() {
let long = "x".repeat(ONE_LINE + 1);
let src = format!(
"// \"{long}\"\nlet q = '\"';\nlet a = \"{long}\";\n<Hint line=\"short\" more=\"{long}\"/>\n<span title=\"{long}\"/>\nlet b = \"{half}\\\n {half}\";\nlet c = r#\"{long}\"#;\n",
half = "y".repeat(ONE_LINE / 2 + 1),
);
let found: Vec<usize> = long_unasked(&src).into_iter().map(|(l, _)| l).collect();
assert_eq!(found, [3, 6, 8], "{:?}", long_unasked(&src));
let (_, joined) = &long_unasked(&src)[1];
assert!(!joined.contains(' '), "a continued line keeps no leading blanks: {joined:?}");
}
}