intuiTUIve gaps: termusic¶
Round 1: termusic¶
examples/music.rs
rebuilds termusic, the terminal
music player (tui-realm on ratatui), on intuiTUIve with no framework
change. It needs the crate's wav and art features. It is 1,556 lines
(after rustfmt): about 560 are the domain (the library scan with
termusic's filters and alphanumeric order, a WAV LIST/INFO tag reader
through std, an .lrc parser with termusic's caption rule, the playlist
and transport state as signals in a Copy struct), and the rest the
panes, the search pop-up and the keys. Its tests
(tests/music.rs, 508
lines, 17 tests) write a library into a temporary directory for each test
(16-bit WAV files written byte by byte, two with an INFO chunk, a
cover.png, an .lrc file, a fake .mp3, a hidden file and a text file),
drive the app with termusic's keys, play on the app's clock
(wait_for_tasks(true), Driver::advance) and assert on the rendered
text and, for the current lyric line, a cell's style.
spotify-player was not chosen: it needs a Spotify account and the network. Its views beyond termusic's are listed at the end, with what they would need.
What is there, with termusic's layout, strings and default keys:
- The tree view: the library on the left third, and on the right the
playlist over the 3-row progress gauge and the 4-row lyrics pane, the
footer (
Help: <control+h> Config: <shift+C> Library: <1> …) on the last row. Rounded borders, blue while focused, the highlight in light yellow. - Library: a tree of the music folder (folders and the extensions
termusic plays, hidden files left out, alphanumeric order).
ladds a track (or opens a folder),La folder's tracks,ddeletes (asked first, and the playlist follows),y/pmove a file, Enter opens a folder as the root and Backspace goes back up with the old root selected,o/a/Acycle, add and remove roots,/searches. - Playlist: termusic's table (a marker column with 🚀 on the cursor
over ► on the playing track,
[ 03:25 ], the title in bold, artist, album) and its title,─ Playlist ──┤ Total 2 tracks | 00:10 | Mode: 🔁 ├─.lplays,d/Ddelete one or all,rshuffles,mcycles the loop mode in termusic's order (🔁 → ⮕ → 🔂 → 🔀),K/Jmove a track,sadds 20 random tracks,Sa random album,/searches. - Now playing: the gauge's title
Status: Running | Volume: 30 | Speed: 1.0 | Gapless: Trueand its label00:02 - 00:06(--:--stopped), rich-ext'sFillGauge(ratatui'sGauge) under it. Space pauses or starts,n/Nnext and previous,+/-volume by 5,0restarts,f/bseek 5 s, Ctrl+F/Ctrl+B the speed, Ctrl+G gapless. The end of a track goes on by the loop mode: the same track, the next (round), the next until the last, or a random one. - Lyrics: an
.lrcbeside the track ([mm:ss.xx], several stamps a line,[offset:]), the pane titledLyrics of Artist - Title, the current line lit and kept in view as the clock moves, with termusic's two seconds of lead;F/Bmove them a second;Stopped.andNo lyrics available.as termusic says them. - The cover: the first
jpg/jpeg/pngin the track's folder (notartist.*), drawn in half blocks over the playlist's bottom right corner, where termusic puts it; Ctrl+Shift+arrows move it, Ctrl+Shift+ PgUp/PgDn size it, Ctrl+Shift+End hides it. - Search (
/): termusic's general search, a box 65% by 68% withSearch for: (support * and ?)over the results (idx,File namefor the library;Duration,Artist,Titlefor the playlist),*and?wildcards without case; Enter in the box goes to the results, Enter there locates the item (the tree opens to it),lloads or plays. - Help (Ctrl+H, by section), quit (
q/Esc, asked), save playlist (Ctrl+S, an.m3uin the root), errors as notifications.
Playback is simulated. intuiTUIve's audio plugin captures but has no
output, so the transport is a 100 ms every on the app's clock, scaled by
the speed. A WAV is opened with audio::wav for its length and to check
that it decodes; other formats are listed and queued, and playing one
says No decoder for .mp3 files. Tags come from a WAV's INFO chunk
(parsed with std, since hound skips it), else from the file name
(Artist - Title). Volume, speed and gapless are shown and change the
clock (speed), but there is nothing to apply them to.
Left out: sound itself, decoders other than WAV, ID3/Vorbis/MP4 tags and
embedded covers and lyrics, the database view (2, SQLite of tags), the
podcast view (3, RSS and downloads), YouTube search and download, the
tag editor (t), the config editor (C), the sort pop-up (,), lyric
download and the lyric frames switch (T), themes and the config file,
MPRIS and Discord, the client/server split (termusic's TUI talks to a
termusic-server over gRPC), and the cover through Kitty, Sixel or
ueberzug. 2, 3, C, t, s (library) and , say they are not in
this clone.
What is bespoke, and why:
- No widget of its own. Every pane is a core node:
tree_with,table_with,list,renderable(FillGauge),image_of,layers,input,cx.modal,cx.confirm,cx.prompt,cx.notify,cx.help. - Titles that change (
titled, 10 lines): a panel's title is fixed when it is built, so each pane is alayersof the panel with an empty title and atextplaced on its top row (place(align, Start),inset(0, 1, 0, 1)). It looks as termusic's titles do, but it is not the panel's title (not its accessible name, not inborder.focused). - The data source: the RIFF
INFOreader (40 lines) and the.lrcparser (40) are what a tag plugin and a lyrics plugin would hold; the wildcard matcher (15) is termusic's search rule.
New gaps and bugs¶
| Gap or bug | Needed for | API sketch or repro | Where |
|---|---|---|---|
No audio output: audio opens inputs only (audio::input, audio::open, devices lists capture devices) |
playing anything: termusic's whole point; the clone simulates the clock | plugin (audio): audio::output(OutputOptions::new().device(id).rate(r).channels(c)) taking Matrix buffers, audio::output_devices(); and a player on it: audio::player(source) with play, pause, seek(d), volume(f), speed(f), gapless, a position() signal, an on_end hook |
plugin, over the core transport below |
No transport on the app's clock: audio::play and latest_iter play an iterator at a pace but cannot pause, seek or change rate, and a handler cannot read the clock (Ctx has no now(); only DrawCx::now) |
the position, pause, seek, speed and the end of a track; the clone adds 100 ms a tick and checks the end itself | core: let t = transport(length): t.position() (a signal), play/pause/seek(d)/rate(f), t.on_end(\|cx\| ..), on the app's clock (a test advances it); and cx.now() |
core (generic: media, slideshows, replays, animations) |
| Decoders: only WAV (hound) | mp3, flac, ogg, opus, m4a; symphonia is not in the lockfile | plugin: audio::decode(path) -> Decoded { rate, channels, duration, frames(): Iterator<Matrix>, seek(d) }, audio::wav given seek too |
plugin |
| No tag reader | ID3v2, Vorbis comments, MP4 atoms, FLAC: title, artist, album, duration, the embedded cover and lyrics (termusic uses lofty); the clone parses WAV INFO itself and falls back on the file name |
plugin: tags::read(path) -> Tags { title, artist, album, track, duration, cover: Option<Bytes>, lyrics: Option<String> }, and tags::write for a tag editor |
plugin (none in core: a domain) |
An image from bytes: image, image_with and image_of take a path |
an embedded cover (bytes from a tag), a cover fetched over the network (spotify-player) | image_from(\|\| Option<Arc<[u8]>>, view), or ImageSource::{Path, Bytes} in image_of |
plugin (art) |
| A tree has no options: no highlight symbol, no style per row, no way to say which keys it takes (Enter and Space toggle) | termusic's 🦄 symbol; Enter as "open as root" and Space as pause needed previews_keys on the pane and on the root |
tree_with(..).symbol("🦄"), .row_style(\|path\| ..), TreeOptions::toggle_keys(""), the list options a tree could share |
core |
| A tree is selected and expanded by index paths, with no key and no reveal | locating a search result (the clone walks the entries for the indices, inserts every ancestor's path into expanded, and resets both after each rescan); tree_lazy has keys but cannot reveal a key (open its ancestors) or reload a level after a delete |
tree_keyed(items, key_of, selected: Signal<Option<K>>) with .reveal(&key) and .reload(&key); tree_lazy(..).reveal(key) |
core |
| A table has no marker column or per-row symbol | termusic's first column (🚀 on the cursor, ► on the playing track); the clone puts them in a cell, so its rows closure reads the cursor and every move rebuilds every row | TableOptions::marker(\|i\| Option<markup>) (as a list's .marker), with .symbol(..) drawn over it |
core |
| No seekable progress bar | a click or drag on the gauge to seek (termusic has none, but every GUI player and spotify-player's progress does); renderable(FillGauge) has no idea where it was clicked |
gauge(\|\| ratio).label(\|\| ..).on_seek(\|ratio, cx\| ..), or a slider(value) node |
core |
| A list cannot keep its selection centred | synced lyrics read best with the current line in the middle (spotify-player's lyrics page, every karaoke view); the clone keeps it in view only | list(..).scroll_padding(Center) or .keep_centered() |
core |
| The help lists only the focused node's path | termusic's help is one table of every section (Global, Library, Playlist, …) wherever the focus is; with the playlist focused, the library's keys are not listed | HelpOptions::new().whole_app(true): every described binding of the screen, by section |
core |
No percentage offsets in layers: inset is cells |
termusic places the cover by percentages of the terminal (x 100%, y 77%, width 20%) | .inset_percent(top, right, bottom, left) or .place_at(x%, y%) |
core (low) |
| No media keys, MPRIS or now-playing integration | termusic's and spotify-player's MPRIS (play/pause from the desktop, the lock screen) | plugin: mpris(app_name) on D-Bus (zbus, not in the lockfile), feeding commands; core shape: named commands from outside the terminal reaching bindings, cx.run_command("Pause current track") / App::commands(source) |
plugin + core |
Friction with the existing APIs¶
- Placement belongs to the
layerschild. The cover'sswitch(to rebuild when it moves or hides) had.place(..)on the node it built; theswitchitself took the whole rectangle and blanked the playlist under it. Moving.placeand.insetonto theswitchfixed it. A debug warning forplaceon a node whose parent is notlayerswould have said so. - Panel titles that follow state (already listed in bottom's round 1):
three of termusic's four panes change their titles every second. The
titledoverlay works and looks right;panel_with(|| ..)would make it the panel's own title. - Two
previews_keys. The root previews keys so Space pauses rather than toggling a tree folder, and the library pane previews them so Enter opens a folder as the root rather than toggling it. - A theme style per pane is
theme_styles([("selected", ..), ("tree.selected", ..)])on each widget: it works, but a tree, a table and a list each look their styles up under different names. - The confirm is Yazi's. termusic's quit and delete boxes are a
30x3 radio (
Yes No);cx.confirmdraws Yazi's box with[Y]es (N)o. AConfirmOptions::compact()would port small yes/no boxes. - A WAV is read twice when queued (hound for its length, std for its
INFOchunk) and opened again when played. With an output it would be read as it plays, butaudio::wavhas noseek, sof/bwould have to read again from the start.
Components that would help¶
| Component | What it would replace or enable in music.rs | API sketch | Where | Value |
|---|---|---|---|---|
| An audio player: output, decoders, transport | the simulated clock (tick, seek, next's end check), sound itself; termusic's three backends (symphonia/rodio, mpv, GStreamer), spotify-player's librespot sink |
core: transport(length) (position signal, play/pause/seek/rate, on_end, on the app's clock); plugin (audio): output(..), decode(path), player(decoded, output) with volume, speed and gapless |
core + plugin | high |
| A tag reader | riff_info, Track::read's name parsing; embedded covers and lyrics |
plugin: tags::read(path), tags::write(path, tags) (lofty's shape) |
plugin | high |
| A playback queue | the playlist half of Player (current index kept through removes, moves and shuffles; next and previous by loop mode; random picks) |
core: queue::<T>() with current(), next(Repeat::All \| One \| Off \| Shuffle), previous, remove, swap, shuffle(seed), play(i) (generic: playlists, slideshows, a task runner's queue) |
core | medium |
| A keyed tree with reveal | Entry::locate, locate_path, the expanded bookkeeping, the rescans that reset the selection |
tree_keyed(..) with .reveal(key), .symbol(..), .cursor_by(key) as a list's; termusic's library and every file tree (Yazi's, gitui's) |
core | high |
| A time-synced list | lyric_lines, the memo/watch pair that follows the position, Lyrics::index_at |
core: follow_time(cues: \|\| Vec<Duration>, position: Signal<Duration>) -> Memo<Option<usize>> and list(..).keep_centered() (subtitles, transcripts, logs by time); plugin: lrc::parse(text) with offsets and several languages |
core + plugin | medium |
| A seekable gauge | renderable(FillGauge) and the label; seeking by click |
gauge(ratio).label(..).on_seek(..) (ratatui's Gauge, Bubbles' progress, Textual's ProgressBar) |
core | medium |
| A search pop-up over a table | search: the modal, the input, the filtered rows memo, Enter to the results, the Esc |
finder_table(columns, rows, matcher, on_pick): the core fuzzy_list with columns and a matcher of your own (wildcards, substrings, fuzzy) |
core | medium |
| Images from bytes, and Kitty/Sixel cells | an embedded or fetched cover; a sharp cover where the terminal can show pixels (termusic's cover feature, spotify-player's) |
image_from(bytes); the paint step passing rs-rich-art's Kitty/Sixel placements through (already noted in the guide) |
plugin (art) + core |
medium |
| Whole-app help | the help with the playlist's keys while the library is focused | HelpOptions::whole_app(true) |
core | medium |
| MPRIS and media keys | play/pause/next from the desktop, the now-playing widget of a desktop | plugin: mpris(..) (D-Bus); core: named commands from outside |
plugin + core | medium |
| m3u playlists | save_playlist, and loading one (termusic's database view lists them) |
plugin: playlist::read_m3u(path), write_m3u(path, paths) |
plugin | low |
What spotify-player would add¶
spotify-player (ratatui) has views termusic does not, each needing more
than this clone used: a library page (playlists, followed artists,
saved albums in three panes), a search page (tracks, albums, artists
and playlists, four result panes filled as you type), a context page
(an album's, artist's or playlist's header over its track table, top
tracks and related artists for an artist), a browse page (categories,
then their playlists), a lyrics page, a queue pop-up, a device
picker (Spotify Connect devices) and a command palette of its
actions. It streams through librespot, shows the cover through Kitty,
Sixel or iTerm2, sends desktop notifications on a track change, and
exposes MPRIS. On intuiTUIve those need, beyond the gaps above: an HTTP
client plugin with OAuth (the Web API), panes of results that fill as
requests return (the core resource_for per pane, with a loading state),
image_from(bytes) for covers fetched over the network, and an output
device picker (audio::picker lists inputs only).
Round 2: rebuilt on rounds 5 and 6¶
examples/music.rs went from 1,556 lines to 1,512, tests/music.rs from
508 to 549 (17 tests to 20). All 17 earlier tests pass; one assertion
changed (below).
Replaced by components¶
| Before (hand-built) | Now | Component |
|---|---|---|
A 100 ms every adding the speed's step to a pos signal, the end checked each tick, seek clamping by hand, a status signal kept in step |
A play head: play, toggle, seek, seek_by, set_rate (the speed), set_length (each track's), on_end (the loop mode's next), position() and progress() for the gauge and the lyrics; Status is derived from the current track and is_playing |
transport (round 5) |
titled: each pane a layers of an untitled panel and a text placed on its top row |
The panel's own title, read again as state changes: the accessible name, lit with the focus | panel_with, title_align |
The tree selected and opened by index paths (tree_at, expanded: HashSet<Vec<usize>>), Entry::at/locate/locate_path walking for indices, selection reset after each rescan |
The tree keyed by path: the selection stays on its entry across rescans (on the row in its place when the entry goes), tree_reveal locates a search result and the folder Backspace came from |
tree_keyed, TreeItem::keyed, tree_reveal |
The library pane's previews_keys so Enter opens a folder as the root instead of toggling it |
The tree leaves Enter and Space to the bindings | Node::tree_keys(TreeKeys::default().no_toggle()) |
The playlist's first column a cell (🚀/►), so every cursor move rebuilt every row |
A symbol column: 🚀 on the cursor, ► on the playing track; the rows no longer read the cursor | TableOptions::symbol_for |
The cover's switch on its place and size, keeping a page for every place it was ever moved to, and a blank label when hidden |
One child rebuilt when the place changes, the old one dropped; hidden by state | keyed, shown_when |
| The help of the focused pane's path only | Every pane's keys, by section, wherever the focus is | HelpOptions::all_panes |
| (none: termusic's tree highlight symbol was missing) | 🦄 on the library's selected row, termusic's default | Node::symbol on trees |
New tests: the help from the playlist lists the library's keys; at speed 2.0 a four-second track ends after two; a deleted library entry leaves the cursor on the row in its place.
Changed look (test changed): a panel's title sits a glyph in from the
corner with a space each side (╭─ Library ─), where termusic's (ratatui's
Block::title) sits at the corner (╭ Library ─). The playlist's title
string starts Playlist ──┤, so its row reads as termusic's up to ├─,
then a space before the border goes on. the_tree_view_layout now
expects ╭─ Library ─; the other title assertions pass unchanged.
Still hand-built, and why¶
- The tag reader (
riff_info, WAVINFOwith std), the.lrcparser, the wildcard matcher, the library scan and natural order, the m3u writer: domain code a plugin would hold (round 1's gaps). - The playlist model (current index kept through removes, swaps and shuffles, next by loop mode): no queue component yet.
- The lyric line: a
memoon the play head and awatchwriting the list's cursor. - The search pop-up: a modal of an
inputover atable_with, with the results as a memo. - The footer: termusic's five fixed hints;
key_hints_withshows the focused path's bindings, all of them (52 here), with no way to pick five and name them. - The cover's place: a column of a row with blank
labels for the offsets, inside.place(End, End).inset(1, 1, 1, 1). - The progress gauge:
renderable(FillGauge); there is no seekable gauge node.
New gaps¶
| Gap | Needed for | API sketch | Where |
|---|---|---|---|
| A panel title as ratatui draws it | termusic's titles at the corner, unpadded (╭ Library ─); every ratatui port's Block::title(" X ") |
.title_at_corner() / TitleLook::Ratatui on a panel (no inset, no added spaces) |
core (generic) |
| A transport's status | Status (stopped, running, paused) is worked out from the current track and is_playing; a transport with no length set and at 0 cannot say "stopped" |
Transport::stop() (pause and seek to 0, is_stopped()), or a state() of Stopped/Playing/Paused |
core (generic) |
| Key hints chosen by key | termusic's footer (Help: <control+h> Config: <shift+C> …), a fixed few of the app's bindings with their own short names |
key_hints_with(KeyHintsOptions::new().only(&[("ctrl+h", "Help"), ("C", "Config")]).format(..)) |
core (generic) |
| A tree reveal that also scrolls into view and is a method | locate builds the items again to pass tree_reveal a slice |
tree_keyed(..) returning a handle with .reveal(key), reading its own items |
core (generic) |
| Following a time | the lyric line's memo + watch from the play head to the list's cursor (round 1's) |
follow_time(\|\| cues, \|\| head.position()) -> Memo<Option<usize>>, or Transport::cue_index(&cues) |
core (generic) |
Bugs found¶
None. (transport's end on the app's clock, tree_keyed after a delete
and symbol_for all behaved as documented under the driver.)
Components that would help¶
| Component | What it would replace or enable | API sketch | Where | Value |
|---|---|---|---|---|
| Audio output and decoders (round 1's) | Sound; the play head would follow the sink | audio::output, audio::decode(path), a player driving a Transport |
plugin (audio) |
high |
| A tag reader (round 1's) | riff_info, tags from file names, embedded covers (image_bytes_of is ready for them) |
tags::read(path) |
plugin | high |
| A playback queue (round 1's) | The playlist half of Player |
queue::<T>() with current, next(Repeat), remove, swap, shuffle |
core (generic) | medium |
| A seekable gauge | renderable(FillGauge) and its label; seeking by a click |
gauge(\|\| ratio).label(..).on_seek(..) |
core (generic) | medium |
| Ratatui-style titles | The one look difference left | see the gap | core (generic) | medium |
| Chosen key hints | The footer's hand-written markup | see the gap | core (generic) | low |