intuiTUIve gaps: gitui¶
Round 1: gitui¶
examples/git.rs
rebuilds gitui, the git TUI, on
intuiTUIve with no framework change. It is 2,219 lines (after rustfmt):
about 630 are the repository through the git command line (status,
per-file diffs, git apply of a hunk or of chosen lines, commit, log,
refs, details, branches, stashes, files at HEAD), 180 the file trees, 210
the diff view, and the rest the tabs, panes, popups and keys. Its tests
(tests/git.rs, 535
lines, 21 tests) build a repository in the temporary directory with the
git command line for each test, isolated from the user's and the
system's git configuration, and drive the app headless.
What is there, with gitui's layout, strings, colours and default keys:
- The top bar:
Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5]over a rule, the repository's path at the right; 1-5, Tab and Shift+Tab switch. - Status: the Unstaged Changes and Staged Changes trees (60/40) beside
the diff (50/50); gitui's status letters and colours (
M,+,-,R,!), folders open, single-folder chains folded (a/b/c); the branch (↑1 ↓0 {main}) on the focused list's edge. Enter stages or unstages a file or a whole folder,astages (unstages) everything, D resets a file after a confirm,iadds it to.gitignore,wmoves between the lists, → (orl) to the diff and ← back. - The diff: gitui's hunk markers (
┌,│,└, lit for the cursor's hunk), a line cursor, Shift+↑/↓ to select lines,n/pbetween hunks; Enter stages (unstages) the hunk,Dresets it,sstages (unstages) the selected lines,dresets them (both asked first),ycopies them. Partial staging builds a one-hunk patch and runsgit apply --cached(--reverseto unstage or reset), the lines left out kept as context or dropped asgit add -pdoes. - The commit popup (
c, only with something staged): 60% x 20% of the screen,{branch}on its top edge,type commit message..,[subject length: N]past 50; Ctrl+D commits, Ctrl+A amends (Commit (Amend), HEAD's message loaded), Ctrl+E hands the terminal to$GIT_EDITOR/core.editor/$VISUAL/$EDITORonCOMMIT_EDITMSGwith gitui's comment lines and reads it back without them, Esc closes it and keeps the message. - Log:
hash time author <tag> {local} [remote] subjectin gitui's colours and widths,Commit n/Ncounted from the bottom as gitui does; Enter toggles the details beside it (Info: author, date, committer, sha, tags, subject; the files), → inspects a commit full screen (its details and files, the diff of the selected file),ycopies the hash. - Files: the tree at HEAD and the selected file's text, numbered and highlighted by its extension.
- Stashing: Files to Stash beside the Options box (
[x] stash untracked,[_] keep index,u/i),sasks a message and stashes. Stashes: the list;aapplies,ppops,Ddrops (asked), → inspects. - The branch list (
b): a thick-bordered popup,Local | Remote,*on HEAD and↑where tracked; Enter checks out (a remote branch as a tracking one),ccreates,rrenames,Ddeletes (asked),mmerges. - The key bar (
key_hints) and the help (h,Help: all commands, sectioned by pane), errors in gitui'sErrorbox.
Left out: push, pull and fetch (and their progress and credential
popups), blame (B), file history (H), tags and remotes popups, log
search (f) and marking (Space), compare commits, reword, reset to a
commit, checkout of a commit, submodules, the options popup (whitespace
and context lines), the fuzzy file finder, and gitui's theme and key
config files. gitui's log draws no graph, so the clone has none.
Bespoke, and why: no Widget of its own. Two helpers stand in for what
is missing, and are counted as gaps below: titled (a panel whose title
follows a signal, as a text layer over its top edge) and the diff's
row model (diff_rows and Diff: hunk markers, a range selection, hunk
jumps, over list_rows). The git layer is the data source the brief
allows (std::process::Command), parsed with rs-rich-ext's
diff::git::parse_unified.
New gaps and bugs¶
| Gap or bug | Needed for | API sketch or repro | Where |
|---|---|---|---|
A panel's titles are fixed when it is made. gitui's titles change (Diff: src/lib.rs, Commit 3/10, Stashes 1/1), and are bold while focused and grey otherwise; titled lays a text over the top edge, so the title's focus look comes from the app's own focus signal, not the panel's |
every titled pane in gitui | .panel_with(\|\| markup), .title_with(Edge, Align, \|\| markup); the theme's title/title.focused styles |
core |
A node shown or hidden by state. Node::shown_if exists but is pub(crate) (it is how hide_below works); the details pane beside the log is a switch between two copies of the commit list on one selection signal, with a watch moving the focus to the copy shown |
Log's Enter (details), any toggled sidebar | .shown_when(\|\| bool): shown_if made public |
core |
Sizes that follow state. gitui swaps the lists to 40/60 when the staged one is focused and gives the diff the whole width while it has the focus (the lists 0%); the clone keeps 60/40 and 50/50. Sizes are read when the node is made; a stylesheet's class_when changes colours only |
gitui's focus-driven layout | .size_with(\|\| Size), or classes that relayout (class_when matching size rules) |
core |
tree keeps its selection and its open folders by index path. Staging a file moves every path after it, so the clone reopens every folder on each refresh (a folder the user closed opens again) and finds the selection again from a remembered row (Files::new's two watches) |
the status trees, the Files tab | tree_keyed(items, key, selected: Signal<Option<K>>, expanded: Signal<HashSet<K>>) (tui-tree-widget keys by identifier), or tree(..).cursor_by(\|path\| key) as list has |
core |
tree's rows cannot put anything before the indent: a leaf is indented past the arrow column (+ notes.md), gitui puts the status letter first (+ notes.md, M lib.rs under a folder) and the arrow on the name (▾src); no selected_style_for either (the trees use the theme's selected, the lists white on blue) |
gitui's tree look | TreeItem::prefix(markup) drawn before the indent; the list's row options (selected_style_for, row_style) on tree |
core |
A range selection on a list: an anchor and the cursor, Shift+↑/↓ (and Shift+click) to extend, the rows between lit. The diff keeps an anchor signal and moves the cursor by its own bindings (Diff::step), so the list's own ↑/↓ are bypassed. multi_select's visual mode is by key and draws markers, not rows |
the diff's line selection | list(..).range(anchor: Signal<Option<usize>>): Shift with the moves extends, the theme's selected.range lights the rows, range() reads it |
core |
No way to know the framework's focus as state, or to hear it move. gitui's diff follows the list last focused; the clone keeps its own Focus signal, moves the real focus to node ids from a watch, and adds an on_click to each pane to keep the two in step |
which list the diff shows | focus_within(id) -> Memo<bool>, or .on_focus(\|cx\| ..) / .on_blur |
core |
key_hints has one look: key desc, innermost first, bind_when bindings left out. gitui's bar is Stage [⏎] Reset [D] … in its own order, greys the commands that cannot run now instead of hiding them, and more [.] expands it to several lines |
gitui's command bar | key_hints_with(KeyHintsOptions::new().format(\|key, desc\| ..).key_symbols(true).show_disabled(true).expand_key("period")); bind_when bindings as disabled hints |
core |
A modal's size has no clamp: gitui's branch list is 80% x 50% of the screen but at least 60 x 20 (rect_inside) |
the branch popup on a small terminal | ModalOptions::min(w, h) / max, or Size::Percent(80).at_least(60) |
core |
A text area's language is set when it is made: the Files tab rebuilds the area per extension in a switch on the same text |
Files, any viewer of changing files | .language_with(\|\| name) |
core |
Markup everywhere: a plain string with brackets ([subject length: 55], a commit subject, diff text) is read as tags unless escaped; the clone escapes ~25 places, and missed one (fixed) |
every text from outside the app | text_plain(\|\| s), or seg-style spans: text_spans(\|\| vec![(s, style)]) |
core |
The git plugin's own model: rs-rich-ext's git reads status letters for a file manager only; the staged/unstaged split, per-file diffs, git apply of a selection, log with refs, details, branches, stashes are the clone's ~630 lines (gitui leans on libgit2 through its asyncgit crate) |
all of gitui | a git plugin (feature): Repo::status/diff/apply/commit/log/refs/branches/stashes/blame, each also as a resource; under it, none in core (it is git's domain) |
plugin |
Building a patch from part of a hunk (hunk_patch): rs-rich-ext parses a unified diff but cannot write one back |
partial staging, reset lines, add -p |
FilePatch::select(hunk, \|line\| bool, Direction::Forward \| Reverse) -> String, Patch::to_string() |
plugin (rs-rich-ext diff) |
All git work runs on the app's thread: fine for a test repository, a frozen frame on a large one. gitui runs everything on worker threads with a spinner, and push/fetch with a progress popup. spawn, resource and jobs exist; what is missing is a process with progress (stderr's Counting objects: 42%) and cancellation, and a refresh after the repository changes on disk (the clone polls every 5 s; gitui watches with notify) |
push, pull, fetch, big repositories | core: jobs.run_process(title, Command, parse_progress, done) (the files round's "Jobs running a process"); plugin: the git plugin's async calls and a .git watcher on the watch feature |
core + plugin |
Friction with the existing APIs¶
- Two sources of focus. Because gitui's pane focus decides what the
diff shows, the clone mirrors it in a signal; the framework's focus
drives the borders, the app's drives the titles. A click on a pane
sets both only because each pane has an
on_click. - Keys that must beat the tree's. Enter on a folder stages it in
gitui; the tree toggles it. Each list's wrapper
previews_keys()and binds Enter,a,D,i;h(help) is on the root withpreviews_keys()so the tree'sh(to the parent) never runs. → on a leaf bubbling out of the tree to the page's "Diff" binding works as gitui does; on a folder the tree opens it, as gitui does too. list_rowsand the cursor's hunk. Every row's marker depends on which hunk the cursor is in, so the row closure reads the cursor and every row is made again on each move.- Copies of a pane for a layout change. The log's two commit lists
share
log_at, but each keeps its own scroll offset: with the details open the list can show a different first row. - Help sections come from
.name(..), which also names the node forassert_focusedand the inspector; gitui's help groups by component and shows a description pane for the selected command. handoff_thenworks well for Ctrl+E, and the headless driver'stake_handoff/handed_backmade it testable; the app still writes the file, quotes$EDITORthroughsh -c, and strips the comments (the files round'scx.edit_textwould be all of it).bind_whenonckeeps Commit out of the bar and the help when nothing is staged, where gitui greys it.
Components that would help¶
| Component | What it would replace or enable | API sketch | Where | Value |
|---|---|---|---|---|
| An interactive diff view | diff_rows, Diff and diff_bindings (~280 lines): hunk markers lit for the cursor's hunk, line ranges, n/p, and also a side-by-side layout (rs-rich-ext's PatchView has one, as a renderable only), syntax-highlighted lines (rs-rich-ext's highlight_lines is private), word emphasis, folding unchanged lines; delta, lazygit and tig draw these |
plugin diff_view(patch: Signal<Option<FilePatch>>, DiffOptions::new().side_by_side().highlight(lang).on_hunk(..).on_lines(..)); the generic core shape under it: a sectioned list (rows in sections, a gutter marker per section lit for the cursor's, jump to the next/previous section, a range kept within a section) |
core (sectioned list) + plugin (diff) | high |
| A git plugin | the clone's Git (~630 lines) and the async work gitui does on threads |
git::repo(path) with status, diff, stage/unstage/reset(paths), apply(selection), commit, log (paged, with refs), branches, stashes, blame, push/fetch as jobs with progress; a .git watcher (feature watch) |
plugin (none in core: git's domain) | high |
| A keyed tree | Files (the selection and open folders found again after each refresh) |
tree_keyed: items with keys, selection and expansion by key, prefix before the indent, the list's row options |
core | high |
| A commit message editor | the commit popup's layers (branch on the edge, the subject warning) and the editor round trip | core: text_area(..).line_style(\|n, line\| ..) (dim # lines, a red subject past 50), .ruler(72), cx.edit_text(initial, ..); plugin: git::message_editor(sig) with the conventions (subject 50/body 72, comment lines, co-author trailers) |
core + plugin | medium |
| A command bar | key_hints in gitui's (or lazygit's) look |
key_hints_with(..) as above; Bubbles' help short/full toggle, Textual's Footer with show priorities |
core | medium |
| A blame view | gitui's B: the file with each line's commit, author and age in a gutter, Enter to the commit |
core: a text view with a per-line gutter (text_area(..).gutter(\|line\| markup) or list_rows with a leading column); plugin: git::blame parsing git blame --porcelain |
core + plugin | medium |
| A lane graph for a DAG | a commit graph in the log (lazygit, tig, gitu draw one; gitui's is an open request) | core: dag_lanes(rows: &[(id, parents)]) -> Vec<String> (the glyphs per row, │ ├─╮ ╰─╯), any DAG (a build graph, a task tree); plugin: the git log's |
core + plugin | medium |
| A progress popup for a running process | push/pull/fetch with stages (adding objects (1/3)) and a credential prompt |
jobs.run_process above, jobs_panel in a modal; cx.prompt_with(.., mask('•')) for the password exists |
core + plugin | medium |
Reactive layout: shown_when, size_with |
the log's two lists, gitui's 0/100 and 40/60 layouts | as in the gaps | core | medium |
Round 2: gitui on round 6¶
The clone is rebuilt on what round 6 added. It is 2,204 lines (from 2,219), and does more than before: gitui's focus-driven layout and its command bar are in, the closed folders stay closed. The tests are 585 lines, 23 tests (from 21); all pass.
What was replaced, and by what¶
| Hand-built in round 1 | Now | Lines |
|---|---|---|
Files: index-path selection and open folders, every folder reopened on each refresh, the selection found again from a remembered row (two watches and a last signal) |
tree_keyed on Signal<Option<String>> / Signal<HashSet<String>>, keyed by path; one watch opens only the folders it has not seen, so a folder the user closed stays closed (new test) |
−20 |
The status letter inside the label, after the indent (M lib.rs) |
TreeItem::prefix: the letter first, as gitui draws it (M lib.rs) |
±0 |
The diff's selection: an anchor, Diff::step and ten bindings (↑/↓, J/K, Shift+↑/↓, PgUp/PgDn, Home/End) over the list's own, the selected rows' style by hand |
list_rows(..).range(anchor), the theme's selected.range set to gitui's white on blue |
−45 |
The log's details: a switch between two copies of the commit list on one selection, a watch moving the focus to the copy shown, each copy with its own scroll |
one list and the details .shown_when(\|\| details) |
−25 |
The panes' on_click, to keep the app's focus signal in step with a click |
Node::on_focus on each pane |
±0 |
| Fixed 60/40 and 50/50 (gitui's layout not kept) | size_with (the lists 60/40, 40/60 while the staged list leads) and hidden_when (the diff alone while focused), as gitui |
+8 |
key_hints() in its own look (enter Stage · a Stage All) |
key_hints_with(..format(\|k, d\| "{d} [{k}]").key_symbols(true).separator(" ").more(more, "more [.]")): Stage [⏎] Stage All [a] Reset [D] … more [.], . expands it (new test) |
+12 |
The Files tab's switch per extension, keeping every text area it built |
keyed(\|\| extension, ..), the last one dropped |
±0 |
Tests changed, each where the component's behaviour is the better one:
the key bar's strings (gitui's look); the tree rows (+ notes.md,
M lib.rs: the letter first, as gitui); and two key sequences in the
staged list (w up for w down), because the selection now stays on
the file it was on (staged.txt) when src/lib.rs is staged above it,
where the index path stayed on row 0. FileTree.rows are keyed by path.
Dropped: J/K as a second selection key (gitui's are Shift+↑/↓).
Still hand-built, and why¶
titled(atextlayer over a panel's top edge).panel_withandtitle_withfollow state, but a panel's title is alwaysTitleone glyph in from the corner (┌─ Staged Changes ─); gitui's, as any ratatuiBlock's, is flush with the corner (┌Staged Changes──). The branch on the lists' edge and the commit popup's{main}and[subject length: N]stay layers too, flush right ({main}┘), for the same reason (add_title_withpads them).- The title's focus look still reads the app's
Focussignal: the panel combines its title with the edge's style, so a focused title iswhiteand an unfocused onebright_black, but gitui's focused title is bold; there is notitle.focusedstyle. Diff's hunk model (diff_rows, the markers lit for the cursor's hunk,jumpfor n/p, the hunk lines a range covers): the sectioned list of round 1's "Components that would help".- The app's
Focussignal and the watch that moves the real focus: gitui's focus decides the diff's file and the layout, andw,→and←set it.on_focuskeeps it in step with clicks;focus_withinis a read in a handler, not a signal. - The git layer (~630 lines) and
hunk_patch: the plugin gaps of round 1, unchanged.
New gaps¶
| Gap | Needed for | API sketch | Where |
|---|---|---|---|
A panel title flush with the corner, as a ratatui Block draws it, and titles flush with the right corner |
every pane's title and the {branch} on the lists' edge, with no layer |
.title_padding(0) / .title_inset(0) on a panel, or the theme's title.padding; or Title::flush() |
core |
| A focused panel's own title style | gitui's bold title on the focused pane | the theme's title.focused (combined with border.focused) |
core |
A tree's leaves are indented past the arrow column: + notes.md at the top level and M lib.rs under a folder, where gitui draws + notes.md and M lib.rs (and ▾src, the arrow on the name) |
gitui's tree look exactly | TreeKeys' sibling: TreeLook::leaf_indent(0), .indent(2), .arrow_gap(0) |
core |
A binding whose description follows state. The diff's Enter is "Add hunk" or "Remove hunk", s "Stage lines" or "Unstage lines": two bind_whens each. With show_disabled(true) the bar would grey the other one beside it, so the clone leaves it off, and Commit is hidden rather than greyed as gitui does |
gitui's bar with greyed commands | bind_with(keys, \|\| desc, f), or show_disabled leaving out a disabled hint whose keys an enabled one has |
core |
Size::Percent children of a column can leave a row unused (60% + 40% of 21 rows is 12 + 8); ratatui gives the rest to the last. The clone uses Flex(3)/Flex(2) |
percentages that fill | the last percentage child takes the rounding's remainder | core |
| A modal's size clamp (round 1) is still missing | the branch popup | ModalOptions::min(w, h) |
core |
Bugs found¶
None blocking. The rounding above is the only surprise.
Components that would help¶
| Component | What it would replace or enable | API sketch | Where | Value |
|---|---|---|---|---|
| Panel titles in ratatui's placement | titled, the branch and warning layers (~40 lines) |
as above | core | high |
| A sectioned list (round 1) | Diff's markers and jumps, now on range (~150 lines) |
list(..).sections(\|i\| section), a gutter marker per section lit for the cursor's, next_section/previous_section, a range kept within one |
core (sectioned list) + plugin (diff) | high |
| The git plugin (round 1) | Git (~630 lines), hunk_patch |
as in round 1 | plugin | high |
| A reactive focus read for containers | the app's Focus signal and its watch |
focus::within(id) -> bool as a tracked read (as focus::has_focus is for one node) |
core | medium |