Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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: a flower_core::Backend over prov's carrier-aware MetaEditor. 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.
  • DocumentSession — one open prov document, edited through a flower metadata model and a leaf body editor, reconciled on save. The two regions share no byte offsets, so they edit independently and meet only at save, 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 prov WorkspaceConfig (plus the vocabularies its controlled fields point at) into a generic flower_core::Schema for 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.
  • 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 that fixity is one of two words. With it, id_storage becomes a picker instead of free text, and a typo like fixity: 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 retracts link_of gets an ordinary field and one that declares see_also gets 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.
  • 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.

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, an 11-row terminal band 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 = DocumentSession::open_managed(path, schema, facets.managed_key_names())?;
session.metadata_mut().set_demoted(facets.structural_keys(session.meta()));

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 TUIprovui-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.

cargo run -p provui-tui -- path/to/document.md

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

 flower — ▶ document.md ●            ┐
 ‹document›                          │  metadata band: a third of the height,
  title      New Title               │  bounded to 9…14 rows
  draft      true                    │
  part_of    ↑ The Vault             │  prov's structure, sunk below the
  id         ajp7eq                  │  document's own values
  j/k · l/h in/out · e edit · x del  ┘
   leaf — body                       ┐  body label (▶ marks the focused pane)
 # Heading                           │
                                     │  the body gets every row the band and
 Original body.                      │  the status line do not
                                     ┘
 ⌂ document.md ○ saved  focus: body   ^W pane · ^S save · ^Q quit

A horizontal band, not a side-by-side split. That is the widgets' decision rather than a taste: flower collapses its own two-pane page view below 64 columns, and half of an 80-column terminal is 40 — so a vertical split would silently degrade the metadata view on the most ordinary terminal there is. Prose wants the width too. Stacking gives both panes the full width and spends the one scarce dimension, height, on the surface that is the point.

The band is sized against flower_ratatui::page_room, which says how many item rows survive the widget's own three rows of chrome, and the model's inline budget is refit to the pane's height rather than the terminal's on every frame. The floor of 9 rows is where flower's budget stops using extra room anyway; the ceiling of 14 is where a band of mostly-empty list starts costing the prose. When the terminal is too short 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 metadata cursor
^O back to the document you followed from
^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 edit · x delete

Following links

Some of a prov document's frontmatter keys are links, and a workspace is what makes them resolvable. ^G opens the document the metadata cursor is standing on and ^O returns, which makes this a two-key browser over the spanning tree: ^G on part_of goes up, ^G on a contents item goes down.

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.

Following is the metadata pane's gesture: from the body there is no row to be standing on, and following whatever the other pane was last left on would be a guess, so the host says so instead. A row that is not a link says that too, and so does a link that lands somewhere that is not a file you can open — 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.

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.

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.

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(provui_core::config_schema::config_rules(&config));
let schema = flower_core::Schema::new(rules);

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 over fig (has flower-ratatui, flower-ffi).
  • leaf — the rich-text document editor over twig (has leaf-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.

cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings

provui-tui additionally needs the two widget crates, which are not on crates.io yet. They are consumed the prepublication way — by the version each repo declares, with a [patch.crates-io] supplying it — so that no manifest here carries a path across a repository boundary and nothing has to be undone to publish. In this working tree that patch is ~/diaryx/.cargo/config.toml, copied from ~/diaryx/.cargo/patches.toml with four entries uncommented:

leaf-core      = { path = "leaf/crates/leaf-core" }
leaf-ratatui   = { path = "leaf/crates/leaf-ratatui" }
flower-core    = { path = "flower/crates/flower-core" }
flower-ratatui = { path = "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.

provui-core alone needs none of this: it depends only on published crates.

License

MIT or Apache-2.0, at your option.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages