provui
An unopinionated UI composition layer over prov —
a structural editor for prov documents (embedded metadata + prose body) and for
the workspace config that governs them.
provui is the composition, not the app. It deliberately lacks any particular product's style and user-friendliness; it exposes prov's structure directly, so that the foundation can be validated on its own and reused by more than one frontend.
The shape
Everything under the UI is Rust, and each layer already exists and is tested:
prov (workspace / document library) ┐
flower-core (structural metadata editor) ├─ provui-core ─ a frontend
leaf-core (rich-text body editor) ┘
provui-core is where the three meet. It owns the composition and the
translation between them — flower-core never learns the word "prov", and prov
knows nothing about editors — so a frontend is left with drawing and input
handling and nothing else.
ProvBackend— the flower↔prov bridge: aflower_core::Backendover prov's carrier-awareMetaEditor. Lossless, so comments, key order, the metadata carrier and the prose body all survive an edit. It is the second implementation of that trait, and it is checked against flower's own conformance suite rather than against a restatement of it, so a guarantee added upstream arrives here as a failing test. It also answers flower's two document-shaped questions: what a picker on a reference field should offer (candidates, injected byset_candidatesbecause one document has no way to enumerate a workspace) and what a list item is, across a reorder (item_key— a link's target, which survives a relabel).DocumentSession— one open prov document, edited through a flower metadata model and a leaf body editor, reconciled on save — with one undo across both, kept as a journal of which editor took each step. The two regions share no byte offsets, so they edit independently and meet only atsave, which splices the body back in and writes the reassembled document. A disk round-trip test (open_edit_save_reopen_round_trip_on_disk) proves open → edit both → save → reopen with comments, fences, untouched keys and untouched body all preserved.schema_from_config— the adapter turning a resolved provWorkspaceConfig(plus the vocabularies its controlled fields point at) into a genericflower_core::Schemafor the workspace's content documents. This is where prov's controlled vocabularies and its spanning relation reach the UI, so a frontend renders term pickers and link widgets instead of text boxes.schema_for_documentis the same adapter for one document in particular: prov 0.12 lets a field be declared several times, eachunder:an index, so whichstatusa document gets — a task's terms, a proposal's, or none — is a fact about where it sits, andWorkspaceView::schema_foranswers it per document.config_schema— the same trick turned on the config document itself. A prov config is a document, and flower can already render, type-direct and validate any prov document; the only thing missing was a schema saying thatfixityis one of two words. With it,id_storagebecomes a picker instead of free text, and a typo likefixity: alll— which prov silently ignores, keeping the default — stops being reachable.facets— what each frontmatter key is to prov: a relation, a one-way pointer at machinery, identity, the policy block, a declared field, or a value prov only carries. Read off the workspace's own vocabulary rather than a list kept here, so a workspace that retractslink_ofgets an ordinary field and one that declaressee_alsogets a followable link, without a line changing.links— the links a document's frontmatter declares, each carrying the metadata path it sits at. prov already extracts a document's edges; what an editor additionally needs is where each one is, so that "the row under the cursor — is that a link?" is a question with an answer. Lexical throughout: no filesystem, no registry, no claim anything exists.body_links— the same question asked of the prose, answered with a byte range into the body instead of a metadata path. The scan is prov's own (scan_body_links, the seam its census, check and rename all use), so it is code-aware, reaches into footnote definitions, and never mistakes bracket prose for a link — three bugs a second implementation here would have had to find again. The two halves share a target and nothing else, andAnyLinkis what that sharing is called:WorkspaceView::resolvetakes either, so a[[a.md]]in a paragraph and an[[a.md]]incontentsland in the same place by construction rather than by agreement.findings— what prov's integrity check says about one document, placed where an editor can draw it, and then given to that editor —DocumentSession::apply_findingswashes the body half under the prose as leaf highlights and hangs the metadata half on the rows as flower annotations, so a frontend gets markers and messages without drawing either. prov'sFindingnames the document and, for a link, the site — a relation's name, or a byte span in the body. A relation's name is one step short of a place:contentsis a list, and the broken item is the third one. So this recovers the index by matching the finding's target against the links that key actually holds, which is exact unless the list repeats a target — and where it does, the key alone is the honest answer rather than a guess. prov has no severity of its own; the error/warning split is this crate's, it is a rendering hint that suppresses nothing, and it lives in one list so that a frontend does not grow its own.WorkspaceView— the step that needs a workspace to take it in. It finds the workspace a document belongs to, resolves the effective config and the vocabularies it points at, and turns a link into a document you can open — absolute, and checked against the disk. Read-only, and that is not temporary: see Scope.
Every spelling in config_schema is prov's own. The term lists mirror
prov::diagnose's accepted values, and the tests assert exactly that: each
offered term is round-tripped through prov's linter, so a spelling prov renames
fails the build here rather than drifting into a picker that writes values prov
ignores.
Scope
The single-document metadata surface — prov's edit layer — plus read-only
navigation across documents.
Following a link reads. Retargeting one does not: a relation field is half of a
pair prov maintains bidirectionally, so writing contents in one document means
writing part_of in another, and that is prov's mutate layer. The metadata
backend here edits one document's bytes and has no way to touch a second, which
is exactly why the line is where it is. A frontend may follow a link with what is
here and must not conclude it can retarget one.
Producing link text is still reading. WorkspaceView::reference_to says what
a link to a document would be spelled like in this workspace, and writes
nothing — not even the id an id-addressing style would need, since minting one
is a write: an unregistered target degrades to a path link, which is what prov's
format_reference does when handed no id. What the caller does with the string
is the caller's business.
Saving writes bytes directly. A frontend that wants fixity and updated
restamping maintained routes the write through prov's Storage/mutate layer
instead; this is the floor it builds on, not a policy it inherits.
Structure, values, and what this crate refuses to decide
A prov document's frontmatter holds two kinds of thing side by side, and they
look identical: keys prov reads to build the workspace (contents is an edge,
id is identity, prov: is policy) and keys prov merely carries (mood: rainy). A schema-free editor draws id and mood as the same row and offers to
let you type into both.
facets is the answer to which is which, and it is only the answer.
Nothing in this crate hides a row, sinks one, reorders them, or makes one
read-only — even where it plainly knows enough to. Facets will tell you that
id is minted by the workspace and that contents is structure, and hand you
those lists already shaped for flower's derived and demoted sets, and then
stop.
That is a deliberate answer to a real question. An application over prov usually does separate the two halves — diaryx puts prov's structure in a sidebar and gives the form to the user-defined values — and it is a good design. It is not a general one. A mobile inspector, a 30-column terminal sidebar and a settings sheet do not want the same split, and a core that picked one would be a core each frontend had to work around. So the classification is general and lives here once; the arrangement is local and lives in the frontend.
What that buys is measured in lines. provui-tui's whole policy — the keys the
workspace maintains decline edits, and prov's structure sinks below the
document's own values — is two:
let mut session = open_managed?;
session.metadata_mut.set_demoted;
A frontend that wants a flat list writes neither.
The same principle is why document_rules and config_rules are public and
first-match-wins, and why rules is public at all: see
Composing over it.
Frontends
The core is frontend-neutral, and the plan is to prove that by using it twice.
First: a TUI — provui-tui, which exists (see Usage below). It
embeds leaf-ratatui and
flower-ratatui — the two widget crates
that already exist for exactly these two editors. No FFI: it is one Rust binary
linking one copy of each library, which makes it the cheapest possible test of
whether the composition holds up under a real event loop, and the fastest thing
to iterate the core against.
Later: SwiftUI over UniFFI, against the same core. The hardest binding
already exists — leaf ships leaf-ffi + leaf-swift (LeafUI), a full
rich-text body editor for Apple, and flower ships flower-ffi. What that
milestone adds is a single provui-ffi wrapping DocumentSession, so the whole
editing composition stays in Rust and fig/twig are linked once. Orchestrating
several FFI stacks from Swift instead would risk duplicate native libraries in
one binary, which is the failure this arrangement exists to avoid.
Both frontends drive the same DocumentSession. If the TUI needs something the
core does not expose, that is the core's gap, and fixing it there is what makes
the second frontend cheap.
Usage
provui-tui's binary is provui, following the family: leaf-tui's is leaf
and flower-tui's is flower.
It finds the workspace the file belongs to, opens the file through
DocumentSession under whatever schema that workspace implies, and draws the
document's two regions with the two widgets that exist for them —
leaf-ratatui over the prose, flower-ratatui over the frontmatter. Everything
about the document belongs to the session; the binary owns the terminal, the
split, the focus, the navigation and one status line, and nothing else.
There is a smaller door for looking rather than editing: cargo run --example inspect -p provui-core -- <file> prints what each frontmatter key is to prov and
where each of its links lands, which is the whole of facets + links +
WorkspaceView in forty lines of caller.
A file that belongs to no workspace still opens — that is the ordinary state of a
markdown file — with no schema and with links resolved by path alone. A ⌂ at
the head of the status line is how it says which of the two you are in, because
that is the fact that decides whether there are pickers and whether id: links
resolve.
The panes
▶ leaf — body │ flower — document.md ●
# Heading │ ‹document›
│ title New Title
Original body. │ draft true
│ part_of ↑ The Vault
│ id ajp7eq
│
│ j/k · l/h in/out · e edit · x del
⌂ document.md ○ saved focus: body ^W pane · ^S save · ^Q quit
The body on the left, the metadata on the right. The prose is the document
and reads left to right; the frontmatter is what is true about it, which is what
a sidebar is for. The metadata pane takes a third of the width, bounded to 30…80
columns — the floor is where a key … value row stops being readable, the
ceiling is where a wide terminal would be drawing pad between the two columns —
and the body gets every column the pane and the divider do not. Both get the
full height, which is the dimension a page of metadata actually spends: the
model's inline budget is refit to the pane's height on every frame
(flower_ratatui::page_room says how many item rows survive the widget's own
chrome), so on a tall terminal the whole frontmatter is drawn on one page with
nothing to drill into.
The cost is flower's own two-pane page view, which wants 64 columns and does not get them from a third of an ordinary terminal. It falls back to its single-pane layout — the same interaction in one column — and the split view comes back from about 190 columns. When the terminal is too narrow for both minimums the split is abandoned rather than shrunk, and whichever pane holds the keyboard takes the screen. A whole-file config document has no prose region at all, and is all metadata.
Focus
Exactly one pane owns the keyboard. ^W switches it — the window key, in a
host that has windows. The status line always names the pane that has it, and
the focused pane's label carries a ▶.
^W is taken by the host before either widget sees the event, and it has to
be: leaf swallows every Ctrl and Alt chord it is handed, bound or not, so a host
cannot discover a free one from the return value; and flower reads key.code
while ignoring modifiers entirely, so an un-intercepted ^X would arrive as x
and delete a key. ^W is unbound in leaf's Ctrl table and is not a bare letter
for flower to navigate on, which is what makes it free to take.
A click also moves focus to the pane it lands in. A focus switch is refused while the metadata pane has a value open for editing — leaving mid-edit would strand a half-typed value in a pane no longer taking keys — and says so.
| Key | |
|---|---|
^W |
switch panes |
^S |
save the document — both regions, from either pane |
^G |
follow the link under the cursor — the metadata row, or the body link the caret is inside |
^O |
back to the document you followed from |
^R |
show the link text that points at the caret |
^Z |
undo — one history over both panes |
^Y / ^⇧Z |
redo |
^Q |
quit; refused once while there are unsaved changes |
| body pane | leaf's keys (leaf --help) |
| metadata pane | j/k move · l/h in/out · e pick or edit · E type · x delete |
One undo, over two editors
Each editor keeps its own history: leaf's is twig's, flower's is a journal of inverse ops. Neither knows the other exists, which is right — and it means that left alone, undo would mean the pane you are standing in. A reader who typed a sentence, fixed a frontmatter value, typed another sentence and pressed undo twice would get two sentences back and keep the metadata edit.
So ^Z and ^Y are taken by the host, before either widget — and unlike ^W,
^G, ^O and ^R, they are taken in order to take them away from a widget
rather than because no widget wanted them. leaf binds ^Z/^⇧Z/^Y for the
body, and flower binds u/U for the metadata; the host's chord asks the
session instead, and gets undo that is about the document the way save already
is.
DocumentSession keeps the whole of what makes that work: a journal of which
editor took each step, and nothing else. The host calls sync_history() once
per event, which reads both editors' change counters (Doc::revision,
Model::edit_seq) and records whichever moved; undo() pops the most recent
entry and calls that editor's own undo. Neither editor is reimplemented and
neither is second-guessed: a workspace-maintained key still refuses its undo,
because flower replays the inverse through the same backend the edit went
through.
It is polled rather than pushed because there is nothing to wrap. A keystroke
reaches leaf and flower through their own handle_key, and a host holding
body_mut() and metadata_mut() can edit through either without passing
through this crate. A counter both editors already expose is the seam that
needed no cooperation. A host that forgets to call sync_history loses undo; it
cannot get the order wrong, which is the failure worth designing against.
The one limit is leaf's to set, and it is stated rather than papered over.
leaf coalesces keystrokes into steps on its own schedule, so a Body journal
entry is not a leaf step and a count of entries is not a count of undos. What
undo() does is take one leaf step — never a keystroke — and then walk past
whatever further Body entries leaf has nothing left to answer for, so a
metadata edit is never stranded behind a word someone typed. flower has no such
coalescing: one commit is one step.
That asymmetry is also why a body step that changes nothing and a metadata step
that changes nothing mean different things. flower's history_len is its actual
journal, so nothing happening there is a refusal and stops the walk; leaf's
can_undo is a step counter that can overshoot a coalesced run, so nothing
happening there is exhaustion and the walk carries on.
flower's own u/U still reach the metadata pane and move only flower's
history. The session records those as fresh Meta steps, which is what flower
itself says an undo is — "a change, not a rewind".
Picking a link instead of typing one
A relation's row is a link, and a link to a document in this workspace is a
string a person should not have to spell. flower's e opens a picker where
the field has something to pick from and the text line where it does not, so
there is one key for both and this host binds nothing new for it.
What a reference field has to pick from is other documents, which flower-core
— one document, no filesystem — can never enumerate. So it asks the backend, and
ProvBackend answers from a map it was handed: WorkspaceView::candidates_for
walks the workspace's reachable documents (prov's own population, so the config
document the root points at is one of them), and each candidate's value is
exactly what WorkspaceView::reference_to would write — markdown or wikilink,
by path or by id, labelled with the target's own title, in this workspace's
style. A document whose part_of was chosen from the list is therefore
indistinguishable from one whose part_of was typed by hand correctly. The
label is the title, which is what the filter matches; the detail is the path,
which is what tells two documents with the same title apart.
The walk is paid at open and never on a keystroke. Nav::open builds the
map once and the backend answers every picker from it by relation name, so
contents, contents[4] and the append position are one entry and opening the
picker is a lookup. The staleness that buys is the staleness the per-document
check already has: a document created in another window is not on the list until
this one is reopened.
A field with a controlled vocabulary never reaches the backend at all —
flower asks its own schema first, and a prov vocabulary is already a
Constraint::Enum there. That includes a reified vocabulary, whose terms are
documents: schema_from_config emits the field's rule before the relation's and
a schema is first-match-wins, so the picker shows the vocabulary's terms rather
than every document in the workspace. ProvBackend::relation_at reads the same
rule, which is what keeps the two from ever disagreeing about which one wins.
Choosing writes a value through the ordinary commit funnel, so everything that was already true of a typed value is still true of a chosen one: the schema validates it, a workspace-maintained key refuses it, and the splice is lossless.
Which item of a list is which
A metadata path addresses a sequence item by position, so reordering
contents re-points every path after the item that moved: a page opened on
contents[1] goes on showing contents[1], which is now a different document.
flower asks the backend for a stable identity instead (Backend::item_key), and
for a relation's list ProvBackend answers with the link's target.
That is the right identity for exactly the two edits a reader makes to a link
list. A reorder carries the page and the cursor along with the item, because
the target went with it. A relabel — [The Vault](/README.md) becoming
[Home](/README.md) — moves nothing, because it is one edge with a different
word on it and the word is the part being edited. flower's own fallback would
have been the item's whole text, which changes when the label does.
The #locator is stripped, so a.md#one and a.md#two are one identity and
the first of them wins — item_key's documented behaviour for a repeated key.
Two items pointing into the same document are two ways of saying where to look,
and a page that lands on the first has landed in the right document. A list that
is not a relation's gets no answer here and keeps flower's own.
Following links
A prov document carries links in both of its regions. Some frontmatter keys are
links, and so is a [label](target) or a [[target]] written in the prose; a
workspace is what makes either resolvable. ^G opens the document the focused
pane's cursor is on and ^O returns, which makes this a two-key browser
over the whole document graph: ^G on part_of goes up, ^G on a contents
item goes down, and ^G with the caret inside a link in a paragraph goes
wherever the prose pointed.
Both are taken before the widgets for the same reason ^W is, and both are free
in leaf's Ctrl table — ^G for go, ^O for the jump-back every vi has. The
back chord is advertised in the status line on arrival rather than in the
standing hints, which is exactly when there is something to go back to.
Each pane follows its own cursor, and never the other's. A body caret in the
middle of a paragraph is no evidence about which frontmatter row was last
selected, and following one from the other would be the host guessing. So the
metadata pane follows the row it is standing on, the body pane follows the link
the caret is inside — half-open, so a caret just past the closing bracket is
past the link — and a cursor on neither says no link under the caret or not a link rather than picking something.
A link that lands somewhere that is not a file you can open says that too — a
URL, a #locator into this document, a reference into a workspace prov cannot
locate, or a target that is simply not on disk. Each of those is a real answer
rather than a failure, and the status line gives it.
Images are not followed. An  is one of prov's body links, but
it names a payload rather than a document, and opening a picture in a text
editor is not what the chord promises; prov's own census leaves them out for the
same reason.
A link to here
Following a link is half of navigating a workspace; the other half is writing
one, and that starts with knowing what to write. ^R puts the link text that
would point at where the caret is into the status line — link to here: [Crash Safety](#crash-safety).
The locator is the heading at or above the caret, slugged with prov's own
link::slug, which is the spelling that matters: it is the fragment prov check resolves and the fragment leaf's Doc::locate lands, and for Markdown —
where there are no ids to name — a heading's own words are the only thing a
fragment can name. Above the first heading there is no place to point at, and
the answer is a reference to the document as a whole.
The rest of the spelling is the workspace's. WorkspaceView::reference_to
asks prov for the effective reference_style and writes markdown or wikilink,
by path or by id, root-relative or document-relative, labelled or bare,
accordingly — with the target's own title as the label, falling back to
link::path_to_title. A workspace that addresses by id gets one only if the
target is already registered: minting an id would be a write, and this stays
read-only, so an unregistered target degrades to a path link, which is what
prov's format_reference does when handed no id. With no workspace it is a
relative markdown link, which is the only form two paths alone can justify.
Showing it is the whole deliverable. This host has no clipboard — ^C and
^X in the body already say so — so the status line is the honest maximum, and
it is the terminal that copies from there. A frontend with a clipboard calls the
same provui_core::reference_here and puts the string on it.
^R is free by the same two tests ^W and ^G passed: unbound in leaf's Ctrl
table, and r is not a bare letter flower navigates on, so an un-intercepted one
would reach flower as a plain r and do nothing. ^L and ^K were the other
candidates and both fail a test — ^K is leaf's kill-to-end-of-line, and l is
how flower opens a row.
Leaving a document with unsaved changes is refused, with no second-press escape hatch. Quitting has one because quitting twice discards work you were told about and meant to discard; following a link is a reading gesture, and an edit lost to one would be an edit lost to something nobody thinks of as destructive.
Findings
prov can say what is wrong with a document — a link that resolves to nothing, a
term outside a closed vocabulary, a child that does not link back — and
WorkspaceView::findings_for runs that check for one document and places each
answer. The host runs it on open and after every save, and nowhere else:
Workspace::check is reachability-bounded, so starting it at the document is
one document's worth of work for a leaf note and a subtree's worth for an index,
which is a price worth paying at the two moments the structure actually changed
and not on a keystroke.
That bound is also why the answer is narrower than prov check over the whole
workspace: a finding lodged against this document by a walk that started
somewhere else — a parent reporting that this document does not link back — is
not reachable from here and does not appear. What does appear is everything this
document declares.
Each half goes to the editor that owns that half of the document, and the
host draws neither. Body findings become leaf highlights, washed under the
link they are about — the spans prov reports are already body offsets, so
nothing converts. Metadata findings become flower Annotations at the same
metadata path, so the row contents[2] was narrowed to gets the marker, and the
widget puts the message in its own footer when the cursor reaches it. The host's
status line carries the count and nothing else: that there is something to
go and look at is the one part of this no widget can say, because neither of
them can see the other's half.
session.meta_findings() and meta_finding_at are still there and still the
prov-side answer — the kind and the severity rather than the sentence — for a
frontend that wants to branch on a finding rather than draw it.
provui_core::annotations_of is the translation on its own.
A finding about the file rather than about anything written in it — an orphan,
a fixity mismatch — becomes an annotation at the empty path, which is
flower's spelling for the document. Nothing draws the root, so those stay the
host's to report from session.findings(); making one of them a row would be
inventing a row prov never named.
One consequence of both editors' design is worth stating: Doc::set_highlights
and Model::set_annotations each replace the whole set rather than adding to
it — deliberately, so the host and the document can never disagree about what is
on screen — so apply_findings owns both lists. A frontend that also wants
search hits in the body or annotations of its own on the rows composes the list
itself and calls leaf and flower directly.
Saving, and what is not here
A save from either pane writes the whole document: DocumentSession::save
reconciles the body edits back into the metadata editor's document and writes the
reassembled bytes, so the unit that gets saved is the file, not the pane you were
standing in. Dirtiness is likewise the session's answer, covering both regions.
leaf's own Doc::save is deliberately unused — this body is a region of a
file rather than a file, and the Doc has no path.
leaf's Outcome is a full editor's surface, and leaf-tui is where all of it is
handled. This host implements the three outcomes that are about the document —
Save, Quit, Continue — and degrades the rest to a status-line message
rather than dropping them: Copy/Cut/Paste, SaveAs, New, the link,
language and media prompts, the command palette, Find/Replace, Help, and
the right-click context menu all say what they are and where they live. A key
that does nothing here is at least a key that admits it.
Two things do work without any of that: bracketed paste is enabled, so the
terminal's own paste arrives as one Event::Paste and goes into the body as a
single edit rather than as N keypresses; and mouse capture is on, so leaf gets
click-to-place-caret, drag-select and scrolling.
flower takes no mouse events, so the host maps a click in its pane back onto
the row it landed on and drives the model in the vocabulary flower's keys use.
A click stands on a row; a second click on the row the cursor is already on is
Enter — a container opens as a page, a value opens for editing; the wheel is
j/k, and works without taking the keyboard. In flower's two-pane view the
other half is one step along the lineage, and a click there takes it: a row of
the parent's page on the left backs out onto it, a row of the previewed page on
the right opens it there. The click that brings the keyboard to the pane only
ever stands, whatever row it landed on — focusing a pane is not Enter. A value
that is open for editing stays open, and its row stays put, until Enter or Esc.
Composing over it
prov permits a config surface to carry keys it never reads, so an application
that keeps its own block (myapp.default_view, myapp.publish, …) supplies its
own rules for it. config_schema governs none of them — its vocabulary is
prov's, and an app's is the app's.
A schema resolves a path by first match wins, so an application prepends
its rules to config_rules:
let mut rules = my_app_rules; // `myapp.*`, and any narrowing
rules.extend;
let schema = new;
Prepending is what makes it an overlay rather than only an addition: an app that wants a narrower vocabulary for a key this crate governs openly can shadow the generic rule. Appending would leave the generic rule winning and the app's rule silently dead — which is why there is a test asserting the order.
document_rules is the same door one document over, for a content
document's frontmatter, and the ordering inside it is the same argument made
twice. A workspace's fields declarations come first, prov's own kernel keys
(title, id, content/manifest/attachment/content_hash, and the root's
inline prov: block) come last — so a workspace that declares fields.title
shadows prov's rule for it rather than being shadowed by it, and an app that
prepends shadows both.
That the inline prov: block is governed at all falls out of stating the
vocabulary once. prov's spec says workspace policy has two homes and the same
keys in each — nested under prov: in the root, at top level in a config
document — so kernel_rules re-roots config_rules one key deeper rather than
keeping a second copy. A term added to the config schema reaches the inline block
in the same commit, because it is the same list.
The provui_core::rules module is public for the same reason: an overlay's rows
should come out looking like the ones beside them, with the same tints and the
same consequence vocabulary, without every frontend restating what a "costly"
field looks like.
Related repos
prov— the self-describing plaintext workspace library.flower— the generic structural config editor overfig(hasflower-ratatui,flower-ffi).leaf— the rich-text document editor overtwig(hasleaf-ratatui,leaf-swift).fig/twig— the Zig parsing/editing libraries underneath both.
Building
The dependency chain reaches fig and twig, which are Zig-backed, so a build
needs zig on PATH. nix develop in prov or
nix provides one.
Everything here is consumed from crates.io by version, leaf-ratatui and
flower-ratatui included; no manifest carries a path across a repository
boundary, so nothing has to be undone to publish. To build against a checkout
of leaf or flower instead — for a widget change that is not on the registry
yet — turn on the [patch.crates-io] in ~/diaryx/.cargo/config.toml, copied
from ~/diaryx/.cargo/patches.toml, with four entries uncommented:
= { = "leaf/crates/leaf-core" }
= { = "leaf/crates/leaf-ratatui" }
= { = "flower/crates/flower-core" }
= { = "flower/crates/flower-ratatui" }
The two cores have to be patched alongside the widgets, not just the widgets.
Each widget path-depends on its own core inside its own workspace, so patching
only the widget leaves the graph holding two copies of that core — a registry one
under provui-core and a path one under the widget — and Model and Doc stop
being the same type across the two. It surfaces as a baffling type error rather
than as anything mentioning duplicate crates. cargo tree -i leaf-core should
show exactly one. The patch must be off again before any publish: cargo publish verifies by building, and would build against it.
CI and releasing
cargo xtask ci runs what CI runs, in the same order — format, clippy, tests,
docs, provui-core checked on its own, and the MSRV — and cargo xtask <job>
runs one. .github/workflows/ci.yml reads the job list from cargo xtask ci-matrix, so a job is added or renamed in xtask/src/main.rs and nowhere
else.
Releases are cut with the org's shared tooling — dx release <spec> bumps,
regenerates docs/CHANGELOG.md, commits and tags, and
pushing the tag runs publish.yml, which uploads provui-core. provui-tui is
publish = false and stays in the checkout; its manifest says why.
License
MIT or Apache-2.0, at your option.