intuiTUIve: what the demos are missing¶
Status: working list, updated after each round.
We rebuild well-known ratatui apps on intuiTUIve to learn what the framework lacks. Each clone is built only from intuiTUIve's own components, from its core or its plugins: no framework changes during the round, and no custom widget in the example to stand in for a missing one. What the clone needs and cannot get goes on this list. Gaps chosen from it are added to the framework, the clones are rebuilt on them, and we repeat until each clone uses intuiTUIve components only. Then we pick more apps.
Each round also looks beyond what the clone strictly needs. Whoever builds a clone reports, under Components that would help, the components that would make it shorter or more faithful: the ones the original app leans on, and the ones comparable libraries (ratatui and its crates, Bubble Tea and Bubbles, Textual) offer. Each entry gives what it would replace, an API sketch, core or plugin, and how much it would help.
A whole-framework comparison with Textual, widget by widget, with its gaps filed as issues, is in the Textual parity audit.
Where a gap belongs:
- core: rs-rich-intuituive itself (layout, widgets, keys, popups);
- plugin: a crate or feature beside it (rs-rich-ext's charts, rs-rich-ratatui, or a new one).
Core components are generic. A core component must be reusable by
other components: no domain types, names or parameters (no paths, files,
samples, sound cards, or one app's or one data source's shapes). Anything
only one domain could use is a plugin: an off-by-default feature module
(files, pcm/audio, watch, art) or another crate. Where a domain
component has a generic shape underneath, the generic part goes in core
and the plugin builds on it: files::miller is the core's miller_of
over a directory tree, files::tasks the core's jobs running file
operations, files::finder the core's finder_of over a walk,
files::preview the core's preview_of keyed by a path, and
files::path_crumb the core's breadcrumb over a path's parts.
This is a gate, not a preference. A proposal for core that is not generic is not accepted into core: it is reshaped as a generic core piece plus a plugin built on it, or kept as a plugin. So every gap report and every Components that would help entry that proposes a domain component must also propose the generic core shape under it (or say why there is none, and mark it plugin). The tables below predate the rule where they name a domain shape as core; such an entry is read as its generic shape.
Round 1: Yazi and scope-tui¶
The clones are examples/files.rs
(Yazi, the file manager) and
examples/scope.rs
(scope-tui 0.3.5, the oscilloscope). How each compares with its original
is in the porting guide.
Keys and input¶
| Gap | Needed for | API sketch | Where |
|---|---|---|---|
| Key sequences, with a which-key pop-up | Yazi's gg, ,s, cc chords; today each prefix needs a popup of its own, a key that doesn't match is ignored instead of closing it, and the palette cannot list chords |
node.bind("g g", desc, f); which-key turned on by the app |
core |
| Catch any key | Closing which-key on any other key | node.on_any_key(\|cx, key\| -> bool) |
core |
| Keys a plain container sees before its children | Yazi's root keymap beats a focused list's own j/k/g; the example makes the root focusable instead | column(..).previews_keys() |
core |
| Placing the caret, selecting part of an input | Rename with the caret before the extension | input(sig).caret(Caret::End \| Caret::At(n)), .select(range) |
core |
Lists and tables¶
| Gap | Needed for | API sketch | Where |
|---|---|---|---|
Row controls on list |
Yazi's right-aligned line mode, a one-cell marker column, a selected style per row; two synced lists stand in today | list(..).trailing(\|i\| markup), .marker(\|i\| Option<style>), .selected_style_for(\|i\| style) |
core |
| A table without a header | Name and line-mode columns in one widget | TableOptions::header(false) |
core |
virtual_list options |
Huge directories with scrolloff and row colours; list rebuilds every row on each draw |
scroll_padding, row_style, offset on virtual_list |
core |
| A table row with ratatui's cell rules | scope-tui's header: cells cut rather than wrapped, column spacing, percentage widths | a row cell option, or a one-row table |
core |
Layout, popups and chrome¶
| Gap | Needed for | API sketch | Where |
|---|---|---|---|
| Widths solved as ratatui solves them | scope-tui's header: ratatui takes percentages of the whole width, intuiTUIve of the width after gaps, so cells sit 1–3 columns off and get cut at 80–100 columns | row(..).gap(1).percent_of(Basis::Whole), or a Solver::Ratatui mode |
core |
| The screen rect of a node or a list row | Yazi's rename prompt below the hovered row; the example re-derives hsplit's maths |
Node::rect_handle(), list(..).row_rect(i) |
core |
| A popup anchored to the screen | Yazi's input (top centre) and which-key (bottom, with margins, clamped) | cx.popup_at(Origin::TopCenter, Offset { x, y, w, h }, ..) |
core |
| A divider joined to the panel border | The help box's ├──┤ line |
column(..).divider(), or rule() with border merging |
core |
| Toast placement and levels | Yazi's notifications: top right, info/warn/error titles | cx.notify(Level, title, body) and a position setting |
core |
| A confirm dialog in the framework's look | Trash, delete and overwrite; rs-rich-interact has one as a component, but not as an intuiTUIve modal | cx.confirm(title, body, items, on_yes) |
core |
| Palette and theme details | The palette shows no key for esc bindings; Theme has no title style for popups |
core |
Charts¶
| Gap | Needed for | API sketch | Where |
|---|---|---|---|
| Drop or clip lines that leave the bounds | Ratatui drops a line with an end outside the bounds, rs-rich-ext clips it; the example turns such points into NaN | Chart::out_of_bounds(OutOfBounds::Drop \| Clip) |
plugin (rs-rich-ext) |
| A dataset drawn over the ones before it | scope-tui's reference lines break where the trace crosses them; rs-rich-ext merges the dots | Chart::layering(Layering::PerDataset \| Merge) |
plugin (rs-rich-ext) |
| A legend style of its own | The legend box borrows the x axis's style | Chart::legend_style("…") |
plugin (rs-rich-ext) |
| Leave the plot visible under the legend | Ratatui leaves the dots inside the legend box | Chart::legend_clear(false) |
plugin (rs-rich-ext) |
Data, sources and the outside world¶
| Gap | Needed for | API sketch | Where |
|---|---|---|---|
| The newest value from a thread | scope-tui's reader: the example hand-rolls a thread, a mutex slot and every() polling |
latest(\|tx\| loop { tx.send(read()) }) -> Signal<Option<T>> |
core |
| Handing the terminal to another program | Yazi's Enter/o opens a file; Flow::Handoff cannot do this inside an app |
cx.handoff(cmd) |
core |
| Ratatui colour names | scope-tui's --palette-color DarkGray; about 30 hand-written lines |
style_from_color_name("DarkGray") -> Option<String> |
plugin (rs-rich-ratatui) |
| FFT and spectrum | scope-tui's spectroscope; the example carries an FFT and a Hann window | dsp::fft, dsp::hann, dsp::spectrum(samples, rate) |
plugin (new) |
| Audio capture | scope-tui's audio and pulse sources, missing entirely |
audio::input(device, rate, channels, buffer) -> Signal<Matrix>, audio::devices() |
plugin (new) |
| Watching a directory | Live updates in Yazi without polling | watch_path(path, \|event, cx\| ..) on notify |
plugin |
| Image previews | Yazi's image preview | an image(path) node over rs-rich-art (half blocks, kitty, sixel) |
plugin (rs-rich-art) |
| File-type icons and MIME types | Yazi's icons and type colours | icons::for_path(&Path) -> (glyph, colour), a Nerd Font check |
plugin |
| Local time and user names | Yazi's mtime/btime and owner line modes (UTC and uid:gid today) | plugin, or a rs-rich-ext utility | |
| Warming up syntax highlighting | A first Markdown preview takes over 2 s in a debug build | Syntax::preload(langs) on a worker |
plugin (rs-rich-ext) |
Bugs found¶
| Bug | Repro | Workaround in the example |
|---|---|---|
| A prefilled input puts the caret at the start | input(signal("alpha".into())), type x: xalpha (TextArea::new and Buffer::set_text place it at 0) |
Press End first |
A list sized Size::Auto measures as the full width |
row([list(a).flex(1), list(b).auto()]): the second list takes everything |
A switch on the measured width with .fixed(w) |
.borders(Sides::BOTTOM) draws nothing on a node without a panel |
A one-row leaf that draws a line |
|
| A popup's bindings work only on its outermost node | Bindings on a node inside the popup's .panel() never fire |
Bind after .panel() |
Round 2: scope-tui¶
The round-1 gaps were added (PR #752) and the clones rebuilt on them, with no framework changes during the round.
scope¶
examples/scope.rs
now uses no workaround from round 1. Checked side by side with scope-tui
0.3.5 (built with app,file) on the same PCM file at 80x24, 100x32 and
160x45: the header's cells start in the same columns and are cut the same
way at every size, the vectorscope's reference lines break where the trace
crosses them as ratatui's do, and the legend box matches. What still
differs is deliberate and listed in the porting guide.
Round-1 gaps now closed¶
| Gap | Used where |
|---|---|
Solver::Ratatui |
header(): row([...]).solver(Solver::Ratatui).gap(1); the cells sit where ratatui's Table puts them at every width (a test checks against ratatui's Layout at 60 to 200 columns), where round 1 was 1–3 columns off |
widgets::cell |
header(): every header cell, cut with no … |
Chart::out_of_bounds(OutOfBounds::Drop) |
chart(): replaces turning points outside the bounds into NaN |
Chart::layering(Layering::PerDataset) |
chart(): the reference lines break under the trace |
Chart::legend_style(..) |
chart(): legend_style(""), the chart's own style as ratatui's; the axis-style hack is gone and the axes keep their default style |
Chart::legend_clear(false) |
chart() |
rich_ext::dsp |
spectroscope(): dsp::hann, dsp::fft, dsp::magnitude; about 100 lines of FFT, Bluestein and Hann removed |
rich_ratatui::style_from_color_name |
parse(): the ~30-line colour parser removed. rs-rich-ratatui is a dev-dependency of rs-rich-intuituive, so the example can use it |
latest(..) |
play(): a file or a pipe is read on a latest thread; the Source/Pull trait, the Threaded reader with its mutex and the every() polling are gone |
audio::input, audio::devices |
scope_app() and list_devices(): scope-tui's audio [DEVICE] [--list] and pulse [DEVICE] sources, behind --features audio; a default build says how to get them |
Also from the round-1 work: the hand-written ? modal is replaced by the
framework's help (cx.help()), fed by .bind(keys, description, ..).
New gaps and bugs¶
| Gap or bug | Needed for | API sketch | Where |
|---|---|---|---|
| A watch that runs on every write, not only on a change | Counting frames: a watch compares values, so two equal buffers (the still test signal, a file's trailing silence, digital silence from a sound card) are one. The example numbers a thread's buffers (Frame { number, data }) to get round it; audio::input cannot be numbered, so silence counts no fps there |
on_write(signal, \|value, cx\| ..), or latest/audio::input signals that carry a sequence number |
core |
latest threads and the headless clock |
A latest thread runs on the wall clock while a headless Script::wait advances a clock of its own, so a thread-fed app cannot be tested frame-exactly. The test signal moved to every(period) (the app's clock) for that reason, and the file test no longer checks fps |
a Headless/Driver hook that lets latest threads catch up (driver.settle_threads()), or latest_every(period, \|\| value), a generator on the app's clock |
core |
| An app builder cannot fail | audio::input must be called inside App::new, so a missing device cannot fail before the terminal is taken. The example carries the error to the first turn with watch(\|\| (), move \|_, cx\| cx.fail(..)) |
App::try_new(\|\| -> Result<Node, E>), or audio::open(..) -> Result<Capture> before the app and capture.signal() inside |
core (or the audio plugin) |
| Audio device names are descriptions | audio::devices() names the ALSA null device "Discard all samples (playback) or generate zero samples (capture)", not null; input matches names, so ALSA PCM names such as pulse may not be reachable |
Device { id, name, default }; input takes the id |
plugin (audio) |
| PulseAudio and PipeWire sources | scope-tui's pulse [DEVICE] --server-buffer N, whose main use is a sink's monitor; through cpal only ALSA's pulse device (the default source) is reachable, and --server-buffer has no meaning |
audio::input_with(AudioOptions { host: Host::Pulse, device, latency }), or a pulse feature on libpulse-simple |
plugin (audio) |
| Device configurations | audio --list prints each input's channels, rate range, buffer range and sample format in scope-tui; devices() gives only names |
Device::configs() -> Vec<DeviceConfig> |
plugin (audio) |
| A stalled stream | audio --timeout S: scope-tui ends when no buffer comes in time. Watching the signal cannot tell a stall from silence (see the first row), so the option is read but not enforced |
an AudioStatus signal (Running, Stalled(since), Failed(error)) beside the samples |
plugin (audio) |
| ALSA writes to stderr | Without a sound card, ALSA's ALSA lib conf.c: ... lines are printed before the app; printed while it runs, they would land on the screen |
install snd_lib_error_set_handler while capturing, and pass the messages to the app's log |
plugin (audio) |
| Help sections | The help lists one node's bindings as one group; scope-tui's keys are per mode, so the descriptions carry "oscilloscope: …" | .bind_in("oscilloscope", keys, description, f), or a help group per binding |
core |
| Modifier variants of a binding | Shift ×10, Ctrl ×5 and Alt ×⅕ on six keys are 24 bindings; to keep the help short only the plain ones are described | .bind_scaled("up", &[("", 1.0), ("shift", 10.0), ..], "scale up", \|cx, m\| ..), shown as one row |
core |
No bugs were found in the round-1 additions themselves.
Friction with the new APIs¶
- Two shapes for "the newest value from elsewhere".
latestgivesSignal<Option<T>>(Nonebefore the first),audio::inputaSignal<Matrix>that starts as empty rows. The example needs a match per source to take a buffer. One shape (Option) for both would let a source be one value. dsp::spectrumstops at the Nyquist frequency. scope-tui plots every bin, the mirrored half too, so the example usesfftandmagnitudeinstead. Fine as it is; aspectrum_fullwould only save three lines.legend_style("")reads as "no style" only once you know it; ratatui's legend takes the chart's own style. AChart::style(..)that the legend takes by default would say it directly.style_from_color_namelives in rs-rich-ratatui, which bringsratatui-core. Fine for the example (a dev-dependency); an app that only wants to read ratatui's colour names on its command line pays for the conversions too. It could sit in rs-rich-ext with rs-rich-ratatui re-exporting it.- Solver::Ratatui and
cellwere drop-in. No friction: the header is shorter than round 1's and exact. - Frames per second count frames drawn.
latestdrops what the app had no time for, as intended; with a debug build at 80x24 the example shows 19–20 fps where scope-tui shows 21–22, because scope-tui counts every buffer it reads.
Components that would help¶
Patterns written by hand from several built-ins, components from other ecosystems an audio or visualisation app would use, and plugins that would make the clone more capable. Value is for this kind of app.
| Component | What in scope.rs it replaces or enables | API sketch | Where | Value |
|---|---|---|---|---|
| A live plot node | renderable(move \|\| chart(..)) reading a frame signal, with no cap on how often it draws; a node that draws from a signal at most N times a second, dropping the rest |
live(fps_cap, move \|\| chart(..)) |
core | medium |
| A row of percentage cells | header()'s seven cell(..).percent(..) with the solver, the gap and the style: ratatui's one-row Table as one call |
cells([(35, a), (25, b), ..]).spacing(1) |
core | medium |
| A frame-rate meter | the frames/fps signals and the one-second every |
rate(signal) -> Signal<u32>, updates a second |
core | medium |
| A mode switcher | Mode::next, name and the tab binding |
cycle(signal, &[modes], "tab") |
core | low |
| Generated key help with sections | the help's descriptions, prefixed by hand (see "Help sections") | .bind_in(section, ..) |
core | medium |
| A PCM reader | Pcm and deinterleave: interleaved s16le into rows |
audio::pcm(reader, Format::S16Le, channels, buffer) -> impl Iterator<Item = Matrix> |
plugin (audio) |
medium |
| WAV files in and out | reading WAV as well as raw PCM; recording what is shown | audio::wav(path), audio::record(signal, path) |
plugin (audio, hound) |
medium |
| A signal generator | Demo: tones, partials and filtered noise |
dsp::tone(hz, rate), dsp::noise(seed), dsp::mix(..) |
plugin (dsp) |
medium |
| Trigger detection | triggered and the offset search |
dsp::trigger(samples, level, depth, Edge::Rising) -> Option<usize> |
plugin (dsp) |
low |
| Musical notes | tune's table of octave-0 pitches |
dsp::note_hz("A4") -> Option<f64> |
plugin (dsp) |
low |
| Axis grid lines | the spectroscope's reference lines, drawn as 29 datasets of two points | Axis::grid([20.0, 30.0, ..]) in the axis style, under the datasets |
plugin (rs-rich-ext chart) | medium |
| A spectrogram (waterfall) | a fourth mode: spectrum against time, the newest at the top, as in cava, spek and Audacity | Spectrogram::new(rows).push(spectrum), a heatmap over time |
plugin (rs-rich-ext chart) | high |
| A level meter with peak hold | a VU or peak meter per channel beside the scope (Bubbles' progress, ratatui's Gauge/LineGauge, and every DAW) |
meter(level, peak).hold(Duration) |
plugin (rs-rich-ext) | high |
| A sparkline of a level | the level history in the header | ratatui's Sparkline, Textual's Sparkline: sparkline(signal) |
plugin (rs-rich-ext) | low |
| Big text | a large clock or level readout (tui-big-text) | big_text(markup) over rs-rich-art's FIGlet |
plugin (rs-rich-art) | low |
| A device picker | choosing among audio::devices() inside the app, as Bubble Tea's list does in audio TUIs |
audio::picker(on_pick), a list over the devices |
plugin (audio) |
medium |
Round 2: files (Yazi)¶
examples/files.rs rebuilt on what round 1's gaps added (PR #752). It
uses intuiTUIve's own components and plugins only, with no framework
change in the round. Built with default features it follows the disk only
through what it does itself and describes images; the crate's watch and
art features (which CI's tapes job now builds the examples with) add the
watcher and image previews. Local times come from rs-rich-ext's
local-time feature, turned on for the examples and tests through a
dev-dependency, so files needs no required-features.
Round-1 gaps now closed, and where files.rs uses them¶
| Round-1 gap or workaround | Now | Where in files.rs |
|---|---|---|
Per-prefix chord pop-ups (which()) |
bind_sequence on the root for all 31 sequences; App::which_key(true) draws the pop-up; the help and the palette list them |
SEQUENCES, files_app_with |
| The root made focusable to own the keymap | .previews_keys() on the root column; the current list is focused (autofocus) |
files_app_with, current_column |
| Two synced lists for the line mode, the switch on the measured width | one list_lazy with .trailing(..) (Yazi's padding cell included) |
current_column, linemode_cell |
| Markers as a lead cell of the markup | .marker(\|i\| style) |
current_column, parent_column |
| The selected style per row | row_style + the theme's reverse was already right; .selected_style_for gives the preview's underline |
preview_column |
list rebuilding every row |
list_lazy in all three columns |
the three columns |
Re-derived hsplit maths (split_at, columns(), body(), top_center, hovered_rect) |
popup_at(Origin::TopCenter, Offset::new(0, 2, 80, 3)); RowRects for the hovered row (Origin::At); rect_handle() for the preview's width and the page size |
Prompt, Act::Rename, open, preview_column, Fm::page |
| The End-key caret workaround | .caret(Caret::At(stem)), Yazi's --cursor=before_ext |
Act::Rename |
| The hand-made confirm box | cx.confirm(title, body, items, on_yes) |
Act::Paste, Act::Remove |
| Toasts for errors | cx.notify(Level, title, body) + App::toasts_at(Corner::TopRight), theme notify.* in Yazi's colours |
work, the prompts |
The one-row leaf line |
rule(), joined to the help's border |
help |
| Polling the directory every 2 s | watch_path on the directory and its parent, moved with it (watch feature) |
follow_disk |
UTC times, numeric uid:gid |
rich_ext::fs::short_local_time, local_time, user_name, group_name |
short_time, owner, spot |
| Hand-copied icons and type colours | rich_ext::filetype::icon_with (hovered flag), style_for, mime_for_path, mime_from_bytes, mime_for |
entity, row_style, load_preview, spot |
| Images described | image_of(path, ImageView) (art feature) |
image_pane |
| Opening files left to the shell | Yazi's [open] rules: cx.handoff_then($EDITOR) for text and folders, the system opener ($OPENER, xdg-open, open) in a spawn; O is Yazi's "Open with:" pick below the row |
openers_for, open, open_with |
| A first Markdown preview stalling | rich_ext::syntax::preload at start-up |
main |
Signal-polled preview width |
the pane's rect_handle() in the preview watch |
preview_column |
Kept on purpose: the empty-list end of visual mode, the trash never moved
into itself, and tab clicks going through switch_tab (all tested).
New gaps and bugs¶
| Gap or bug | Yazi feature that needs it | API sketch | Where |
|---|---|---|---|
| A list's highlight is drawn over the item's markup, so no cell of a selected row can escape it | Yazi's rounded indicator ends (U+E0B6, U+E0B4) on the hovered row with a Nerd Font: its spans patch over the line style, ratatui-fashion | .highlight_layer(Layer::Under), so markup ([not reverse blue]) wins; or .selected_caps(open, close) |
core |
| No left-cut overflow | The header's path loses its start (ui.truncate(.., rtl = true)); the example cuts the string from viewport() width minus the count's |
Overflow::EllipsisStart, or text(..).truncate_start() |
core (rich's Overflow is a mirror, so an intuiTUIve text option) |
| Which-key has no description style and no gap between its columns | Yazi's which: keys bright_cyan, bright_black separator, descriptions bright_magenta, columns padded; ours run into the next column's key ((rever a Sort…) |
theme which_key.desc, which_key.sep; a cell of gap per column; right-aligned keys as Yazi |
core (bug: the missing gap) |
cx.confirm has a fixed 50-wide box, and an empty body still takes a row and a rule |
Yazi's trash/delete confirm is 70×20 with no body; overwrite 50×15 | cx.confirm_with(ConfirmOptions { size, .. }); an empty body draws nothing |
core (bug: the empty body) |
Signal::set needs PartialEq; RectHandle and PathWatch are not |
Keeping the watchers to stop them, keeping the column's handle in app state | derive PartialEq on both (compare by identity), or Signal::replace without the bound |
core |
A RectHandle exists only after its node is made; RowRects can be made first |
App state wants the column's rect before the view is built: the example stores Signal<Option<RectHandle>> |
RectHandle::new() + node.rect_into(&handle) |
core |
Image decoding is not a task wait_for_tasks waits for |
Testing the image preview needs a polling Driver loop |
count image's decode as a task |
core (art) |
| The headless driver records only a handoff's program | Asserting that $EDITOR got the hovered file |
Record.handoffs: Vec<(program, args)> |
plugin (rs-rich-interact) |
| A "keyed" resource | The preview: re-fetch when the hovered path or width changes, drop stale results, clear after a delay; the example keeps a generation counter and two spawns |
resource_for(move \|\| key(), \|key\| fetch(key)).stale_after(150ms) |
core |
| MIME by content in the background | Yazi's type colours come from file-style sniffing done by its fetchers; the listing uses the name only |
filetype::mime_batch(paths) -> Vec<&str> run in a spawn, merged into the listing |
plugin (rs-rich-ext) |
| No archive extraction or system trash | Yazi's extract opener and its trash |
see components below | plugin |
| Kitty/Sixel images | Yazi's pixel-exact previews | screen pass-through for graphics escapes | core + rs-rich-art |
Friction with the new APIs¶
bindvsbind_sequence: inbind("k up", ..)a space separates alternatives, inbind_sequence("g g", ..)keys pressed in turn. Easy to mix up when moving a binding between the two tables; a sequence with one key silently becomes abind. A"g>g"or["g", "g"]form would read unambiguously.- The comma:
"\",\" s"for,sis hard to read;"comma s"would be kinder. - Pop-up title styles:
popup.titlestyles every pop-up's title, but Yazi's confirm titles are blue and its input titles plain. Prompts set.title_style("default")by hand;cx.confirmcannot take a style. selected_style_forwas not needed for Yazi's coloured bar:row_styleplus areversehighlight already gives it. Its doc example suggests it for that case; it earns its keep only for a different look per row (the preview's underline here could equally betheme_styles).rule()joins the border only with no padding between it and the panel, so the help pads its field and list one by one instead of the column; arule().inset(..)or padding that rules ignore would help.popup_atwithOrigin::At(rect)puts the box below the rect and then clamps it on screen; at 80 columns an 80-wide rename box slides to the left edge, away from the row (Yazi does the same, but aw: Size::Percentor "shrink before moving" option would keep it under the row).notifyhas no width or wrap control: the 48-cell box wraps a one-line error at 80 columns.list_lazy's closures re-read the same memos forlen,item,trailing,markerandrow_style; alist_of(memo, |entry, i| Row { text, trailing, marker, style })would read the data once per row.
Components that would help¶
| Component | What it is | In files.rs it would replace or enable | API sketch | Where | Value |
|---|---|---|---|---|---|
| Miller columns | parent, current and preview panes over one cursor, with rails, remembered positions and click-to-navigate | parent_column, current_column, the preview's dir list, the remember map, three on_mouse handlers (~300 lines) |
miller(source: impl Tree, cursor).ratios(1, 4, 3).preview(\|node\| ..) |
core (or rs-rich-ext) | high |
| File list | a lazy list of Entrys with icon, type colour, marker, line mode and find highlights built in |
entity, row_style, marker_style, linemode_cell |
file_list(entries, cursor).linemode(Linemode::Size).icons(true).markers(\|p\| ..) |
plugin (rs-rich-ext filetype + intuituive) |
high |
| Status bar with segments | left/right segments with powerline caps between them, Yazi's and lualine's | status, the cap closures |
status_bar().left(seg(" NOR ", main)).right(..).caps(Caps::Rounded) |
core | medium |
| Breadcrumb / path header | a path that cuts from the left, with ~, and flags after it |
header's cwd text |
path_header(path).home_tilde().flags(..); landed as core breadcrumb(parts) + files::path_crumb(path) |
core + plugin (files) |
medium |
| Preview pane by type | picks a renderer from the MIME type: code, Markdown, image, directory, hex, archive listing; worker-loaded, stale-safe | load_preview, Preview, preview_column's switch, the generation counter |
preview(path_signal).with(Previewer::code()).with(Previewer::image()) |
plugin | high |
| Tab strip that hides below two tabs | tabs that collapse with one tab, number titles, and report clicks as a change event |
tab_strip's switch, clicked signal and two watches |
tabs(titles, active).auto_hide().on_change(\|to, cx\| ..) |
core | medium |
| Key-hint footer | the keys that work now, from the bindings (Bubbles' help, Textual's Footer) |
the help modal for the common case | key_hints().from_focus() |
core | medium |
| Input prompt preset | a titled one-line input at an origin, with change/submit/cancel (Yazi's input, Bubbles' textinput in a box) |
the Prompt builder (~80 lines) |
cx.prompt(title, Origin, Offset).text(..).caret(..).on_change(..).open(\|text, cx\| ..) |
core | high |
| Pick menu at a point | a short list in a box at an origin, Enter picks (Yazi's pick) |
open's popup_at + list |
cx.pick(title, Origin, items, \|i, cx\| ..) |
core | medium |
| Openers | Yazi's [opener]/[open] rules: MIME globs to commands, blocking (handoff) or in the background |
Opener, openers_for, open_with, system_opener |
rich_ext::open::rules().matching(mime) -> Vec<Opener>; opener.run(cx, paths) |
plugin (rs-rich-ext) | medium |
| Directory model | a watched, sorted, filtered listing with natural sort and stat caching (Yazi's Folder) |
read_dir, sort_entries, natural, follow_disk, the listing memos |
dir_model(path).sort(..).hidden(false).watch() -> Memo<Vec<Entry>> |
plugin | high |
| File operations | copy/move/trash/delete with progress, a free-name rule and "not into itself" checks (Yazi's scheduler) | copy_all, move_to, paste, unique, work |
fs_ops::paste(yank, dir, Conflict::Rename).spawn(progress) |
plugin (rs-rich-ext fs) |
medium |
| FreeDesktop trash | the XDG trash spec (and macOS/Windows bins), restore | the .trash folder stand-in |
rich_ext::trash::put(path), list(), restore(item) |
plugin | medium |
| Archive listing | list tar/zip/7z contents as a preview, extract (Yazi's archive previewer, extract opener) |
the "binary data" note for archives | archive::entries(path), archive::extract(path, dir) |
plugin (new) | medium |
| Git status per file | the M/A/? signs Yazi's git plugin shows |
a line-mode column | git::status(dir) -> HashMap<PathBuf, Status> |
plugin (new) | medium |
| Fuzzy finder over the tree | fzf/fd as a component that streams results (Yazi's z, Television, Bubbles' list filter) |
jump's blocking 20,000-entry walk |
fuzzy_find(walk(root).hidden(false)).stream() |
plugin (rs-rich-interact Jump + a walker) |
medium |
| Task manager | running file jobs with progress (Yazi's w) |
errors-only notifications; enables progress | tasks_panel(scheduler) |
core | low |
| Hex view | a hex dump previewer (ratatui's hexyl-style widgets) |
the binary note | hex_view(bytes) |
plugin (rs-rich-ext has hex) |
low |
Round 2's gaps in the core: widgets, pop-ups and the clock¶
The core items of round 2 above, and those the plugin work (files, audio, charts) asked for, were added; the clones are rebuilt on them in the next round.
| Gap | Now |
|---|---|
| Highlight over the markup | .highlight_layer(HighlightLayer::Under) |
| No left-cut overflow | Node::truncate_start(); breadcrumb(path) |
cx.confirm 50 wide, empty body |
cx.confirm_with(.., ConfirmOptions::new().size(w, h).title_style(..), ..); no row or rule for an empty body |
| Pop-up title styles | a pop-up's panel's .title_style already won over popup.title; ConfirmOptions, PromptOptions and PickOptions take one |
rule() and padding |
a rule runs across the padding it touches to the border |
popup_at sliding away |
Origin::At shrinks the box to the room under the rectangle before moving it |
notify 48 cells |
App::notify_width(size) |
list_lazy's five closures |
list_rows(len, \|i\| ListRow, selected) + .marker_column() |
selected_style_for's docs |
corrected |
| Input prompt, pick menu | cx.prompt / prompt_with, cx.pick / pick_with |
| Status bar, breadcrumb, key-hint footer, live plot, percentage cells | status_bar + seg + Caps, breadcrumb, key_hints, live, cells |
| Tab strip hiding, click routing | tabs(..).auto_hide().on_select(..) |
| One-shot timer, stepping the clock | after / cx.after, Driver::advance; stale_after on the app's clock; DrawCx::now() |
| Sequences in sections, cycling back | bind_sequence_in, bind_cycle_back |
| An app dropped unfinished | dropping an App closes its runtime |
A non-Send continuation, a stream as a task |
spawn_then, spawn_stream |
| Cleanup when a node goes | Node::on_unmount |
render_with needing a quit |
it settles after its keys and reads the screen |
| The wheel over a list | .wheel_step(n), 0 passing it on |
| Accessible text for a widget | Widget::access_text |
| Kitty/Sixel pass-through | App::graphics(PlacementSource) after each frame's cells; the image node still draws cells (below) |
New gaps and bugs¶
| Gap or bug | Found where | API sketch | Where |
|---|---|---|---|
The image node places no graphics |
App::graphics draws what a PlacementSource finds in the cells, but image writes half blocks with nothing to find. Wanted: with the terminal's support detected, image writes Kitty placeholder cells (rich_art::kitty::placeholder_row, the image sent once with transmit) or reserves its rect for a Sixel overlay, and an app-level source maps them to placements |
ImageView::graphics(Auto); a GraphicsRegistry the image node registers with, given to App::graphics by the art feature |
core (art) + rs-rich-art |
switch keeps every page it built |
on_unmount never runs for a page switched away from, and a switch keyed by a path grows without end |
switch(..).forget_hidden(), or a switch_fresh |
core |
after cannot be cancelled |
a debounce restarts its wait; today the handler checks a generation | after(..) -> Timeout with cancel() |
core |
truncate_start's … is unstyled |
a cyan path gets a plain … |
the … in the first kept cell's style |
core |
A rule in a row is one cell wide |
rule() is Size::Fixed(1), meant for columns |
size a rule Flex(1) across a row |
core (bug) |
key_hints leaves out the app's keys |
the palette and help keys (App::help_key) and sequences are not listed |
include them, or key_hints().with(..) |
core |
live measures without a cap |
sized Auto, each layout renders it |
measure from the last draw | core |
Components that would help¶
| Component | What it would replace or enable | API sketch | Where | Value |
|---|---|---|---|---|
| A debounced signal | the find-as-you-type and preview reloads built from after and a generation |
debounced(signal, 150ms) -> Signal<T> |
core | medium |
| A mode line | status_bar segments that follow bind_cycle's mode, its colour per mode |
mode_segment(mode, &[(Mode::Normal, "NOR", "blue"), ..]) |
core | low |
| A footer that pages | key_hints showing more on ? (Bubbles' short and full help) |
key_hints().expand_on("?") |
core | low |
Round 3: files (Yazi)¶
examples/files.rs rebuilt on round 3's additions: the files plugin
(intuituive::files, rs-rich-ext's listing, open, file_ops, trash,
git, archive) and the core's bars, tabs and dialogs. No framework
change in the round. It went from 2,756 lines to 1,450: what is left is
Yazi's keymap, what each key does, tabs, the selection and visual mode,
the help and the spotter. It now needs the crate's files feature
(required-features = ["files"], so a default build and --all-targets
skip it); archive, watch and art add to it, and CI's tapes job
builds it with all four.
A rule from this round on: a core entry below names a component that
another, unrelated component could reuse, with nothing about files, paths
or Yazi in its name or parameters. What is about files is a plugin
entry (the files feature); where a file component has a generic shape
underneath, both are proposed: the generic core piece and the plugin
built on it.
Round-2 gaps now closed, and where files.rs uses them¶
| Round-2 gap or component | Now | Where in files.rs |
|---|---|---|
| Miller columns, file list, directory model, preview by type (~700 lines of round 2) | files::miller(start).icons(..), view_with(MillerOptions::new().markers(..).git(..)); its cwd, cursor, hovered, current, hidden, sort, linemode, find, preview_top signals and its arrow/page/enter/leave/cd/refresh |
Fm, columns, run |
| MIME by content | DirModel::sniff, inside miller; m.current.mime(&entry) |
the rows' colours, spot |
| The keyed preview with a generation counter | files::preview inside miller (code, images with art, archives with archive, directories, hex) |
gone from the example |
Openers (Opener, openers_for, system_opener, editor) |
files::opener_rules() (Rules::matching, shared), files::open_with |
open, launch |
| File operations, a free name, "not into itself" | files::tasks() + Op::{Copy, Move, Delete, Trash} with Conflict::{Rename, Overwrite} |
Fm::job, paste, remove |
No system trash (a .trash folder stood in) |
Op::Trash: the FreeDesktop trash |
remove; tested with XDG_DATA_HOME |
| Archives described as binary | the archive preview; Launch::Extract through Tasks::extract |
launch; archives_are_listed_in_the_preview_and_extracted_by_enter |
| Git signs | files::git_status(m.current) into MillerOptions::git |
columns; git_signs_show_beside_the_line_mode |
jump's blocking 20,000-entry walk |
files::finder(walk(cwd).hidden(..)), streaming |
jump; z_finds_a_file_below_here_and_goes_to_it |
| Task manager | files::tasks_panel(jobs) in a modal on w; Tasks::running() for Yazi's quit confirm |
task_manager, quit, the status line's progress |
The Prompt builder (~80 lines) |
cx.prompt_with(.., PromptOptions::new().title_style("default").at(..).caret(..).on_change(..).on_cancel(..), ..), Yazi's 50 wide |
every prompt in run; prompts_are_yazis_50_wide_at_the_top_centre |
"Open with:" as popup_at + list |
cx.pick_with(title, Origin::At(row), items, PickOptions::new().offset(..), ..) |
open |
cx.confirm 50 wide, an empty body taking rows |
cx.confirm_with(.., ConfirmOptions::new().size(..), ..): Yazi's 70 x 20 trash, 50 x 15 overwrite, the quit confirm |
remove, paste, quit; the_trash_confirm_is_yazis_70_by_20 |
| The status line's hand-made caps | status_bar(left, right) of seg(..).style_with(..), .caps(Caps::Rounded) with icons |
status |
The header's left cut from viewport() |
breadcrumb(..).home_tilde().crumb_suffix(..), cyan through the theme's breadcrumb* styles |
header |
tab_strip's switch, clicked signal and two watches |
tabs(..).auto_hide().on_select(\|to, _\| fm.switch_tab(to)) |
tab_strip |
| Which-key columns running together, no description style | the fixed columns; theme which_key.sep, which_key.desc in Yazi's colours |
theme |
"\",\" s" |
"comma s" |
SEQUENCES |
| A rule in a padded column not joining the panel | fixed: the help pads its column, not each child | help |
RectHandle in a signal, Signal<Option<RectHandle>> |
not needed: Miller keeps its column's rect (m.row_rects(), column_rect()) |
open, Act::Rename |
| The headless record kept only the program | Record::handoff_commands |
enter_hands_the_terminal_to_the_editor_as_yazi_does asserts the file |
Newly faithful, and tested: the system's trash; file jobs in the
background and a task manager; quitting asks while tasks run; git signs;
type colours sniffed from content; archive listings and extraction; the
finder's streaming walk; 50-wide prompts; Yazi's confirm sizes; a click in
the parent column goes into that directory (Yazi's cd; round 2 went to
the parent, hovering it, and its test now says so). One assertion changed
for the worse: a binary file previews as a hex dump, not Yazi's file
classification (below).
Not used, on purpose: key_hints (Yazi has no footer), notify_width
(the default fits Yazi's errors at 80 columns), Miller::navigation (see
friction).
New gaps and bugs¶
| Gap or bug | Needed for | API sketch | Where |
|---|---|---|---|
Bug: work that a layout starts (a watch on a rect_handle) is not settled before the next input event. files::preview loads only once its pane has a width, so an app's first frame shows no preview and an event sent right away (a click on a directory's preview) finds nothing. Repro: run(files_app(root).wait_for_tasks(true), Script::new().keys("q"), 80, 10) in tests/files.rs: the preview column of the hovered alpha is empty; Script::new().wait(50ms).keys("q") shows a.txt. The tests wait first (first_frame()) |
a correct first frame; clicks and keys at start-up | a turn that lays out runs the watches its new rects woke, and their tasks under wait_for_tasks, before the next event; or preview loads at a provisional width |
core (generic: any watch on a rect) |
Bug: list options on a node that wraps a list do nothing, silently. file_list returns a switch (for its "No items"), so .highlight_layer(..), .wheel_step(..) or .selected_style_for(..) on it are ignored |
Yazi's rounded ends on the hovered row (round 2's gap, still open for the clone); any wrapped list | a wrapper forwards list options to the list it holds, or warns once in debug builds that an option met no list | core (generic) |
| The hovered row's rounded ends with a Nerd Font | Yazi's hovered row | FileListOptions::hovered_caps(open, close) and MillerOptions::hovered_caps, drawing the ends in the row's colour on highlight_layer(Under) |
plugin (on core's highlight_layer) |
| A binary file previews as a hex dump | Yazi's file previewer: file -bL's classification ("data", "ELF 64-bit…") |
a built-in Previewer::file_type() (the file command, else a description of the MIME type), chosen by PreviewOptions::binary(BinaryPreview::FileType \| Hex) |
plugin |
One cwd: tabs, each with a back/forward history, are app code (Tab, history, switch_tab, close_tab, swap_tab, a watch on m.cwd: ~140 lines) |
Yazi's tabs, H and L | core: history(signal) -> History (back/forward over any signal's past values: back(), forward(), can_back()), and tab_set(make: Fn() -> T) -> TabSet<T> (states with an active index: open, close, swap, switch, titles) for tabs(..); plugin: files::tabs(miller) giving each tab its cwd and history |
core + plugin |
Selection and visual mode are app code (selected, visual, end_visual, targets, marker: ~100 lines) |
Space, Ctrl+A, Ctrl+R, v, V, and what y/x/d act on | core: multi_select(keys) -> MultiSelect<K> over a list's items by key, with toggle, select_all, invert, a range anchor that follows a cursor (visual(unset)), targets(hovered); plugin: MillerOptions::selection(sel) for the markers and Miller::targets() |
core + plugin |
| Yank state and paste are app code | y, x, p, P, the copied and cut markers | files::Yank (paths, cut) with markers and paste(into, conflict) -> Op |
plugin |
Find next/previous over the list is app code (find_arrow, ~25 lines); Miller::find only highlights |
/, ?, n, N | core: list_search(query, items, cursor) with next(), previous(), place() -> (n, of) (smart case); plugin: Miller::find_next(previous) |
core + plugin |
The hovered entry is kept by hand across a re-sort, filter or hidden toggle (keep) |
,s, f, . |
core: a list cursor kept by key (cursor_by(\|item\| key)): when the items change, the selection follows the same key; plugin: Miller's cursor by name |
core + plugin |
A right-click menu on a row needs cx.pointer() and row_rects maths: Miller's lists are not reachable for on_mouse |
the menu on a row | core: list.on_context(\|index, cx\| ..) (a right press or the menu key on an item); plugin: MillerOptions::on_context(\|entry, cx\| ..) |
core + plugin |
open_with extracts with a plain spawn; to show it as a task the example matches Launch::Extract itself and calls Tasks::extract |
Yazi's extract as a task | open_with_tasks(cx, opener, paths, jobs, done), or Tasks optional in open_with |
plugin |
| No progress segment for the status line; the example prints the share and count | Yazi's progress gauge | core: seg_gauge(fraction, label), a status_bar segment filled to a share; plugin: Tasks::summary() -> (fraction, running, failed) |
core + plugin |
Miller keeps the directories visited private (remembered(dir) only) |
Z (zoxide) over what was visited; the example keeps its own set | Miller::visited() -> Vec<PathBuf>, and a frecency store (files::frecency(path)) for Z across sessions |
plugin |
The finder walks a tree only; Z uses rs-rich-interact's Jump, so two finders look different |
one look for z and Z | core: a streaming fuzzy list over any item source (fuzzy_list(source)); plugin: files::finder(walk) on it (the refactor under way) |
core + plugin |
finder has no Esc, title or border; the example adds them |
z in a modal | finder(..) closes its modal on Esc (or .on_cancel) |
plugin (on the core finder) |
| The trash on macOS and Windows fails ("Unsupported") | d there |
trash::put through the Finder (AppleScript or NSFileManager) and the shell's recycle bin (SHFileOperation) |
plugin (rs-rich-ext) |
| Tab titles are cut to the width by hand, and the unselected style is markup in each title | Yazi's strip | core: tabs(..).fit() (titles cut with … to share the width) and a theme style tabs.unselected |
core |
Friction with the new APIs¶
Miller::navigation's descriptions are its own ("Move up", "Go to the top"), not Yazi's ("Previous file"), and its key set is fixed (noshift+pageup). The help and the which-key pop-up show descriptions, so the example binds the moves itself withm.arrow,m.page, …. A table form (navigation_with(&[(Move::Up, "k up", "Previous file")])) would let a clone keep its wording.- Two ways to reach a column: Miller's lists are built inside
view_with, so anything not inMillerOptions(anon_mouse, list options, a name) has to go on the whole view. Every option a clone needs has to be added toMillerOptionsby hand; aMillerOptions::current(|list| list.option(..))hook would end that. - Which signals are public is uneven:
cwd,cursor,find,preview_topare fields,current.filteris a field of a field,row_rects()andcolumn_rect()are methods,remembereda lookup, the preview cursor private. A clone reads all of them; one style (all methods returning signals) would be easier to find. - Tasks report twice: a failed job stays in
tasks_paneland the app notifies too, so the title shows twice ("Move src to src: src: can't go inside itself"). An option for the panel's line without the title, orTasks::on_fail(|job, cx| ..)app-wide, would replace thedoneclosure each call repeats. files::previewdoes not use the core'sresource_for/stale_afterthat round 3 added for it: it keeps its own generation counter and aspawned sleep. The first-frame bug above would be one fix in one place if it did.required-featuresfor an example that a test includes by path:tests/files.rsneeds#![cfg(feature = "files")]by hand; without it a defaultcargo testfails to compile the test, not skips it.
Components that would help¶
| Component | What it would replace or enable in files.rs | API sketch | Where | Value |
|---|---|---|---|---|
| History (back/forward) | Tab::back/forward, history, the watch that records them |
history(signal) -> History with back(), forward(); a browser's, a pager's, a REPL's |
core | high |
| Tab set | tabs/active signals, new_tab, switch_tab, close_tab, swap_tab, tab_strip's titles (Bubble Tea apps and Textual's TabbedContent keep a model like it) |
tab_set(\|\| state) -> TabSet<T>; tabs_for(&set, \|t\| title) |
core | high |
| Multi-select with a visual range | selected, visual, end_visual, targets, marker (ratatui's tui-widget-list, Textual's SelectionList, vim's visual mode) |
multi_select::<K>() with toggle, range_from(cursor), targets(hovered) |
core | high |
| Files on top of those | the rest of the selection code, yank, paste, tabs per directory | files::selection(miller), files::Yank, files::tabs(miller) |
plugin | high |
| List search | find_arrow and the find prompt's handlers (Bubbles' list filter, Textual's OptionList search) |
list_search(query, items, cursor).next() |
core | medium |
| Cursor kept by key | keep around every sort, filter and hidden toggle |
list(..).cursor_by(\|item\| key) |
core | medium |
| Context menu on list items | the columns' on_mouse with cx.pointer() and row_rects (Textual's on_click per option) |
.on_context(\|i, cx\| items) |
core | medium |
| Help screen options | the hand-built help modal (~40 lines) when the core help can take a title, the key notation (<C-u>) and Yazi's filter-first layout (Bubbles' help, Textual's HelpPanel) |
cx.help_with(HelpOptions::new().title("mgr.help").key_format(Vim)) |
core | medium |
| Description list | the spotter's [blue]key[/][yellow]value lines (Textual's DataTable two-column use, ratatui's Table of facts) |
facts([("Name", value), ..]).key_style(..) |
core | low |
| Spotter | spot: name, path, MIME, size, mode, owner, times, link (Yazi's spot) |
files::spot(path) on facts |
plugin | low |
| Gauge segment | the status line's progress text | seg_gauge(fraction, label) |
core | low |
| Frecency store | the session-only visited set behind Z (zoxide) |
files::frecency().visit(dir), .ranked() |
plugin | medium |
file classification previewer |
the hex dump for binaries | Previewer::file_type() |
plugin | low |
| Bulk rename | Yazi's r on a selection: the names in $EDITOR, renamed on save |
files::bulk_rename(cx, paths) over a handoff |
plugin | medium |
| Shell command prompt | Yazi's ; and : (run a command on the selection, blocking or not) |
cx.prompt + open::Launch::Shell as files::shell(cx, paths) |
plugin | medium |
| Content search | Yazi's s/S (fd and ripgrep into a virtual listing) |
core: a streaming list of results (fuzzy_list's source); plugin: files::search(root, query, Grep) showing a listing of matches in Miller |
core + plugin | medium |
Round 3: scope-tui¶
examples/scope.rs
rebuilt on what round 2's gaps added (the audio plugin rounded out,
rs-rich-ext's dsp generators and chart options, and the core's clock,
key and bar components), with no framework change in the round and no
hand-made stand-in left in the example. Its hand-rolled test signal, PCM
reader, Frame numbering, trigger search, note table, 29 reference
datasets, polled fps counter and the 24 modifier bindings are gone; it is
1,323 lines where round 2's was 1,340, while doing more of what scope-tui
does (audio --timeout, audio --list's configurations, PulseAudio's
sources and --server-buffer). tests/scope.rs counts frames exactly on
the app's clock and checks the new behaviour (24 tests; 25 with audio).
Round-2 gaps now closed, and where scope.rs uses them¶
| Round-2 gap or workaround | Now | Where in scope.rs |
|---|---|---|
A watch that runs on every write (Frame { number, data } numbered by hand) |
on_write(samples, ..) takes every buffer, silence included; the example's Frame is gone |
scope_app, the fps match |
Frames per second from a counter and a one-second every |
rate(samples) |
scope_app |
latest threads and the headless clock |
latest_every(period, ..) makes the test signal; Latest::sleep keeps a file's -l beat and the silence after it; tests run with wait_for_tasks(true) and count frames exactly (but see the first bug below) |
Input::Every, play, tests/scope.rs |
An app builder cannot fail (a watch that fails on the first turn) |
audio::open(AudioOptions) before the app: a missing device fails before the terminal is taken, so App::try_new is not even needed |
input, capture_options |
| Two shapes for "the newest value" | audio::input/samples() are Signal<Option<Frame>>, as latest's; one on_write arm per source kind remains (see friction) |
scope_app |
| Device names are descriptions | Device { id, .. }: --list prints ids, as scope-tui (cpal 0.15's names are ALSA ids), and audio DEVICE opens by id |
list_devices, input |
| Device configurations | Device::configs(), printed in scope-tui's + 2ch 8000-192000hz 32-65536buf (i16) form |
list_devices, main |
A stalled stream (--timeout read, not enforced) |
AudioOptions::stall_after + capture.status(): Stalled ends the app with scope-tui's "data source returned null", printed after the terminal is back as [!] Error executing app: … |
scope_app, STALLED, main |
| PulseAudio and PipeWire sources | the pulse feature's Host::Pulse (every source, sink monitors among them); --server-buffer N as latency(N × buffer), scope-tui's maxlength |
pulse_options, input |
| ALSA writes to stderr | kept off the screen inside the plugin; nothing in the example | — |
| Help sections | .bind_in("oscilloscope" \| "spectroscope", ..) |
scope_app |
| Modifier variants of a binding (24 bindings) | .bind_scaled(key, &MAGNITUDES, ..), six bindings, one help row each |
scope_app |
| A mode switcher | .bind_cycle("tab", .., mode, &Mode::ALL); Mode::next gone |
scope_app |
| A row of percentage cells | cells([(35, ..), ..]) |
header |
A PCM reader (Pcm, deinterleave) |
audio::pcm(reader, Format::S16Le, channels, buffer) |
input |
A signal generator (Demo's sines and xorshift) |
dsp::partials, tone(..).phase(..), noise(..).low_pass(..), mix |
Demo::new |
Trigger detection (triggered and its search) |
dsp::trigger(.., Edge) |
Oscilloscope::trigger_at |
Musical notes (tune's table) |
dsp::note_hz (see friction: one note in ten is a sample off scope-tui) |
tune |
| Axis grid lines (29 two-point datasets) | Axis::grid([..]) with Axis::style(axis colour) |
chart, Plot::x_grid/y_grid |
legend_style("") |
Chart::style("none"), which the legend takes |
chart |
dsp::spectrum_full |
used for the points; fft stays for the phase difference |
spectroscope |
style_from_color_name in rs-rich-ratatui |
rich_ext::palette::style_from_color_name; the example no longer touches rs-rich-ratatui |
parse |
Not used, on purpose: live (scope-tui draws every buffer it reads, with
no cap), audio::wav/record, audio::picker, meter, sparkline,
big_text, Spectrogram, status_bar and key_hints: scope-tui has
none of them, and extras are added only where scope-tui has them.
New gaps and bugs¶
| Gap or bug | Needed for | Repro or API sketch | Where |
|---|---|---|---|
Bug: values from other threads are picked up at most every 50 ms. The run loop waits on the terminal for Driver::timeout (50 ms at most) and a latest send or a capture's buffer does not wake that wait, so a source faster than 20 a second is capped at 20 in a real terminal; the headless clock does the same (Driver::advance and Script::wait step to the next timer or 50 ms, never to a Latest::sleep due) |
scope-tui's frame rate: its default 2048-sample buffer at 48 kHz is 23 fps, -b 1024 47 fps; the clone shows 20 for both (paused, so drawing is not the cost; -b 4096 shows its true 12). Tests step the driver by hand every 10 ms (step in tests/scope.rs) |
scope --still -b 1024, Space, wait 3 s: 20fps. Headless: latest_every(10 ms, ..) counted by rate under wait_for_tasks(true), driver.advance(2.1 s): 20, not 100. Fix: a proxy send wakes the backend's read (a self-pipe or the backend's waker), and timeout takes the earliest due of the threads asleep on the app's clock |
core (generic) |
Bug: grid lines share one layer. Under Layering::PerDataset an x and a y grid line that cross merge their dots (⣸); scope-tui's two reference datasets draw the second over the first (⢸). Pinned by reference_lines_are_drawn_as_scope_tuis_datasets_are |
The vectorscope's cross, faithful cell for cell | Chart with x_axis(..grid([0])), y_axis(..grid([0])), Layering::PerDataset, no trace at the centre. Fix: a layer per grid line (y lines first, as scope-tui orders them) |
plugin (rs-rich-ext chart) |
Bug: a grid with no datasets draws in Marker::Dot. The grid takes the first dataset's marker, so before the first buffer the zero line is dots, then Braille |
The first frame's zero line | Chart::new(Vec::new()).y_axis(Axis::default().bounds(-1., 1.).grid([0.])): • cells. Fix: Axis::grid_marker(..) or Chart::marker(..) as the default |
plugin (rs-rich-ext chart) |
| Bindings active only in a state | scope-tui's per-mode keys: t, e, =, w, l do nothing outside their scope but are bound (and listed) always; p means peaks in one scope and phase difference in another, so it is one binding with one description |
.bind_when(\|\| mode.get() == Osc, keys, desc, f) (listed in the help only while active; two bindings may share a key under different conditions); or a switch whose shown page's bindings apply and are listed in its section |
core (generic) |
| Sections for every kind of binding | PgUp/PgDn are the oscilloscope's and the spectroscope's, but bind_scaled (and bind_cycle) take no section |
a .section("oscilloscope", \|node\| node.bind_scaled(..)) scope, or _in variants of each |
core (generic) |
| Named punctuation keys | =, -, +, _ are written "\"=\"" (round 2 added comma only) |
equals, minus, plus, underscore, slash, … wherever keys are parsed |
core (generic) |
| Testing a capture's status | --timeout (a Stalled capture) cannot be tested without a sound card that stops; the test covers only the open error |
audio::Capture::simulated(frames, period) with stall() / fail(why), built on latest |
plugin (audio) |
| A thread feed with a state | a file's end, a pipe closed, a stall: the example's file source cannot say it ended (it pads with silence, as scope-tui), and only a capture has a status | core: latest_with_state(..) -> (Signal<Option<T>>, Signal<FeedState>) with Running, Stalled(since) after stall_after, Ended, Failed(why), for any thread (a sensor, a log tail, a socket); plugin: audio::Capture::status built on it |
core (generic) + plugin (audio) |
audio::pcm moving behind a feature |
Another round-3 change gates audio::pcm/Format/deinterleave behind a light pcm feature; the example and tests/scope.rs read files with it in a default build |
the example then needs required-features = ["pcm"] (or audio implying pcm and the file source behind cfg), and CI's tapes job the feature |
plugin (audio) |
Friction with the new APIs¶
- One
on_writearm per kind of source.latest_everyandlatestgiveSignal<Option<Matrix>>, a captureSignal<Option<Frame>>, so the example writes the sameon_write+ratethree times. Withon_writeseeing every write,Frame::numberis no longer needed to count; a genericlatest_iter(iter, pace)(below) and an audio source enum would make a source one value again. on_writeclones each value (here a 2×2048 matrix per buffer, whichtakethen clones again into the history). A borrowingon_write_ref(signal, |&T, cx| ..)would save both.dsp::note_hzis not scope-tui's table. It is equal temperament with octave 4 by default; scope-tui doubles octave-0 pitches rounded to the hundredth and defaults to octave 0. The example adds the0; about one note in ten (E2, F1, B1, … at 48 kHz) gives a buffer one sample off. More correct, less faithful; a test pins E2.Axis::gridstyles withAxis::style, the axis line's style too. scope-tui's axes have no lines, so it fits here; an app with a styled axis and fainter grid lines would wantAxis::grid_style.Chart::style("none")reads well once known;"none"as a magic string is easy to miss next to"".bind_scaled's help printsalt ×0.2, where scope-tui's docs say ⅕; fine, but a label per variant (("alt", 0.2, "⅕")) would let an app say it its way.audio::openmadeApp::try_newunnecessary. Opening before the app is the cleaner shape;try_newstays for builders that only fail inside.Driver::advance's 50 ms step is documented, but nothing says alatestthread is not a "thing due" (see the first bug); thelatest_everydoc example stepsdriver.updateby hand, which hides it. Round 2's "19–20 fps where scope-tui shows 21–22" was put down to a debug build; it is this cap.- The help cuts long rows.
bind_scaledappends(shift ×10, ctrl ×5, alt ×0.2), 30 cells, and the help box (75 wide at 110 columns) cuts a row rather than wrapping it, so PgUp's description had to shrink to "threshold or avg up".
Components that would help¶
Patterns written by hand from several built-ins, what scope-tui leans on, and what comparable audio and visualisation TUIs and libraries offer (cava, ratatui and its crates, Bubble Tea and Bubbles, Textual). Core entries are generic: another, unrelated component could use them.
| Component | What it would replace or enable | API sketch | Where | Value |
|---|---|---|---|---|
| Mode-scoped views | the in_mode checks in eight handlers, the shared p, and per-mode help: a page per mode whose bindings apply, and are listed under its name, only while it shows (Textual's screens' BINDINGS, Bubble Tea's per-model Update) |
modes(mode, &[(Mode::Osc, "oscilloscope", \|\| node.bind(..)), ..]) |
core (generic) | high |
| A feed with a state | the file source's end, the capture's status, --timeout for any source |
latest_with_state (see the gaps), and audio::Capture built on it |
core (generic) + plugin (audio) |
high |
| An iterator played at a pace | play: frames sent as they come or a period apart, then something after (silence) |
core: latest_iter(iter, Pace::Every(d) \| Pace::Free).then(after); plugin: audio::play(pcm, rate) on it |
core (generic) + plugin (audio) |
medium |
| A history of writes | take's per-channel VecDeques for the spectroscope's averaging; also an oscilloscope's persistence |
core: history(signal, n) -> Memo<VecDeque<T>>, n a signal; plugin: chart::Persistence (older frames fading, as an analogue scope's phosphor, xoscope, glscopeclient) on it |
core (generic) + plugin (rs-rich-ext chart) | medium |
| Bindings that toggle or step a field | ten closures of signal.update(\|g\| g.x = !g.x) and the clamped steps of nudge/samples/page |
.bind_toggle(keys, desc, signal, \|s\| &mut s.flag), .bind_step(keys, desc, signal, \|s\| &mut s.n, step, range) |
core (generic) | medium |
| A bar spectrum, cava's | a fourth view, the one most audio TUIs show: log-spaced bands, gravity fall-off, smoothing, mirrored stereo | plugin: dsp::bands(spectrum, n, lo_hz, hi_hz), dsp::Falloff::gravity(g); chart::BarSpectrum::new(bands).mirror(true); core underneath: a smoothed(signal, attack, release) numeric signal (Bubbles' harmonica springs), usable for any meter or progress |
core (generic) + plugin (rs-rich-ext dsp/chart) | high |
| A tuner readout | the note nearest the loudest peak, with cents, beside the header (guitar tuners, tuner TUIs) |
plugin: dsp::peak_hz(spectrum) + nearest_note; shown with big_text |
plugin (rs-rich-ext dsp) | low |
| Big digits | a level or frequency readout (Textual's Digits, tui-big-text) |
big_text exists behind art; a digits(text) in box-drawing glyphs with no FIGlet would suit every build |
core (generic) | low |
| A fake capture | --timeout and device failures under test (see the gaps) |
audio::Capture::simulated(..) |
plugin (audio) |
medium |
| Already there, not in scope-tui | Spectrogram (spek's, Audacity's waterfall), meter/PeakHold (every DAW), sparkline (ratatui's, Textual's), audio::picker (Bubbles' list), audio::record |
an extended scope, not this clone | plugin | — |
Round 4: files (Yazi)¶
examples/files.rs rebuilt on round 4's additions: the runtime's
settled layouts, the core's tab_set, history, multi_select,
list_search, cursor_by, on_context, seg_gauge, facts,
fuzzy_list, tabs(..).fit() and help_with, and the files plugin's
tabs, selection, Yank, find_next, hovered_caps,
BinaryPreview::FileType, open_with_tasks, Tasks::on_fail and
summary, frecency, finder_with, spot, bulk_rename, shell and
search. No framework change in the round. It went from 1,449 lines to
1,198 while gaining Yazi's search (s, S, Ctrl+S), shell (;, :),
bulk rename (r on a selection), a frecency Z kept between sessions,
per-tab history and a progress gauge: what is left is Yazi's keymap as a
table, what each key does, the create and rename prompts, the opener
choice, the confirms, the header and the status line's segments. Its
tests went from 766 lines to 992 (36 tests to 47). The example still
needs files only (required-features = ["files"]); the tape's keys did
not change, so it was not re-recorded.
Round-3 gaps now closed, and where files.rs uses them¶
| Round-3 gap or component | Now | Where in files.rs |
|---|---|---|
| Bug: a layout's work not settled before the next event (no preview in the first frame) | fixed in the runtime; files::preview is a resource_for keyed by its width |
the tests' first_frame() wait is gone; clicks_hover_and_the_parent_column_goes_there clicks the preview at once |
Bug: list options on a wrapper (file_list's switch) dropped |
fixed: options reach the list a wrapper builds | columns (hovered_caps puts the highlight under the row) |
| The hovered row's rounded ends | MillerOptions::hovered_caps |
columns, with icons on; with_icons_the_hovered_row_has_rounded_ends |
| A binary file as a hex dump | MillerOptions::binary(BinaryPreview::FileType) |
columns; moving_previews_text_highlighted_and_binary_as_file_classifies_it (data) |
Tabs and their history (~140 lines: Tab, history, new_tab, switch_tab, close_tab, swap_tab, the watch on m.cwd) |
files::tabs(m) on the core's tab_set and history |
Fm::new, run (Back, Forward, Tab*, Close); tabs_keep_their_own_history_and_fit_their_titles |
Selection and visual mode (~100 lines: selected, visual, end_visual, targets, marker) |
files::selection(m) (the core's multi_select) and Miller::targets() |
Fm::targets, run (Toggle, SelectAll, Invert, Visual, Escape), status_line, header; select_all_invert_and_unset_mode |
| Yank state and paste | files::Yank: copy, cut, markers, paste(into, conflict) |
run (Yank), paste, columns; a_selection_is_what_y_acts_on_and_is_marked_while_yanked |
find_arrow (~25 lines) |
Miller::find_next(previous) on list_search |
run (Find, FindArrow); find_previous_goes_backwards_from_the_cursor |
keep round every sort, filter and hidden toggle |
the cursor kept by path (cursor_by) |
gone; the_cursor_stays_on_its_entry_through_a_sort_and_hidden_files |
A right-click menu from cx.pointer() and row_rects maths |
MillerOptions::on_context |
columns, menu |
launch matching Launch::Extract to call Tasks::extract |
open_with_tasks |
launch (one line) |
| The status line's progress as text | seg_gauge over Tasks::summary() |
status_line |
Z over a session-only visited set, through rs-rich-interact's Jump |
files::frecency().follow(m), zoxide's ranks in the state directory |
Fm::new, zoxide; z_and_big_z_look_the_same_and_big_z_jumps_to_a_directory_visited |
| z and Z looking different | fuzzy_list_with and files::finder_with, one FuzzyOptions |
picker_look, jump, zoxide |
finder with no Esc, title or border |
finder_with(.., FuzzyOptions); Esc closes its modal |
jump (no on_key("esc") of its own) |
| Tab titles cut by hand, the unselected style as markup | tabs(..).fit(), the theme's tabs.unselected |
tab_strip, theme |
| The trash on macOS and Windows | trash::send (the Finder, the Recycle Bin) |
remove (tested on Linux only) |
| Tasks reporting a failure twice | Tasks::on_fail once; tasks_panel_with(.., failed_title(false)) |
Fm::new, task_manager; no done closure per job (Fm::job) |
| The hand-built help modal (~45 lines) | cx.help_with(HelpOptions::new().title("mgr.help").key_format(KeyFormat::Vim).filter_first(true)) |
run (Help); the keymap table lost its Vim-notation column |
| The spotter's markup lines | files::spot(path) on facts, .key_style("blue") |
spotter; tab_spots_the_hovered_file |
| Bulk rename, the shell prompt, content search | files::bulk_rename, files::shell with ShellOptions, files::search with Miller::show_search |
rename, run (Shell, Search, SearchCancel, Escape); r_on_a_selection_renames_it_in_the_editor, semicolon_runs_a_shell_command_on_the_selection, s_searches_below_here_and_esc_ends_it |
readable_path and home() |
files::path_parts, files::home_dir |
readable_path (one line) |
Newly faithful, and tested: per-tab history; file's classification of
binaries; the cursor kept through a sort or a filter; find as you type
staying on a hovered match; Ctrl+A, Ctrl+R and V; a selection yanked;
s/S search listings that Esc ends; ; and :; bulk rename;
frecency-ranked Z across sessions; Yazi's help in Vim key notation;
fitted tab titles; a progress gauge shown while tasks run, red once one
fails.
Not used, on purpose: bind_when and when (Yazi's manager keymap is
the same in every state: visual mode, a search and one tab all keep it;
a binding hidden while it does nothing would leave Yazi's help); the
MillerOptions::current/parent/preview hooks (nothing needed past
MillerOptions); Miller::visited() (frecency().follow(m) already
sees every directory, and lasts the session where there is no state
directory); Miller::navigation and the core's navigation_with (see
the gaps: unreachable from files::Miller).
New gaps and bugs¶
| Gap or bug | Needed for | API sketch | Where |
|---|---|---|---|
Bug: .autofocus() on a switch focuses the switch, not the node it builds. file_list is a switch (for its "No items"), so Miller's current column never has the keys: its own Down/PgDn do nothing, and the menu key and Shift+F10 never reach MillerOptions::on_context (only the right button does). Repro: App::new(|| miller(dir).view_with(MillerOptions::new().on_context(..))), press menu or down: no call, the cursor stays; driver.focused_name() is Some("current"). The clone binds every key on its root, so only the menu key is lost; its keyboard branch in menu is written but unreachable, and untested |
the menu key on a file; any focused wrapped list | a wrapper hands focus (and so key events) to the focusable node it built, as round 4 hands it list options; or autofocus is a list option file_list forwards |
core (generic: any switch, panel or padding round a focusable node) |
files::Miller does not forward the core's navigation_with, go or Move (its columns are private): Yazi's wording for the moves ("Previous file", "Move cursor up half page") needs them bound by hand, so the clone's own Act::Arrow/Page/Top/… stay |
j/k, h/l, Ctrl+U/D, PgUp/PgDn with Yazi's descriptions | files::Miller::navigation_with(node, &[(Move, keys, desc)]) and go(Move), or Miller::columns() -> miller::Miller<_> |
plugin (the core has it) |
Selection and visual mode are one for all tabs. Yazi keeps a mode and a selection per tab: v in tab 2 then 1 shows tab 1 in normal mode, and tab 2 still selecting. The clone cancels visual mode when the active tab changes (a watch in Fm::new) |
per-tab selection and mode | core: tab_set's state holding any value per tab already works; what is missing is a MultiSelect that can be swapped under the columns (multi_select().scoped(key): one selection per key, here the tab); plugin: FileTab::selection, with files::selection following the active tab |
core + plugin |
A status segment cannot hide: seg_gauge always draws its cells, but Yazi's progress shows only while tasks run, so status is a switch between two status bars |
Yazi's progress, any optional segment | StatusSegment::when(\|\| bool), a segment (and its caps) left out while false |
core |
tabs_for and FileTabs::strip() give a title with no index: Yazi numbers its tabs (1 src), so tab_strip builds the titles from set.tabs() itself |
Yazi's strip | tabs_for_indexed(&set, \|i, t\| title), or tabs(..).numbered() |
core |
find_next starts after the cursor. Yazi's find as you type stays on the hovered entry when it matches; the clone tests the hovered name with smart_contains before calling it |
/ and ? |
ListSearch::next_from(here: bool); Miller::find_next_from(previous, here) |
core + plugin |
A search's listing is not a level: h in it goes up from the directory searched, where Yazi (whose search is a virtual folder) goes back to it; the same would serve bookmarks and "recent" listings |
h and l in a search, a bookmarks listing |
core: a level overlay on miller_of (m.push_level(name, items), popped by leave, its items opening as their own keys); plugin: show_search as such a level |
core + plugin |
The core help filters as you type; Yazi's moves with j/k and filters after / |
Yazi's help keys | HelpOptions::filter_key("/"): until it, letters move (j/k, gg, G) |
core |
facts styles its keys only: Yazi's spotter has yellow values |
the spotter's look | facts(..).value_style(..) (theme facts.value) |
core |
Yazi's links: - and _ symlink the yanked files (absolute, relative), Ctrl+- hardlinks them; there is no operation for it |
three Yazi keys missing from the clone | Op::Link { from, into, kind: Symbolic { relative } \| Hard } in rs-rich-ext's file_ops, Yank::link(into, kind) -> Op |
plugin (rs-rich-ext + files) |
| Two sessions' frecency stores clobber each other: each app writes its own copy whole, so the last to visit wins (the tests, which run side by side, show it) | Z across several Yazi windows | merge on write: read, add this session's visits, write (zoxide's approach); see the state store below | plugin, on a core state store |
Tasks::summary() is read three times a frame by the gauge (share, label, style), each a pass over the jobs |
the gauge | a Memo from summary_memo(), or seg_gauge_of(memo, …) |
plugin |
Tasks::on_fail's job carries its errors in JobState::Failed(errors), so every handler matches on it |
the notification | Job::errors() -> &[String] |
core (jobs) |
Friction with the new APIs¶
- Prompts repeat their look. Yazi's inputs have plain titles; each
prompt_withtakesinput_look(), andShellOptions::prompthas to be given it again. An app-wide default (App::prompt_defaults(PromptOptions)) would let a clone set it once. Core. - The menu from the keyboard needs
row_rects.on_contextsays "then below the hovered row" but leaves it to the app:menumatchescx.pointer()androw_rects().get(cursor)to choose betweencontext_menuandopen_menu. Acontext_menu_at(cx, fallback_rect), or anon_contextthat passes the item's rect, would do it. selectionandYankmark in two ways. The selection is drawn by the columns becauseselection(m)stores itself in theMiller(made beforeview_with, or it is not marked); the yank is aMillerOptions::markersclosure. Either way works, but the order dependence is silent:selectionafterview_withmarks nothing.- Visual mode before an action.
Miller::targets()counts a visual range but does not end it, so every action callsend_visual()first (Fm::targets), as Yazi ends visual mode ony,x,d. - The parent column's marks are re-derived by path inside
files::Millerbecauseselection_markersreads the visual range by index of the current column; the files agent's "marks by key" (aMultiSelect::mark(&key)marker any list showing the same keys can use, no cursor) would make that one call. files::Miller's state is still half fields, half methods (m.cwd,m.find,m.current.filterbutm.searching(),m.visited(),m.row_rects()); the coreMillermoved to methods in round 4, the plugin did not.tests/files.rsstill needs#![cfg(feature = "files")]by hand for an example it includes by path.
Components that would help¶
| Component | What it would replace or enable in files.rs | API sketch | Where | Value |
|---|---|---|---|---|
Edit text in $EDITOR |
bulk_rename's temporary file, handoff and read-back; also "edit the command" in : and a commit-message box anywhere (Bubble Tea's tea.ExecProcess, Textual's App.suspend) |
cx.edit_text(initial, \|edited: Result<String>, cx\| ..); files::bulk_rename on it |
core (generic) | high |
| App state files | frecency's private state_dir and atomic write; tab sessions, prompt history, a last directory (Yazi keeps its state in ~/.local/state/yazi) |
state_dir(app_name), write_state(name, bytes) (temporary file renamed over), read_state(name); merge-on-write with a closure |
core (generic) | high |
| A generic frecency store | files::frecency keyed by paths; a command palette's recent commands, a launcher's apps |
frecency::<K: Serialize>(name) with visit, ranked, remove, aged as zoxide; files::frecency on it |
core + plugin | medium |
| A level overlay on Miller | h leaving a search to the parent; bookmarks and "recent" as listings (Yazi's search is a virtual search:// folder) |
miller.push_level(title, items) popped by leave; files::Miller::show_search and a files::bookmarks on it |
core + plugin | medium |
| Jobs running a process | files::shell's background command and extraction each spawn and watch a child by hand; Yazi's task manager shows a shell task's output (Enter) and kills it |
jobs.run_process(title, Command, done): cancel kills it, its output kept for an inspect view |
core (generic) + plugin | medium |
| Kitty, Sixel and iTerm images | half blocks only (ratatui-image's protocols; Yazi's main draw) | the paint step placing Surface::graphics; image(path) choosing the protocol the terminal answers |
core paint + plugin (rs-rich-art) | high |
| Input completion | Yazi's cmp pop-up under g Space (paths as you type); Textual's Suggester, Bubbles' textinput.SetSuggestions |
core: PromptOptions::complete(\|text\| Vec<String>) with a pop-up list; plugin: files::path_completer(cwd) |
core + plugin | medium |
| Prompt history | Up/Down in Yazi's inputs (find, filter, shell); Bubbles' and Textual's inputs keep one | PromptOptions::history(key), kept in the state store |
core | medium |
| Per-tab state, generically | the selection per tab (above); any app with tabs of documents | tab_set with scoped values (tabs.scoped(\|\| multi_select())), switched with the active tab |
core | medium |
| Links | -, _, Ctrl+- |
Op::Link and Yank::link |
plugin | medium |
| Optional status segments | the switch between two status bars |
StatusSegment::when(..) |
core | low |
| A file picker | not this clone; any app that asks for a file (Bubbles' filepicker, Textual's DirectoryTree) |
files::pick_file(cx, start, filter, \|path, cx\| ..) on miller_of or the core tree |
plugin | medium |
| PDF and video previews | Yazi's previewers through pdftoppm and ffmpegthumbnailer |
Previewer::command(mime_glob, argv) producing an image or text |
plugin | low |
Round 4: scope-tui¶
examples/scope.rs
rebuilt on round 4's runtime, widgets and sound pieces, with no framework
change in the round and no hand-made stand-in left in the example. Its
hand-checked in_mode handlers, the page dispatcher, ten flag closures,
the clamped debounce steps, the quoted punctuation keys, the hand-paced
file thread, the per-channel VecDeque history and the equal-temperament
tune are gone; it is 1,274 lines where round 3's was 1,323, and more
faithful: each scope's keys apply, and are listed, only while it shows
(p is peaks in one and the phase difference in another), --tune picks
scope-tui's buffer sizes sample for sample, and the frame rate is the
buffers' rate, as scope-tui's (-b 2048 23 fps, -b 1024 47, -b 4096
12, on the app's clock and in a terminal). tests/scope.rs (853 lines,
was 741) runs on the app's clock with no hand stepping and checks the new
behaviour (29 tests; 30 with audio).
Round-3 gaps now closed, and where scope.rs uses them¶
| Round-3 gap or workaround | Now | Where in scope.rs |
|---|---|---|
| Bug: values from other threads picked up at most every 50 ms (20 fps whatever the buffer; tests stepped the driver by hand every 10 ms) | the run loop is woken by a Proxy send, and the headless Driver::timeout steps to the next latest thread asleep on the app's clock. The tests' step is gone: driver.advance(2.1 s) reads 23, 47 and 12 fps at -b 2048, 1024 and 4096. In a pty, a debug build of the example shows 24, 47, 12 and 89 (-b 512), where scope-tui's arithmetic gives 23.4, 46.9, 11.7 and 93.8 |
the_frame_rate_is_a_buffers_worth_of_time_as_scope_tuis, every timed test |
Bug: grid lines shared one layer (⣸ where scope-tui draws ⢸) |
a layer a grid line, the y axis's first | chart; pinned by reference_lines_are_drawn_as_scope_tuis_datasets_are |
| Bug: a grid with no datasets was drawn in dots | Chart::marker(marker): the zero line is Braille (or dots with --no-braille) before the first buffer as after |
chart |
Bindings active only in a state (t, e, =, w, l bound and listed in every scope; one p with one description) |
.modes(mode, &[(Mode::Oscilloscope, "oscilloscope", &oscilloscope_keys), (Mode::Spectroscope, "spectroscope", &spectroscope_keys)]): two ps, two pageups, two escs; the general esc is bind_when(mode != Oscilloscope), so each scope's help lists one |
scope_app |
| Sections for every kind of binding | in_mode's section (under modes) lists the modes' bind_scaled and bind_step bindings under the scope's name |
oscilloscope_keys, spectroscope_keys |
| Named punctuation keys | equals, minus, plus, underscore; the help shows =, -, +, _ |
oscilloscope_keys |
| Testing a capture's status | Capture::simulated(frames, period) with stall() and fail(why): --timeout and a failure are tested; the test signal is itself a simulated capture, so it and a sound card are one Input::Capture |
demo, a_capture_that_stalls_ends_the_app_as_scope_tuis_timeout, a_capture_that_fails_ends_the_app_saying_why |
| A thread feed with a state | latest_with_state under Capture::status(), a FeedState; the file source has none (see the gaps: audio::play gives no state, and scope-tui needs none) |
scope_app |
audio::pcm behind pcm |
required-features = ["pcm"], unchanged; Capture is now pcm's, so Input has no cfg(feature = "audio") arm |
Input |
One on_write arm per kind of source (three) |
two: a capture's Frame and a file's Matrix |
scope_app |
on_write cloned each buffer |
on_write_ref lends it; one copy is made, into the buffer shown (but see recent below) |
take |
dsp::note_hz was not scope-tui's table (E2 a sample off) |
dsp::NoteTable::ScopeTui.hz(note), octave 0 by default: E2 at 48 kHz is 583 samples, as scope-tui's; the hand-added octave is gone |
tune, tune_reads_notes_as_scope_tui_does_on_rich_exts_pitches |
Axis::grid styled with Axis::style |
Axis::grid_style(axis colour) for the reference lines; Axis::style stays, as scope-tui styles its axes |
chart |
Chart::style("none") for the legend |
Chart::legend_unstyled() |
chart |
bind_scaled's help said alt ×0.2 |
labelled variants, ("alt", 0.2, "⅕"): scale up (shift ×10, ctrl ×5, alt ⅕), as scope-tui's docs |
MAGNITUDES |
| The help cut long rows ("threshold or avg up") | it wraps; with the keys per scope the descriptions say one thing each ("threshold up", "average more") | oscilloscope_keys, spectroscope_keys |
Ten signal.update(\|g\| g.x = !g.x) closures, the clamped steps of the debounce and the averaging |
bind_toggle(keys, desc, signal, \|s\| &mut s.flag) (ten flags), bind_step(keys, desc, signal, field, step, 1..=65535) (the debounce's four keys, the averaging's) |
scope_app |
A history of writes (take's per-channel VecDeques) |
recent(frame, averaged), averaged a memo of the spectroscope's average; spectroscope takes the buffers, oldest first |
scope_app, spectroscope |
An iterator played at a pace (play's thread) |
audio::play(pcm, rate) with -l (latest_iter at Pace::Every, then silence); a pipe is latest_iter(pcm, Pace::Free) |
play |
Not used, on purpose, as in round 3: live, audio::record/wav,
audio::picker, the viz plugin's meter, sparkline, hold,
spectrogram and bar_spectrum, chart::Persistence, digits,
dsp::peak_hz/nearest_note, smoothed, status_bar and key_hints:
scope-tui has none of them. Not reproduced, also on purpose: scope-tui
0.3.5's file source reads buffer × channels bytes a buffer, half a
buffer of s16 frames a channel (its trace fills half the width), and
paces -l by whole milliseconds of that; and its spectroscope keeps its
averaging history only while it shows. The clone reads a whole buffer a
channel at its time, and averages the last buffers shown in any scope.
New gaps and bugs¶
| Gap or bug | Needed for | Repro or API sketch | Where |
|---|---|---|---|
| The help cuts a long key list instead of wrapping it | a binding with several keys: bind_step("pageup shift+pageup ctrl+pageup alt+pageup", ..) is listed pgup/shift+pgup/ctrl+pgu |
the key column wraps as the description does, or is as wide as its widest entry up to a share of the box | core (generic) |
| A binding whose modifiers do not matter | scope-tui's spectroscope steps the averaging by one on PgUp with any modifier; the clone binds pageup and, with an empty description (hidden), shift+pageup ctrl+pageup alt+pageup |
.bind_step(..).any_modifiers(), or shown keys for every kind of binding, as bind_scaled has |
core (generic) |
| A step scaled by the modifier | ← →, ↑ ↓ and PgUp/PgDn step a clamped field by a base step times shift ×10, ctrl ×5, alt ⅕; bind_scaled gives the factor but not the field, so samples and threshold are hand-written clamps (and ← → truncate the step to whole samples) |
bind_step_scaled(keys, &MAGNITUDES, desc, signal, \|s\| &mut s.n, base_step, range); an integer field rounds (or truncates, as scope-tui) the step |
core (generic) |
| A played feed with a state | an app that shows "end of file" (scope-tui does not: it pads with silence for ever, so the clone needs none) | latest_iter(..).with_state() -> (Signal<Option<T>>, Signal<FeedState>), Ended when the values run out (before then's), and audio::play returning it |
core (generic) + plugin (audio) |
| A tail at its own pace | a pipe read as fast as it comes, then silence a buffer's time apart: then keeps Pace::Free, a flood, so the clone writes then_with(\|tx\| while tx.send(silence.clone()) && tx.sleep(period) {}) |
.then_at(Pace::Every(period), iter) |
core (generic) |
Pcm drops read errors |
scope-tui ends on a read error with "data source returned null"; audio::pcm treats an error as the end, so the clone pads with silence instead |
Pcm::error() -> Option<&io::Error>, or a Result item; audio::play with a state then Failed(why) |
plugin (audio) |
| A capture that never stalls | scope-tui's pulse source blocks and has no timeout; the clone's ends after the default second without a buffer |
AudioOptions::stall_after(impl Into<Option<Duration>>) and Capture::stall_after, as latest_with_state takes |
plugin (audio) |
recent clones every write |
the spectroscope's history copies each buffer a second time (a 2×2048 matrix a frame), on top of the copy into the buffer shown | recent on on_write_ref, cloning once, or a recent_of(source, n, \|&T\| -> U) that keeps a projection |
core (generic) |
Friction with the new APIs¶
- A field picker kept in a variable does not compile.
let average = |s: &mut Spectroscope| &mut s.average;passed to twobind_steps fails with "lifetime may not live long enough": a closure's return does not take its argument's lifetime. The clone writesfn depthandfn average. Afield!(Spectroscope, average)macro, or the docs saying "use afn", would save the detour. - A name for a memo and a field picker clash quietly.
averagethe memo andaveragethefngave a type error namingMemo<usize>; not the API's fault, butrecent(signal, n)taking a closure (|| spec.with(..)) as aCountwould make the memo unnecessary. - Empty descriptions hide bindings.
""is still the way to keep a binding out of the help, as"none"was for the legend's style; Textual'sBinding(show=False)names it:.hidden(). modeswants&dyn Fn(Node) -> Node. The pages are closures kept in variables (oscilloscope_keys), which reads well; the type (ModePage) only shows when inference fails.- One key, two scopes, one handler each. scope-tui hands a key to the
app and then to the scope showing (Esc resets the view and stops the
trigger); a binding's key runs one handler, so the oscilloscope's Esc
calls the shared
resetitself and the general Esc isbind_when(mode != Oscilloscope). Fine, but a "fall through" binding (.and_then_parent(), Bubble Tea's message passed on) would say it directly. - Headings in the help have no hook for tests. A test finds a section
by
│nameat the box's edge, since a description may hold the same word ("oscilloscope, vectorscope, spectroscope"). Capture::simulatedfor a demo signal. It fits (a source with a status, the same arm as a sound card), but its name says tests; aCapture::generated(iter, period)alias, or the one source type below, would read better.
Components that would help¶
What scope-tui leans on, what the sound and chart work left open, and what comparable audio and visualisation TUIs and libraries offer (cava, ratatui and its crates, Bubble Tea and Bubbles, Textual). Core entries are generic: another, unrelated component could use them.
| Component | What it would replace or enable | API sketch | Where | Value |
|---|---|---|---|---|
| One audio source type | the two on_write_ref arms and the Input enum: a capture, a file, a pipe and a generator as one value with samples and a status |
core: a Feed<T> value (samples(), status()) that latest_with_state and latest_iter(..).with_state() return; plugin: audio::Capture::from_pcm(pcm, rate), Capture::generated(iter, period), Capture::from_feed(feed), all a Capture |
core (generic) + plugin (audio) |
high |
| A wait the test clock counts as asleep | a feed thread blocked on a condition (a device's condvar, a socket) is busy to the headless clock, so a sound card's feed polls every 100 ms and only simulated's sleep(period) is stepped exactly; wait_for would let any feed be tested on the app's clock |
Latest::wait_for(\|\| cond, timeout) -> bool, notified by Latest::notify(), on the app's clock under wait_for_tasks |
core (generic) | medium |
| Scaled steps | samples, threshold and nudge: a clamped field stepped by the modifier's factor (ratatui apps' and scope-tui's shift ×10) |
bind_step_scaled(keys, variants, desc, signal, field, step, range) |
core (generic) | medium |
| Falling values | a peak or a level that falls with gravity after it rises (cava's bars, a meter's peak, a notification badge's decay); dsp::Falloff is a plain struct in rich-ext |
falling(signal, gravity) -> Memo<f64>, on the app's clock, beside smoothed (Bubbles' harmonica springs) |
core (generic) | medium |
Ages in recent |
persistence that fades by time, not by count (a phosphor's decay, a toast list, a log's recent lines) | recent_with_ages(signal, n) -> Memo<VecDeque<(Duration, T)>>, or recent(..).aged() |
core (generic) | low |
| A persistence or oscilloscope view | the oscilloscope's plot builders (trigger, peaks, zero line) for any scope app, and an analogue scope's phosphor (xoscope, glscopeclient) | plugin: viz::oscilloscope(\|\| frame).trigger(..).persistence(n) over chart::Persistence; generic core under it: recent (with ages) |
plugin (viz) |
medium |
| A tuner readout | the note nearest the loudest peak, with cents (guitar tuners, tuner TUIs) |
plugin: viz::tuner(\|\| spectrum) on dsp::peak_hz and nearest_note, drawn with the core's digits |
plugin (viz) |
low |
| A help that wraps keys | the hidden modifier bindings above, and any binding with several keys | the help's key column wrapping (Bubbles' help full view, Textual's key panel) |
core (generic) | low |
| Bindings that fall through | scope-tui's Esc, the app's and then the scope's | .bind_through(keys, desc, f): runs, then passes the key on (Bubble Tea's Update returning the message to a child) |
core (generic) | low |
| Hidden bindings by name | "" descriptions |
.bind_hidden(keys, f), or .hidden() after a binding (Textual's show=False) |
core (generic) | low |
| Already there, not in scope-tui | viz::spectrogram, viz::bar_spectrum (cava's bars, dsp::bands, Falloff::gravity), meter/hold, sparkline, digits, smoothed, audio::record |
an extended scope, not this clone | plugin | — |
Plugins¶
The user's decision for this round: visualisation and recording are
plugins, not core. The viz feature is the home of the views of sound and
signal data: meter, sparkline, the peak hold (PeakHold, hold), the
spectrogram and the bar spectrum, and later persistence and an
oscilloscope view. The wav feature is the home of recording
(audio::record, wav_writer). The core keeps only what any app could
use under them: recent, smoothed, latest_with_state, latest_iter,
digits, rate.
Round 5: nine new apps¶
Round 5 cloned apps unlike a file manager and an audio scope, each built on
intuiTUIve as round 4 left it (no framework change during the round), each
with its own report in docs/design/gaps/:
| App | Clone | Lines | Tests | Report |
|---|---|---|---|---|
| gitui (git client) | examples/git.rs |
2,219 | 21 | git |
| bottom (system monitor) | examples/top.rs |
2,706 | 25 | top |
| slumber (HTTP client) | examples/http.rs |
2,088 | 20 | http |
| rata-tetris (game) | examples/blocks.rs |
919 | 15 | blocks |
| csvlens (CSV viewer) | examples/csv.rs |
1,534 | 14 | csv |
| diskonaut (disk treemap) | examples/disk.rs |
1,395 | 21 | disk |
| termusic (music player) | examples/music.rs |
1,556 | 17 | music |
| taskwarrior-tui (tasks) | examples/tasks.rs |
2,582 | 27 | tasks |
| a raycaster (Doom-style) | examples/raycast/ |
2,924 | 26 | raycast |
None draws a widget of its own except the game's playfield and the raycaster's 3D view. The round-5 core and plugin work (below the round-4 sections' backlog) landed alongside; the clones do not use it yet.
Gaps shared across apps¶
What several clones hit independently leads round 6. Each is generic core unless marked.
| Gap | Hit by | Shape |
|---|---|---|
| Panel titles and border styles that follow state | git, top, http, music | panel_with(\|\| markup), a focused border kind |
| Show or hide a node by a signal; sizes that follow state | git, top, http | .shown_when(\|\| ..) (today's private shown_if), .size_with(\|\| ..) |
Focus a page a switch has not built yet; read where the focus is |
git, top, http, tasks | cx.focus_within(id), focus_scope, on_focus / focus_within signal |
A node rebuilt from state that drops its old child (switch keeps every page) |
disk, csv, tasks | keyed(key, build) |
| Tables: reactive options and columns, marker column, keyed cursor, offset, widths in percent, row heights | csv, tasks, music, top | signal-taking TableOptions, marks_by/selection_markers on tables, cursor_by, offset_x, Column::max_percent, row_height_for |
| A tree keyed by item, with reveal and a prefix | git, music | tree_keyed, reveal(key), TreeItem::prefix |
| Range selection on a list | git | list(..).range(anchor) |
| Directional (spatial) focus and selection | top, disk | move to the neighbouring rectangle by direction |
| Input completion pop-up, ghost text and history; a command line | tasks, http | input(..).complete(..), history(..), command_line(..) |
| A query language over records | tasks, top, csv | query::parse over a Record trait; syntaxes as plugins |
| Timers with a handle (pause, rate, cancel), catch-up; key repeat control; key releases for bindings | blocks, raycast | every(..) -> Timer, fixed step with catch-up, DAS/ARR, bind_release |
key_hints formats; whole-app help |
git, music | key_hints_with(..), HelpOptions::all_panes() |
| Treemap layout | disk | layout::squarify(rect, weights, ..) |
| Natural dates and local time | tasks | date parsing ("tomorrow", "eow"), a local offset that follows DST |
App::min_size guard |
disk, blocks | a too-small screen by default |
| A process as a job, with progress and cancel | git, http | round 5's Jobs::run_process (landed) |
| App state files | blocks, tasks | round 5's state module (landed) |
| Plugins: git model, HTTP client, audio player and decoders, tags, disk usage, system metrics, data sources, Taskwarrior | one each | plugin |
Bugs found: keys typed while the terminal ignores the start-up query are
lost for up to 3 s (blocks); tables and lists treat Shift+↑/↓ as ↑/↓ (top);
an .auto() switch ignores its page's .fixed()/.min_size() (top);
Driver::event does not settle as press does (top); a full-size layer
paints over a fill beneath it (blocks); upstream's Text.markup is not
ported (csv); the app loop handles one input event per frame, so queued
keys spread out over slow frames: drain pending input before drawing
(raycast); rs-rich-ext's Screen style table and the painter's code cache
only grow, so a full-screen truecolour framebuffer leaks styles every frame,
and interning hashes with SipHash (33 of 41 ms a frame at 160x50): compact
styles between frames, a fast hash and a put_rgb path (sprites, raycast).
Sprites landed during the round (Sprite, stage, pixels/PixelBuf,
sprite sheets behind art, micro assets behind micro); blocks and raycast
use them from round 6.
Real Doom (rs-rich-doom)¶
rs-rich-doom (GPL-2.0,
a separate repo: the engine is GPL) runs doomgeneric on an intuiTUIve
latest thread and draws its 320x200 frames in half blocks, Braille, Kitty,
Sixel or iTerm2 at 33–35 fps (release, 80x24 and 160x50), with sound effects
through cpal. What it needed from rs-rich, beyond the raycaster's list:
| Gap | Shape | Where |
|---|---|---|
Painter::paint clones the whole style table every frame and it never shrinks: averaged pixel colours made a frame 50 ms at 80x24 (nearest-neighbour sampling, keeping Doom's 256 colours, brought it to 4 ms) |
drop unused styles between frames, or pixel cells that skip interning | rs-rich-ext surface (core) |
DrawCx has no public screen rect, so a widget emitting graphics guesses where it is |
DrawCx::rect() (as EventCx has) |
core |
Graphics output for a pixel buffer is hand-rolled (PlacementSource, shared state) |
the pixels node drawing Kitty/Sixel/iTerm2 itself |
art plugin |
| Key state: modifiers alone are dropped; whether releases are reported is unknown to the app | kitty flag 8 opt-in, held_keys, releases_reported() |
rs-rich-interact (core) |
Frame pacing and stats: no target-fps mode that skips frames; App::stats unreachable after run() |
every(..).target_fps(n), frames drawn in FrameStats, stats returned by run |
core |
| Encoders: indexed-palette Sixel is crate-private; Kitty has PNG only and no direct placement id | public palette Sixel, Kitty raw RGB (f=24/32, zlib), placement ids |
rs-rich-art |
A git dependency on rs-rich-cli prints an "invalid character" error from templates/intuituive-app/Cargo.toml ({{project-name}}) |
exclude the template from cargo's package discovery (rename the manifest, or a workspace exclude) |
repo |
| Music | MUS→MIDI and an OPL/MIDI synth | plugin |
Round 7: the nine apps rebuilt on round 6¶
Each clone was rebuilt on round 6's components, without library edits. Each
report in gaps/ has a "Round 2" section with the details.
| Clone | Example lines | Tests | What replaced hand-built code |
|---|---|---|---|
| git (gitui) | 2,219 → 2,204 | 21 → 23 | tree_keyed with status prefixes, list_rows(..).range, shown_when/size_with layout, on_focus, key_hints_with, keyed |
| tasks (taskwarrior-tui) | 2,582 → 2,412 | 27 | query::Grammar filters (adds or, not, brackets), dates::parse_with, Zone, command_line_with(two_rows) with completion and history, one reactive report table, year_view_with |
| http (Slumber) | 2,088 → 2,026 | 20 → 22 | one tree with shown_when/size_with, focus_within, focus_border, panel titles from state, tree_keyed/tree_reveal, Timer pause/resume, input history |
| disk (diskonaut) | 1,395 → 1,192 | 21 | layout::squarify, focus::neighbour, fit_text/truncate_middle, keyed, App::min_size |
| top (bottom) | 2,706 → 2,415 | 25 | focus scopes with directional focus, shown_when instead of a second copy of each widget, columns_with with priority, query::Grammar search, title_with |
| csv (csvlens) | 1,534 → 1,375 | 14 | TableSource with view_of (keep, find, sort), a reactive table with frozen columns and multiline_when, command_line with shared history, Text::markup() |
| blocks (rata-tetris) | 919 → 959 | 15 → 18 | fixed_step with catch-up, DAS/ARR .repeat, App::min_size, one backdrop fill layer, state_file, sprites on a stage |
| raycast | 2,924 → 2,891 | 26 → 28 | fixed_step, on_key_up and the release report, all_keys, pixels_fit, DrawCx::rect(), keyed with on_unmount; the Kitty-mode and held-key bugs are fixed |
| music (termusic) | 1,556 → 1,512 | 17 → 20 | a transport play head, panel_with, tree_keyed/tree_reveal, symbol_for, keyed/shown_when, HelpOptions::all_panes |
blocks grew by 40 lines. Its model is unchanged; the extra lines are the repeat rates and the sprite builder.
Bugs found¶
| Bug | Found by | Workaround in the clone |
|---|---|---|
Closing a modal gives the focus back to the node focused before a cx.focus(x) made just before it opened |
tasks | focuses the report after the modal closes |
A styled layers node does not cover the gaps its inset children leave |
blocks | a fill as the first layer |
layout::squarify gives the largest item no tile when the rest's tile is under the minimum width |
disk | 12 lines |
A previews_keys container takes the letters typed into a command_line inside it |
csv | the line sits outside the container |
Each has a minimal failing test in its report. All four are fixed in
0.0.21 (2a36216), their tests in
crates/rich-intuituive/tests/round7_bugs.rs, the workarounds gone.
Performance¶
view_of made csv's million-row filter slower: 3.3 s → 15.4 s in the
unoptimised test build. About 8 s goes on reading each row through
TableSource::row(i), and about 7 s on building a Text per row for the
regex. Needed:
- a bulk read for passes;
- a compiled matcher on
&strin rs-rich-ext.
Both are in 0.0.21 (ee8c474): TableSource::rows and
rich_ext::transform::Pattern; the filter takes 3.8 s.
Gaps shared across apps¶
| Gap | Apps | Kind |
|---|---|---|
| Panel titles flush with the corner (ratatui's look), and plain-text titles | git, music, http | core: a title option. Done in 0.0.22: title_flush, panel_text |
| A tree table (a table whose rows nest) | top, tasks | core. Done in 0.0.22: tree_table, tree_table_rows |
| A treemap that joins its tiles' borders and keeps a selection | disk | core |
| Held keys that fall back on a latch where releases are not reported | raycast | core: held_keys_with |
App::min_size with a node of its own, and shorter forms of the message |
blocks, disk | core. Done in 0.0.22: min_size_with, min_size_forms |
A timer that runs while a condition holds (running_when) |
blocks | core |
A stopped state on Transport; follow_time for a time-keyed line |
music | core |
Command-line reads (is_typing, is_asking), a closed-bar look, mode titles from state |
tasks, csv | core |
Keys that submit a command line (<n>G) |
csv | core |
Directional-focus binding descriptions, tie-breaking in neighbour, scope cycling past hidden panes |
top, disk, http | core. Scope cycling done in 0.0.22: focus_scope_reveal; the rest is open |
A glyph checks column on tables, column_highlight from state |
http, csv | core |
| A scroll offset kept across a resize | http | core |
Real pixels on the pixels node; PixelBuf::copy_rgb |
raycast | plugin (art) |
Unit words in a grammar (1 gib), TextMatch on comparisons, Zone re-exported |
top, tasks | core (query, dates) |