calcli 0.4.0

Fast terminal calculator (TUI) with history, variables and engineering helpers
Documentation
# Development

How calcli is put together, and what to touch when you change something.

## Gates

All three must pass before a change lands:

```sh
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
```

`cargo deny check` runs the advisory and licence audit against `deny.toml`. It is not installed by default (`cargo install cargo-deny`) and there is no CI workflow in this repository, so it is a local step.

Then drive the real thing – tests do not catch a broken layout:

```sh
cargo run -- --demo
```

## Layout

Dependencies point inward, toward `domain`. Nothing below a layer knows about the layer above it.

```text
src/
  main.rs        composition root: config, state, service, TUI, save on exit
  lib.rs         APP_NAME/APP_VERSION, and `pub use ratada::theme`
  keymap/        mod (Context, Scope, Keymap) + catalog (the action table)
  demo.rs        the --demo session

  domain/        pure, no I/O
    errors.rs      AppError: only domain failures
    evaluator.rs   the Evaluator trait + the meval implementation
    units.rs       the rink-core wrapper (conversion, unit arithmetic)
    expression.rs  preprocessing, `ans`, assignments, comments
    format.rs      how a value is rendered
    highlight.rs   per-character token classification
    history.rs     the entries and their replay
    quantity.rs    a value with an optional unit
    variables.rs   the store

  services/      orchestration, no I/O
    calc_service.rs   the façade: submit / edit / delete + recompute, settings
    eval.rs           the evaluation router: meval vs. rink, unit literals
    mod.rs            the error funnel: StorageError -> AppError::Storage

  storage/       persistence
    repository.rs   the StateRepository port and the on-disk shape
    toml_state.rs   the TOML adapter, writing atomically
    errors.rs       StorageError, which reaches no layer above the service

  config/        defaults -> config.toml -> CALCLI_*
    mod.rs          Config, the calcli theme, and the palette/keymap helpers
    appearance.rs   theme name, glyphs, palette overrides
    highlight.rs    the eight token colours
    loader/         mod (entry points + merge), raw (the on-disk shape),
                    legacy (the 0.2 shim), validate (unknown colours), env

  tui/           the Ratatui front-end
    app/           App: the state in mod.rs, the handlers in child modules
                   (dispatch, render, hints, calc_input, calc_history,
                   variables, settings, clipboard) - see below
    appframe.rs    the shared frame every view draws through
    bindings.rs    which actions each view hints at
    interaction.rs the port the blocking dialogs open through
    colors.rs      the syntax-highlight styles and the caret colours
    text_edit.rs   the per-character colouring of a wrapped value (see below)
    views/         calc/ (mod, history, input, completion), variables, settings

  util/          fs (atomic writes), paths, logging
```

## The toolkit

The terminal toolkit comes from [`ratada`](../../../libs/ratada): the terminal guard and event loop (`Tui`, `Screen`, `run`), the widgets (`list`, `tabs`, `statusbar`, `scroll`, `chrome`), the blocking modals and the help overlay, the theming (`Palette`, `Skin`, `Glyphs`, `ThemeRegistry`), the shortcut hints, the chord grammar (`keymap`), the modifier rules (`input::is_command`, `input::is_bare_character`), the quit machinery and the clipboard. `crate::theme` is a re-export of `ratada::theme`, so nothing reaches for a second path.

Before writing a widget, look for it in `ratada`. If it is missing, extend the library rather than keeping a copy here.

**The one deliberate exception** is `tui/text_edit.rs`, and it covers the *rendering* only. calcli colours its input per character (functions, constants, operators, numbers, variables, units, `ans`, comments), and `ratada::input::InputField` / `ratada::textarea::TextArea` render plain text with no per-token style hook. Everything else the old calcli had – its own colours, modals, help overlay, terminal guard and clipboard – is gone in favour of the toolkit.

The module used to be a fork of the whole edit core: its own `TextCursor`, `EditMode`, `apply_edit_key`, `handle_clipboard`, `replace_selection` and soft-wrap arithmetic, roughly 400 of its 685 lines duplicating `ratada::input` and `ratada::textarea`. That fork is gone; what those names refer to now are re-exports, and the file is down to the span builders that actually need to exist here.

Removing it needed one addition to the toolkit. The input holds an **expression**, which is one logical line by definition while being too long to sit on one row. `EditMode::SingleLine` navigates wrongly there (`Home`/`End` would jump the whole value rather than the display line), and `EditMode::Multiline` lets `Enter` and a pasted newline put a `\n` into a buffer that must never contain one. `ratada::input::EditMode::Wrapped` is the mode that sits between them: it navigates like `Multiline` and keeps `SingleLine`'s newline rule.

The key rules in particular are never reimplemented here: `ratada::input::is_command` decides whether a key is a chord, rather than a hand-rolled `CONTROL` test. A terminal reports `AltGr` as Control+Alt, so a plain Control check swallows the characters it produces (`@`, `\`, `[`, `~` on a German layout) instead of typing them.

## The action catalog

`src/keymap.rs` is the single source of truth for shortcuts. Each `Action` row carries its `[keys]` config name, its human description, its **scope** and its default keys. From that one table come the key dispatch, the footer hints, the help overlay and the `config` documentation.

The chord grammar underneath is not calcli's: parsing a `[keys]` string, rendering a key, merging the defaults with the overrides and detecting a shadowed binding all live in `ratada::keymap`, which every app on the toolkit shares. calcli implements `ratada::keymap::Action` for its own enum and keeps what is genuinely its own – the catalog, the scopes and the lookup.

calcli is modal, which a flat chord-to-action map cannot express: `Enter` submits an expression in the input, applies an in-place edit, edits a line in the history, inserts a name in the variables list and steps a value in the settings list. So each action names a `Scope`, and a key is resolved within the scopes active in the current `Context`. `Keymap` is therefore a newtype around `ratada::keymap::Keymap<Action>` rather than a bare alias: the toolkit holds the bindings, while the context-aware lookup stays here, where `Scope` lives.

The same split governs conflicts. Two bindings only *conflict* when their scopes can be active at the same time – a rule the toolkit cannot know, so `Action::overlaps` hands it down and the toolkit's check applies it. A conflict is logged, and the earlier action in the catalog keeps the key.

Two rules the catalog enforces:

- A bare printable character never triggers an action while a text field has the keyboard. That is what keeps `q`, `?`, `y` and `d` typeable inside an expression, and why the view tabs are `Alt+N` rather than bare digits. The test is `ratada::input::is_bare_character`, re-exported here; it is stricter than "not a chord", because an `AltGr` character must type rather than dispatch.
- `Ctrl+Q` is never bound. It is the toolkit's unconditional escape hatch, and it is reported by `shortcut_hints::global_bindings()` together with `F1`. Both appear in the closing `Global` group, which `App::global_group` builds once and hands to *both* the footer and the help overlay – they cannot drift apart.

When you add or rebind a shortcut, update `src/keymap/catalog.rs`, then the key tables in `README.md` and the `[keys]` block in `examples/config.toml`. The footer and help follow on their own – and `tests/docs_in_sync.rs` fails if the two documents do not, so this is a checked requirement rather than a request.

## The app frame

`tui/appframe.rs` draws the frame every view sits in, so the app looks the same wherever you are: a tinted header panel carrying **only** the brand and the tab bar, the raised content surface, a tinted status band (the active settings on the left, the transient status on the right) and the backgroundless shortcut hints. The frame itself draws no border lines; rounded borders belong to individual widgets (the input box, the modals).

The hint band's height comes from `shortcut_hints::height`, never a constant, so `F1` reclaims the hint rows *and* the blank separator line above them. It is then capped so the content always keeps `MIN_CONTENT_HEIGHT` rows: on a narrow terminal the grouped hints would otherwise wrap far enough to squeeze the input box off the screen.

## Dialogs and testability

`ratada`'s modals need a live `&mut Tui`, which a test cannot build – they put the terminal into raw mode. `tui/interaction.rs` hides them behind the `Interaction` port: production uses `Modals` (real, dimming the live view behind them), tests use `Headless`, which answers every dialog without a terminal. That is what lets `App::dispatch` be driven key-by-key in `tui/app.rs`'s tests.

A dialog can end in `Answer::ForcedQuit`: `Ctrl+Q` is honoured inside a modal too, and the toolkit reports it as `ModalSignal::Quit`. Dropping that signal would trap the user in the dialog, so it is carried back to the app rather than folded into "the user said no".

The history finder (`Ctrl+R`) and the command palette (`Ctrl+P`) go through the same port: `Interaction::pick` and `Interaction::palette` return a `Selection` (`Index`, `None` or `ForcedQuit`), the picker analogue of `Answer`. `Headless` returns a configurable `Selection`, so both flows are driven terminal-free in the tests. The palette's items are built from the action catalog itself: the label is each action's description, the category comes from `bindings::category_of`, and an action unavailable in the current context is passed as `enabled: false`, so the toolkit dims it and refuses to select it.

## Input completion

The suggestion dropdown under the input field is `ratada::autocomplete`, driven from pure helpers in `domain/completion.rs`: `candidates` lists the current variables plus the built-in names (the lists shared with the highlighter), and `identifier_before` finds the word under the caret to match against and replace. Its keys are widget-internal, so they stay out of the keymap; `App::dispatch` lets an open dropdown consume `↑`/`↓`/`Enter`/`Esc` before the normal key path, and rebuilds it after every edit. `PgUp` still enters the history while the dropdown owns `↑`.

## Colours

Two sections, two key sets, and they are not the same:

- `[appearance.colors]` overrides any **palette** colour (`ThemeColors::KEYS` plus the derived ones: `selection`, `cursor`, `input_bg`, …). Validated against `Palette::KEYS`.
- `[themes.<name>]` defines a **theme**, and a theme contributes only the base colours a palette is derived *from*. Validated against `ThemeColors::KEYS`. Naming a derived colour here is reported, not silently dropped.

The input box's focused frame is `palette.border_focus`, a toolkit colour that follows `border` unless it is set. `tui/views/calc.rs::focus_skin` swaps it into `border` on a `Skin` copy rather than only styling `border_style`, because `chrome::border_title` reads the title's leading stroke from `palette.border` – that one stroke would otherwise stay dark.

## Configuration

`config/loader.rs` builds the `Config` in three layers, each overriding the one before it: the built-in defaults, then `config.toml` if it exists (a missing file is not an error), then the `CALCLI_*` environment variables (`CALCLI_THEME`, `CALCLI_DECIMALS`, `CALCLI_ACCENT`, …). `examples/config.toml` is the fully commented reference for every key. It is held to the defaults by `the_shipped_example_config_parses_and_states_the_defaults` in `config/loader/mod.rs`, and to the action catalog by `tests/docs_in_sync.rs`; neither can rot unnoticed.

Two things the loader is strict about live in their own sections: the colour tables (see `Colours`) and the refusal of unknown keys (see `On-disk compatibility`).

## Error messages

`AppError` (`domain/errors.rs`) is the domain's only error type, and each variant's `Display` is exactly what the user reads in the status line and what the history stores for a failed line. Changing the wording is a user-visible change, not a refactor; `every_failure_mode_keeps_its_message` in `services/calc_service.rs` pins all of them.

Two variants differ on purpose: `Calculator` prefixes meval's fragment with "cannot evaluate: ", while `Units` renders rink's message verbatim, because rink already phrases whole sentences that name the offending unit.

## On-disk compatibility

`tests/legacy_data.rs` guards the two on-disk files against verbatim fixtures in `tests/data/`. New fields are `#[serde(default)]` in both, so an older file loads. Beyond that the two files answer to different rules, because they fail differently.

**`state.toml` must read every shape calcli has ever written.** A shape that has changed is still *read*, even if it is no longer *written*: a dimensionless variable used to be a bare number (`g = 9.81`) and is now a table, and `PersistedValue` accepts both. This is not politeness. `main` treats an unreadable `state.toml` as an empty session, so a rejected file silently discards the user's settings, variables and whole history.

**`config.toml` may lose a key.** `RawConfig` carries `deny_unknown_fields`, so a key that no longer exists refuses the file, `main` prints the cause chain and exits non-zero. Nothing is lost and the message names the offending line, which the user deletes. `history_zebra` went this way, and the two `a_0_2_config_file_*` tests pin both halves: the old file is rejected *by name*, and the same file minus that line still loads with its colours mapped.

Removing a config key is therefore a breaking change to be announced in `CHANGELOG.md`, not a silent one to be papered over.

## Logging

Diagnostics go through the `log` crate, never `println!`/`eprintln!`: a TUI owns the terminal, and writing to stderr would corrupt the alternate screen. The backend is `util/logging.rs`, a minimal `Log` implementation that appends to a file. `main` installs it once at `Info` level, pointed at `paths::log_file()`; when no file can be opened the logger stays a silent no-op, so logging is never fatal. Visible TUI output (the status band, the history) is not logging.

## Tests

Unit tests live beside their code in `#[cfg(test)] mod tests`; integration tests in `tests/`. Rendering is checked through ratatui's `TestBackend`, and the key path through `Headless`. Names describe the expected behaviour.