runemarkv0.10.0

Selection

A grouped, keyboard-driven menu for tools that need a choice rather than a prompt.

The optional select feature adds a grouped menu. It is the one place where runemark reads from the terminal instead of only writing to it — a cursor has to react to keys.

It drives the terminal directly: termios for raw mode, four escape sequences for drawing, and a small decoder for the keys a menu needs. The only dependency is libc, and the interactive path is Unix-only. On other platforms run returns Outcome::Unavailable, the same result a pipeline gets, so a caller writes one code path with no cfg.

The boundary still holds in the direction that matters: a menu carries labels, descriptions and hints, and nothing about what the entries mean. Grouping, ordering and wording stay with the application.

cargo add runemark --features select

Building a menu

use runemark::{Group, Hint, Item, Menu};

let menu = Menu::new()
    .with_heading("casoon.dev")
    .with_note("pnpm")
    .add_group(
        Group::new("Development")
            .add_item(Item::new("dev", "dev").with_description("Start the site"))
            .add_item(Item::new("dev:landings", "dev:landings")),
    )
    .add_group(Group::new("Build").add_item(Item::new("build", "build")))
    .add_hint(Hint::new('U', "Updates"));

Item::id is the application’s own identifier and is what selection returns. The label is what the user sees; descriptions form a second column that lines up across every group.

Ids must be unique within a menu. Ticking is by id, so two entries sharing one tick together, and Menu::run_multi lists the id twice in Picked::Chosen.

Item, Group and Hint are #[non_exhaustive]: build them with new and the builder methods rather than a struct literal, so a field can be added without a breaking release. Outcome and Picked are exhaustive on purpose — a caller matches them to decide what happens next, and a new case should be a compile error rather than a silent fallthrough.

Running it

# use runemark::{ColorMode, Console, Menu, Outcome, SelectMode};
# let menu = Menu::new();
use std::io::IsTerminal;

let outcome = menu.run(
    Console::stderr(ColorMode::Auto),
    SelectMode::Auto,
    std::io::stderr().is_terminal(),
)?;

match outcome {
    Outcome::Selected(id) => println!("chose {id}"),
    Outcome::Hotkey(key) => println!("pressed {key}"),
    Outcome::Cancelled => {}
    Outcome::Unavailable => print!("{}", menu.render(Console::stdout(ColorMode::Auto))),
}
# Ok::<(), std::io::Error>(())

The menu writes to stderr, so the caller’s stdout stays clean for piping.

is_terminal is supplied by the caller rather than detected inside runemark, matching the rest of the crate: the application owns the decision about its own streams.

Keys

Key Effect
↑ ↓ Move the cursor, wrapping at both ends
Home End Jump to the first or last entry
PgUp PgDn Move ten entries, stopping at the ends instead of wrapping
← →, Tab, Shift-Tab Switch groups, with Layout::Tabs
1–9 Jump to that group, with Layout::Tabs
Enter Select, returning Outcome::Selected
/ Start filtering
A hint key Returns Outcome::Hotkey, matched case-insensitively
Esc, q Outcome::Cancelled
Ctrl-C Outcome::Cancelled

Home, End, PgUp and PgDn work in both layouts and inside a filter, on the entries currently shown. Letters such as j and k are deliberately not cursor keys: a hint key wins over the built-ins, and they would collide with the hints a menu binds.

A hint key wins over the built-in q, so a menu is free to bind q itself. / is reserved for the filter and cannot be bound. A tabbed menu has given its digits away: they reach its groups, and a hint bound to one is not seen there — the tab is the one of the two the user can see on screen.

Two things follow from raw mode. Ctrl-Z cannot suspend the menu: raw mode turns off the terminal’s signal keys, so it arrives as a key the menu ignores. And keys typed before the menu opened are discarded, so a stray Enter does not select the first entry.

Picking several

Menu::run_multi answers a different question: not “which one” but “which of these”. Space ticks the entry under the cursor, Enter confirms the lot, and the result is a Picked rather than an Outcome:

# use runemark::{ColorMode, Console, Menu, Picked, SelectMode};
# let menu = Menu::new();
use std::io::IsTerminal;

let picked = menu.run_multi(
    Console::stderr(ColorMode::Auto),
    SelectMode::Auto,
    std::io::stderr().is_terminal(),
)?;

match picked {
    Picked::Chosen(ids) => println!("{} chosen", ids.len()),
    Picked::Cancelled => {}
    Picked::Unavailable => print!("{}", menu.render(Console::stdout(ColorMode::Auto))),
}
# Ok::<(), std::io::Error>(())
casoon.dev                                            pnpm
  [ ] dev              Start the site
› [x] build            Build for production
  [x] test
  [ ] deploy

  space tick   enter confirm   / search

Picked::Chosen carries the ticked Item::ids in display order. Confirming with nothing ticked is an answer, not a cancellation: an empty list says the user looked and chose none, which is a different thing from Picked::Cancelled and usually deserves a different response.

Menu::with_ticked starts named entries ticked, for a list where most entries are the obvious answer and a few are not — the user unticks the exceptions instead of ticking the rule. An id no entry carries is never reported back.

# use runemark::{Group, Item, Menu};
let menu = Menu::new()
    .add_group(
        Group::new("Packages")
            .add_item(Item::new("app", "app"))
            .add_item(Item::new("site", "site")),
    )
    .with_ticked(["app", "site"]);

Ticks are held by id, not by position, so they survive everything that moves entries around: filtering, leaving the filter, and switching tabs. Space ticks inside a search too, which is what makes “filter to test, tick what is left” work.

Hints are neither shown nor answered here. A key that ended the menu would have to throw away what was ticked, and Picked has no outcome to report one in. Everything else is as in Menu::run — the filter, the tabs, and the decision not to run at all.

Groups as tabs

Layout::Tabs puts the groups in a row above the list and shows only the active one’s entries:

# use runemark::{Group, Item, Layout, Menu};
let menu = Menu::new()
    .with_heading("web-casoon")
    .with_note("pnpm")
    .with_summary("27 scripts · 7 groups")
    .with_layout(Layout::Tabs)
    .add_group(Group::new("Development").add_item(Item::new("dev", "dev")))
    .add_group(Group::new("Build").add_item(Item::new("build", "build")));
web-casoon                                            pnpm
  27 scripts · 7 groups

  1 Development   2 Build   3 Preview   4 Deploy   5 Quality   …
    ─────────────
› dev              Start the site
  dev:landings

Which groups become which tabs is the application’s call, as is when to ask for the layout at all. A menu knows how many entries it has, not how much of the screen its caller is willing to spend, and a layout that flipped on its own whenever a window was resized would rearrange the list under a cursor already moving through it.

The rule under the active tab is not decoration. With colour off it is the only thing on the screen saying which group the entries below belong to.

Tabs past the ninth carry no digit — there is none left to offer — and the arrows still reach them. When the row is wider than the terminal it is windowed like the body, growing outwards from the active tab so that switching along the row scrolls it rather than jumping it. Tabs are dropped from the ends rather than shortened: half a group name is no longer the word its digit belongs to.

A single group gets no tab row. There is nothing to switch to, and the row would spend two lines repeating the heading.

Groups that share a tab

Group::in_tab puts a run of groups in one tab instead of one each, and Group::with_divider marks where the row stops being one kind of thing and starts being another:

# use runemark::{Group, Item, Layout, Menu};
# let menu = Menu::new().with_layout(Layout::Tabs);
# let menu = menu.add_group(Group::new("Build").add_item(Item::new("b", "build")));
let menu = menu
    .add_group(
        Group::new("@scope/app")
            .in_tab("Packages")
            .with_divider()
            .add_item(Item::new("app/test", "test")),
    )
    .add_group(
        Group::new("@scope/site")
            .in_tab("Packages")
            .add_item(Item::new("site/test", "test")),
    );
  1 Development   2 Build   3 Quality   4 Deploy   │   5 Packages
                                                       ──────────
@scope/app
› test
@scope/site
  test

Inside the tab each group keeps its label as a heading, so the grouping survives; only the row gets its length back. A group that gave the tab its name carries no heading, since the row above says it already.

This is for a set of groups the row cannot carry. One tab each stops working sooner than it looks: a real workspace here has 52 packages against 6 actions, which is a row of 58 that is almost entirely package names, permanently scrolling, with the digits worthless past the ninth. Collapsed, it is seven tabs.

A divider is drawn only between two tabs that are both on screen. Leading the row with one would separate the tabs from nothing, and where the window starts mid-row the ellipsis already marks the break.

Menu::render ignores the layout and lists every group. Nothing on the other end of a pipe can press a key to reach the second tab, so hiding one there would lose entries rather than save lines.

Filtering

An interactive menu with more than a handful of entries shows / search in its footer, before any hints the application adds. A short one does not — filtering still works, but a yes/no question offering to search itself reads as clutter, and below that every entry is already on screen. Menu::render shows no keys at all, since a pipe has no keyboard.

/ starts a filter; typing narrows the menu, Backspace widens it again. Groups with nothing left disappear, and the cursor sits on the best match.

The filter spans every group in either layout, and a tabbed menu leaves its tab row while it runs. Tabs answer “I know roughly where”; the filter answers “I know exactly what”, which is the case tabs are worst at — a query matching four groups shows all four together. Leaving the filter returns to the tab it was started from.

Esc leaves the filter before it leaves the menu, so a mistyped query costs one key rather than the whole selection. A second Esc cancels. While filtering, every printable key is part of the query — a menu that binds q as a hint still lets you type quality.

Matching is case-insensitive and ranked in tiers:

  1. the query as a substring of the name, earlier position first
  2. the query as a subsequence of the name, tightest span first — dpl finds deploy
  3. the query as a substring of the description

A name the user is typing towards beats a description that happens to contain the same letters. An empty query is not a search result: it restores the menu as it was, rather than ranking everything equal and sorting it alphabetically.

Terminals smaller than the menu

Entries are shortened to fit the width, with … marking the cut, rather than wrapped. A wrapped line would change how many lines the frame occupies, which the redraw counts on, and a description spilling to column zero is what makes a long list unreadable in the first place. Where the label column leaves too little room for a description to say anything, the description is left out instead of cut to a stub.

A menu taller than the terminal is windowed: the body scrolls, and ↑ N more / ↓ N more mark what is out of view. Layout::Tabs is the other answer to the same problem, for a list whose groups mean something: it pages by group instead of scrolling past every one of them. The window moves the least amount that keeps the cursor visible, so short cursor moves do not slide the whole screen.

The size comes from the terminal itself and is re-read every time a frame is drawn. A resize redraws at once, not at the next keypress: while the menu is open it handles SIGWINCH and wakes the wait for a key. A terminal that reports no height gets the whole menu.

A narrowing resize can still leave a stale line. The terminal reflows the frame already on screen before the menu hears about it, and the redraw moves up by the number of lines it drew, not by what the terminal made of them. The next redraw is clean again. If the handler cannot be installed, the menu works without it and notices a resize at the next keypress instead.

Lines that cannot be cut to fit are shortened, not left to wrap: the active tab, the heading note, and a footer key’s label all give way before the line does. A terminal shorter than the heading and footer still gets one row of entries, and with fewer than three rows to spare the ↑ N more / ↓ N more lines are left out so the rows can have the room.

Menu::render is neither windowed nor shortened. A pipe or a file has no height or width to run out of, and cutting there would lose information for no reason.

Without a terminal

SelectMode follows the same shape as ColorMode and ProgressMode:

Mode Behaviour
Auto Interactive only for a terminal
Always Interactive regardless
Never Never interactive

Where the mode and the stream rule out interaction — for an empty menu, on a non-Unix platform, or when the process has no controlling terminal — run returns Outcome::Unavailable immediately. It never blocks on a read that cannot be answered, which is what keeps a menu safe in a pipeline and in CI.

Menu::render needs no feature at all. It is plain formatting with no cursor and no terminal control, and it is what a non-interactive caller shows instead.

Like the rest of the crate, the menu follows the console’s SymbolTheme. With the ASCII theme — which is what a console with colour off gets — Menu::render and the interactive menu draw >, ^ / v, <>, ..., | and - instead of ›, ↑ / ↓, ←→, …, │ and ─, so a pipe or a log file receives plain ASCII.

Terminal restoration

Raw mode is held by a guard that restores the terminal when it goes out of scope, on every path out: an early return, an error, or an unwinding panic.

A signal that kills the process outright — SIGTERM, SIGHUP — leaves the terminal in raw mode, because no destructor runs. runemark handles none of those. An application that must survive that has to install its own. Ctrl-C is not affected: in raw mode it arrives as a key event and is reported as Outcome::Cancelled.

SIGWINCH

The one signal the menu does handle is SIGWINCH, so a resize redraws immediately. The handler only writes a byte to a private pipe; the menu waits on the terminal and that pipe together. It is installed when the menu starts and the previous disposition is put back when it ends, on every path out: an early return, an error, or an unwinding panic.

If the host program installed its own SIGWINCH handler first, it keeps being called for every resize while the menu runs — runemark’s handler calls it after waking the menu — and is restored when the menu ends. While the menu runs, the handler’s sigaction flags and mask are runemark’s (SA_RESTART is set). Changing the SIGWINCH disposition from another thread while a menu is open is not supported: the menu puts back what it found when it started, which overwrites the change. Only one menu holds the handler at a time; a second one running concurrently on another thread does without it and notices a resize at the next keypress.

Why not a terminal crate

The surface a menu needs is small: raw mode, \x1b[?25l / \x1b[?25h for the cursor, \x1b[{n}F and \x1b[J for redrawing in place, and a handful of keys. Pulling in a cross-platform terminal stack for that costs far more than it returns — 28 packages against the two runemark now has with the feature on.

Two details make the hand-written decoder correct rather than merely short:

  • A bare Escape and an arrow key start with the same byte. Only a bounded read separates them. The bound comes from termios VTIME, not poll or select: on macOS a pty answers poll with POLLNVAL instead of readability, so a poll-based timeout blocks forever on a read that never completes. VTIME puts the timing in the kernel, where it is portable.
  • Arrow keys have two encodings. ESC [ A normally, ESC O A in application cursor mode, which tmux and some terminals enable. Handling only the first breaks navigation there.

Edit this page on GitHub · Docs for v0.10.0