intuiTUIve gaps: bottom¶
Round 1: bottom¶
examples/top.rs
rebuilds bottom (btm), the
system monitor, on intuiTUIve with no framework change. It is 2,706 lines
(after rustfmt): about 325 are the Linux data source (/proc/stat,
/proc/meminfo, /proc/net/dev, /proc/diskstats, /proc/[pid]/{stat,status,io,cmdline},
/sys/class/hwmon, df for the disks' sizes and kill for the signals,
all through std), 700 the domain (bottom's units, the sliding history,
the search query language, sorted, grouped and tree rows), and the rest
the widgets, layout, keys and dialogs. The source sits behind a Source
trait (sample(), kill(pid, signal)), sampled on a latest_every
thread. Its tests (tests/top.rs,
769 lines, 25 tests) feed scripted samples through that trait, record the
signals sent, run the app on the app's clock (wait_for_tasks(true),
Driver::advance) and assert on the rendered text.
What is there, with bottom's layout, strings, colours and default keys:
- The default layout: CPU across the top (30%), memory beside temperatures over disks (40%, 4:3), network beside processes (30%, 1:1), plain borders, the focused widget's border in light blue.
- CPU: a Braille line per core and the average over a 60 s window
(
60sto0s,0%to100%), the legend at the right (15%):All,AVG,CPU0… with their use, each in its colour; ↑/↓ on the legend picks what the graph shows. - Memory: RAM and swap lines, the legend box at the top right
(
RAM: 25% 4.0GiB/16.0GiB), hidden when too big, as bottom's. - Network: RX and TX in bits a second, bottom's linear y scale (the
top 1.5 times the largest rate in view, four labels in its unit), the
legend
RX: 1.4Kb/s All: 3.4GB. - Temperatures and Disks tables, sorted by
s/tandd m u n t p r w(again reverses), or a click on a header. - Processes: PID, Name, CPU%, Mem%, R/s, W/s, T.Read, T.Write, User,
State, Time, bottom's cell formats; sorted by CPU;
c m p nsort (again reverses),Iinverts,s/F6 opens the "Sort By" menu at the left, Tab groups by name (PID becomes Count, usage summed),Pthe full command,%memory as sizes,t/F5 tree mode (├─,└─,│,+for a collapsed branch; Space,+,-, ←, → collapse and expand),zhides kernel threads, Ctrl+U/Ctrl+D half a page. - Search (
/or Ctrl+F): bottom's box under the table (> query, an error line,Case(Alt+C) Whole(Alt+W) Regex(Alt+R)lit when on,Esc to close); the query language's keywords (pid,cpu,mem,membwith units,read,write,user,state), comparisons,and(or a space),or,!; filtering as you type; Esc closes and keeps the filter; a tree keeps the ancestors of what it finds. - Kill (
dd, F9 or Delete): bottom's Linux dialog, "Kill process 'cargo' with PID 420? Press ENTER to confirm." over the list of signals with15: TERMpicked (0: Cancelsends nothing); a group's every process; a failure is an error notification.--disable_advanced_killgives the yes/no confirm (cx.confirm). - Widget focus: Ctrl or Shift+arrows,
H J K LandA S W Dmove to the widget on that side, from where each was drawn; a click selects one. - Expand (
e): the selected widget fills the screen withEsc to go backat the right;eor Esc returns, the focus kept. - Zoom:
+/-(and the wheel over a graph) change that graph's window by 15 s, from 30 s to the 10 minutes kept;=resets it. - Freeze (
f,Frozen, press 'f' to unfreezeon the last row), reset (Ctrl+R), help (?, bottom's sections in a scrolling box), quit (q,Q, Ctrl+C). - Basic mode (
-b): per-core pipe gauges (AVG [||||| 39%]), RAM/SWP gauges, RX/TX and totals, and one table at a time with bottom's◄ Processes/Temperatures ►carousel, moved with Ctrl+←/→ orH/L.
Left out: the battery, GPU, disk I/O and temperature graphs, cache and ARC lines, custom layouts from a config file (and the config file itself), regex search (no regex engine among the crate's dependencies: the toggle shows, a pattern matches as text), the time axis auto-hiding, the legend position options, the packet counts, the widgets' scroll position in their titles, and per-process GPU columns. Linux only.
What is bespoke, and why:
- The rectangle registry and neighbour search (
Places,neighbour, about 120 lines): bottom moves the selection by its layout tree; intuiTUIve has no directional focus, so each widget's focusable node is kept by name with aRectHandle, and the nearest one on a side is picked. It is also how a node aswitchbuilds later gets the focus (a pending name and awatchon its handle). Both are gaps below. - The pipe gauges in basic mode are formatted text (
pipe_gauge), as wide asviewport()allows: there is no gauge node in that style, and a text closure does not know its width. - The history (
History,series): a sliding window by time over the samples, what a time-series chart would hold.
New gaps and bugs¶
| Gap or bug | Needed for | API sketch or repro | Where |
|---|---|---|---|
Panel titles are fixed when built: .panel(title), add_title, title_right take a string |
bottom's CPU ─ 0.52 0.48 0.40 (the load average, every sample), Processes (3 of 210) when expanded; any title with a count or a state |
.panel_with(\|\| markup), .add_title_with(Edge, Align, \|\| markup) that read signals |
core |
| No directional focus: Tab order only, and a handler cannot ask where widgets are | Ctrl/Shift+arrows, HJKL, WASD between widgets (bottom, every tiling dashboard, tmux-like panes) | cx.focus_toward(Direction::Left): the nearest focusable node (or .focus_group() root) on that side of the focused one, by the last frame's rectangles |
core |
cx.focus(id) of a node a switch has not built yet is lost, and cx.after(Duration::ZERO, ..) runs before the frame that builds it |
the search box and sort menu taking the focus when they open; the expanded widget, and back; the process table rebuilt with new titles | cx.focus_when_shown(id) kept pending until the node is laid out; or Node::focus_on_show() |
core |
| A handler cannot ask which node has the focus | one place for "the selected widget's" keys (e, +, -): today each widget binds its own copy so the binding knows whose it is |
Ctx::focused() -> Option<NodeId>, Ctx::focus_within(id) -> bool |
core |
| A table's columns are fixed: titles cannot follow state | PID → Count when grouped, Name → Command (P), Mem% → Mem (%): the clone has a switch with a table per set of titles, each with its own sort signal mirrored to the view, and moves the focus across |
table_columns(\|\| Vec<Column>, rows, selected, options), or Column::title_with(\|\| ..) |
core |
A table cannot drop columns that do not fit: a Flex column gets what the Auto ones leave (Name vanished at 58 columns until given .min(16)), and the last Auto columns are cut with … |
bottom hides columns from the right and keeps Name; disks at 50 columns lose R/s and W/s whole | Column::priority(n) (dropped lowest first, whole), TableOptions::drop_columns() |
core |
Bug: a table (and a list) takes Shift+↑/↓ as ↑/↓: its key match checks only Ctrl and Alt. An ancestor's shift+up binding never fires while a table has the focus. Repro: column([table(..)]).on_key("shift+up", f), press Shift+↑: f is not called, the selection moves |
bottom's Shift+arrows between widgets; the clone makes every widget's panel previews_keys |
treat a key with Shift as not the plain arrow (unless the table scrolls across with it) | core |
A table's keys are fixed: g jumps to the top at once, s sorts when .sort is set |
bottom's gg, s (the sort menu; the temperature table's sort by sensor) need previews_keys round the table |
TableOptions::keys(KeyMap) or .no_key("g"); the table's g as a g g sequence option |
core |
An .auto() switch measures its page's content, ignoring the page's .fixed(n) and .min_size(n) |
the 11-wide sort menu came out 9 wide (its title cut to So…); the clone pads the menu's items |
measure a child's own fixed size, as a row lays it out |
core |
No reactive hiding: class_when restyles but does not lay out (display: none follows fixed classes only), hide_below follows the viewport |
expand: the clone builds a second view per widget under a switch on the expanded widget, so every bit of state must live in signals, and focus is moved by hand |
.show_when(\|\| bool) / .hide_when(..), a reactive display: none |
core |
| A text node does not know its width | basic mode's gauges sized from viewport() rather than their cell |
text_sized(\|width\| markup), or the gauge node below |
core |
| No regex engine may be added | bottom's Regex(Alt+R) search | a pattern matcher in rs-rich-ext (text::Pattern::regex(..)), used by the core finder's matcher too |
plugin (rs-rich-ext) |
rich_ext::format has one style (1.5 kB, 2.0 GiB, a space, bytes) |
bottom's 4.0GiB, 900MiB, 2KB/s, 1.4Kb/s (bits), 1m 15.00s; the clone has its own helpers |
format::Units::binary().precision(1).compact().bits() with .bytes(n), .rate(n); a duration style |
plugin (rs-rich-ext) |
Driver::event does not settle: a test of a mouse event must call settle(), where press does |
the wheel test | event settles as press does, or event_settled |
core |
Friction with the existing APIs¶
- Panels and ratatui's
Blocktitles differ by a glyph. ratatui putsSort Byagainst the corner (┌ Sort By ┐, 11 wide); a panel puts a line glyph first (┌─ Sort By ─┐, 13), so bottom's narrow boxes cut their titles. A.title_inset(0)would port them as they were. - Charts are rebuilt whole every sample.
renderable(|| Chart)makes every dataset again from the history (seven series, 60 points each); fine at a second, but a time-series node that appends a point would be cheaper and would not need theHistoryin the example. switchkeeps every page, which suits tabs, but the expanded views, the process tables per title set and the search box each keep their nodes (and theirwatches) forever once shown.- The process table's sort is two signals. The table needs its own
Signal<Option<(usize, Order)>>for the header's arrow and clicks; the view keeps the sort with the other options, so twowatches mirror them. - A modal's list needs a name to be found:
focused_name()isNonefor an unnamed list, which read at first as the focus being lost. previews_keyseverywhere. Four containers preview keys (each widget's panel, the process table's, the temperature and disk tables'), each to get round a table's own bindings.
Components that would help¶
| Component | What it would replace or enable in top.rs | API sketch | Where | Value |
|---|---|---|---|---|
| A time-series chart with a sliding window | History, series, time_axis, the three chart builders and the zoom keys (bottom vendors ratatui's Chart as TimeChart for this; Textual has PlotextPlot, ratatui-toolkit and tui-realm have live charts) |
core: window_by_time(signal, Duration) (a recent by time, generic); plugin (viz): time_chart(series, window).zoom(step, min..max).y(Scale::Percent \| Auto) with the 60s/0s axis and wheel zoom |
core + plugin | high |
| Directional focus | Places, neighbour, move_focus, the four bindings on each widget |
App::directional_focus("ctrl+left shift+left H A", ..) or cx.focus_toward(dir) |
core | high |
| Dense sortable tables with column priorities | the per-title switch, the mirrored sort, the padded menu, .min(16) on Name |
table_columns(\|\| cols, ..), Column::priority, TableOptions::keys |
core | high |
| A tree table | tree_rows and its prefixes, collapsed, the Space/←/→/+/- bindings (bottom's tree mode; htop's, btop's) |
tree_table(columns, roots, children, selected) with the core tree's keys and per-row cells |
core | high |
| Layout from a config | bottom's [[row]]/[[row.child]] layouts with ratio and default=true; the hand-built dashboard |
layout_from(toml_or_value, \|type\| node): rows and columns of named widgets with weights; a stylesheet already sizes nodes, this would build them |
core (generic: names to nodes) | medium |
| Expand (zoom) a pane | the expanded signal, the second view per widget, Esc to go back |
node.zoomable() and cx.zoom(id): the node drawn over the whole screen, its state kept (tmux's z, Zellij's fullscreen) |
core | medium |
| A gauge node | pipe_gauge and the viewport() sums |
gauge(\|\| ratio).label(..).inner(..).style(Gauge::Pipe \| Blocks \| Line) (ratatui's Gauge/LineGauge, btop's and htop's meters) |
core | medium |
| Unit formatting | binary_string, decimal_string, per_second, bits_label, format_time, memory_label |
rich_ext::format::Units (binary/decimal, bits, precision, compact) |
plugin (rs-rich-ext) | medium |
| A system-metrics source | the 325-line Linux source and Sample |
none in core (a domain); plugin: system::sample() (CPU, memory, network, disks, temperatures, processes) and system::kill(pid, signal), on sysinfo or /proc, cross-platform; fed through the core's latest_every |
plugin | high |
| A query language for tables | Query, Cond, keyword, unit_bytes |
core: table_filter(\|row\| ..) with a parsed field op value and/or ! expression over named columns; plugin: process keywords |
core + plugin | medium |
| Mouse selection in tables, wheel per widget | the wheel works; a click selects a row and focuses a widget already | .on_wheel(\|cx, delta\| ..) sugar over on_mouse |
core | low |
Round 2: rebuilt on round 6¶
examples/top.rs went from 2,706 lines to 2,415 (tests 769, 25 tests,
all passing; one assertion changed, below).
Replaced¶
| Hand-built (round 1) | Now | Lines gone |
|---|---|---|
Places (a registry of names, node ids and RectHandles), Dir, neighbour, place_key, move_focus, and the four move bindings on every widget |
each widget a .focus_scope(), and one .bind_directional_focus("ctrl+left shift+left H A", ..) on the dashboard (Ctx::focus_direction on focus::neighbour) |
~150 |
The pending-focus watch (focus_soon) for nodes a switch had not built yet |
the sort menu and search box always built, .shown_when(..), and cx.focus_within(id) / cx.focus(table) |
~30 |
Expanding by a switch that built a second copy of every widget ("expanded" and "dash" prefixes, state kept in signals so both copies agree) |
shown_when(state.shows(kinds)) on each widget and container: the expanded one is the same node, so it keeps its focus, scroll and selection with no work |
~40 |
The process table: a switch with one table per set of titles, its own sort signal mirrored to the view by two watches, the focus moved to the new table |
one table_with with TableOptions::columns_with(..), .sort(proc_sort) read by the rows memo, .layout(&TableLayout) for Ctrl+U/D's half page |
~45 |
The sort menu's items padded to 11 (an .auto() switch ignored the page's .fixed(13)) |
.fixed(13) honoured; the menu is a plain list |
~5 |
bottom's query parser (Cond, OPS, keyword, parse, holds, ~150 lines) |
a query::Grammar of bottom's keywords (aliases cpu%, r/s…, sizes read with units, user/state containing), Grammar::words for whole words (Alt+W), case_sensitive for Alt+C, over a Record for a process; 15 lines join 1 gib into 1gib and read &&/|| |
~80 net |
The basic-mode carousel line padded from viewport() |
a row of two texts |
~5 |
The frozen banner's switch |
.shown_when(frozen) |
~10 |
New with the components, at no cost: the CPU panel's title carries the
load averages (CPU ─ 0.52 0.48 0.40, title_with), as bottom's does;
"Esc to go back" is an add_title_with that follows the expanded
widget; the process and disk tables drop whole columns from the right
when narrow (Column::priority), as bottom does, where they were cut
with …. The Shift+↑/↓ bug is fixed in the core, but the widgets'
previews_keys stay for the tables' letter keys (s, t, d…).
Behaviour changes¶
- A bad search says
`lots` is not a number for `cpu`(the query module's message) instead of bottom's "Invalid number: lots"; the two tests asserting the text now expect it. The component's message is the better one (it names the field, andQueryError::atsays where), so the test changed rather than the message being rewritten. user = rono longer matchesroot: the query module's=on text compares the whole value, where bottom's (and the clone's) contained it.user:ro(the field'sContainsmatching) does. A gap below.
Still hand-built, and why¶
- The Linux source (
Linux, 325 lines) and the unit formatting (binary_string,per_second,bits_label…): domain, plugin gaps from round 1. History,seriesand the three chart builders: no time-series chart with a sliding window yet.- The tree mode (
tree_rowsand its├─/└─/+prefixes in the Name column): round 6's keyed trees are atreewidget, not a tree table; bottom's tree is a table with every column. - The pipe gauges (
pipe_gauge, widths fromviewport()): no gauge node, and a text still cannot read its own width (fit_textpicks among forms, it does not build one for a width). - Grouping, sorting and filtering the rows (
proc_rows): the rows are recomputed per sample;view_ofis for largeSend + Syncsources on a worker thread, more than 200 processes need. - The help is bottom's text (
HELP), not the bindings' list:HelpOptions::all_paneswould list the app's own descriptions, in its own sections, not bottom's.
New gaps¶
| Gap | Needed for | API sketch | Where |
|---|---|---|---|
bind_directional_focus with descriptions. It lists "Focus left"…; bottom's help says "Move widget selection left". The clone keeps its own help text so it does not show, but an app using the generated help would want its words |
apps with their own vocabulary | bind_directional_focus_with(keys, descriptions) or a DirectionalKeys struct |
core, generic (small) |
A query grammar with units after a space. memb > 1 gib reads gib as a bare word; the clone joins a unit word to the number before parsing (and &&/|| to and/or) |
bottom's syntax; any size or duration field (size > 10 MB) |
Grammar::unit_words(&["kb", "gib", ..]) (a word after a number field's value joins it), Grammar::operator_alias("&&", "and") |
core, generic |
TextMatch for comparisons. user = ro is exact; bottom's = on text is a contains match. matching(TextMatch::Contains) changes only field:value |
faithful syntaxes | Grammar::equals_matching(TextMatch) or apply matching to = too |
core, generic (small) |
A tree table. tree_rows builds prefixes into the Name cells by hand and keeps the collapsed set; a tree_keyed-style table (tree_table(columns, items, selected, expanded) with a tree column) would replace it and give ←/→/Space for free |
bottom's and htop's tree mode, git's file trees with columns | tree_table(columns, \|\| Vec<TreeRow { key, depth?, children, cells }>, selected, expanded).tree_column(1) |
core, generic |
A text that builds its markup for its width. basic mode's pipe gauges need their width; fit_text picks among forms, it cannot draw [|||| 39%] to fit |
gauges, rulers, centred banners | text_sized(\|width\| markup), or a gauge node (round 1's sketch) |
core, generic |
Round 1's still open: a time-series chart with a sliding window (plugin
viz on a core window_by_time), unit formatting (rs-rich-ext), a
system-metrics source (plugin), layout from a config, a gauge node, a
regex matcher.
Bugs found¶
None in the components used. columns_with, priority, focus_scope
with bind_directional_focus, shown_when on flex children and
containers, add_title_with returning an empty string (it draws nothing)
and focus_within on a node shown by a signal all behaved as documented
on the first try; every focus test passed unchanged.
Components that would help¶
| Component | What it would replace | API sketch | Where | Value |
|---|---|---|---|---|
| A time-series chart | History, series, time_axis, the three charts, the zoom keys and wheel (~250 lines) |
round 1's time_chart(series, window).zoom(..) on a core window_by_time |
core + plugin (viz) | high |
| A tree table | tree_rows, the collapsed set, the tree bindings (~110 lines) |
tree_table above |
core | high |
| A system-metrics plugin | Linux, Sample (~350 lines) |
round 1's system::sample() |
plugin | high |
| Unit formatting | six helpers (~80 lines) | rich_ext::format::Units |
plugin (rs-rich-ext) | medium |
| A gauge node | pipe_gauge and its width sums (~60 lines) |
gauge(\|\| ratio).style(Gauge::Pipe).label(..) |
core | medium |
| Zoom a pane | the expanded signal, shows, the shown_whens and "Esc to go back" (~25 lines now, down from ~70) |
node.zoomable() and cx.zoom(id) (tmux's z) |
core | low |