r2ui 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +79 -0
- data/lib/r2ui/app.rb +311 -34
- data/lib/r2ui/canvas.rb +135 -14
- data/lib/r2ui/cli/builder.rb +72 -0
- data/lib/r2ui/cli/context.rb +45 -0
- data/lib/r2ui/cli/definition.rb +174 -0
- data/lib/r2ui/cli/ext/ask.rb +133 -0
- data/lib/r2ui/cli/ext/box.rb +112 -0
- data/lib/r2ui/cli/ext/choose.rb +162 -0
- data/lib/r2ui/cli/ext/choose_many.rb +241 -0
- data/lib/r2ui/cli/ext/color_option.rb +64 -0
- data/lib/r2ui/cli/ext/completion.rb +520 -0
- data/lib/r2ui/cli/ext/config.rb +179 -0
- data/lib/r2ui/cli/ext/confirm.rb +110 -0
- data/lib/r2ui/cli/ext/dashboard.rb +105 -0
- data/lib/r2ui/cli/ext/diff.rb +195 -0
- data/lib/r2ui/cli/ext/editor.rb +94 -0
- data/lib/r2ui/cli/ext/env.rb +108 -0
- data/lib/r2ui/cli/ext/examples.rb +62 -0
- data/lib/r2ui/cli/ext/filter.rb +269 -0
- data/lib/r2ui/cli/ext/format.rb +104 -0
- data/lib/r2ui/cli/ext/list.rb +117 -0
- data/lib/r2ui/cli/ext/log.rb +89 -0
- data/lib/r2ui/cli/ext/pager.rb +97 -0
- data/lib/r2ui/cli/ext/pairs.rb +62 -0
- data/lib/r2ui/cli/ext/parallel.rb +274 -0
- data/lib/r2ui/cli/ext/password.rb +158 -0
- data/lib/r2ui/cli/ext/progress.rb +330 -0
- data/lib/r2ui/cli/ext/reference.rb +129 -0
- data/lib/r2ui/cli/ext/spinner.rb +165 -0
- data/lib/r2ui/cli/ext/summary.rb +63 -0
- data/lib/r2ui/cli/ext/table.rb +141 -0
- data/lib/r2ui/cli/ext/tasks.rb +216 -0
- data/lib/r2ui/cli/ext/trace.rb +64 -0
- data/lib/r2ui/cli/ext/tree.rb +114 -0
- data/lib/r2ui/cli/ext/version.rb +67 -0
- data/lib/r2ui/cli/extension.rb +129 -0
- data/lib/r2ui/cli/help.rb +115 -0
- data/lib/r2ui/cli/helpers.rb +20 -0
- data/lib/r2ui/cli/live.rb +150 -0
- data/lib/r2ui/cli/parser.rb +165 -0
- data/lib/r2ui/cli/prompt.rb +133 -0
- data/lib/r2ui/cli/runner.rb +86 -0
- data/lib/r2ui/cli/shell.rb +185 -0
- data/lib/r2ui/cli/testing.rb +49 -0
- data/lib/r2ui/cli.rb +129 -0
- data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
- data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
- data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
- data/lib/r2ui/compat/bubbletea/input.rb +269 -0
- data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
- data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
- data/lib/r2ui/compat/bubbletea/model.rb +65 -0
- data/lib/r2ui/compat/bubbletea/native.rb +254 -0
- data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
- data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
- data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
- data/lib/r2ui/compat/bubbletea/version.rb +8 -0
- data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
- data/lib/r2ui/compat/bubbletea.rb +16 -0
- data/lib/r2ui/compat/lipgloss/border.rb +17 -0
- data/lib/r2ui/compat/lipgloss/color.rb +78 -0
- data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
- data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
- data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
- data/lib/r2ui/compat/lipgloss/list.rb +85 -0
- data/lib/r2ui/compat/lipgloss/position.rb +30 -0
- data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
- data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
- data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
- data/lib/r2ui/compat/lipgloss/table.rb +185 -0
- data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
- data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
- data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
- data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
- data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
- data/lib/r2ui/compat/lipgloss/version.rb +15 -0
- data/lib/r2ui/compat/lipgloss.rb +64 -0
- data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
- data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
- data/lib/r2ui/component.rb +67 -0
- data/lib/r2ui/context.rb +87 -0
- data/lib/r2ui/drop_in.rb +16 -0
- data/lib/r2ui/dsl/column.rb +11 -3
- data/lib/r2ui/dsl/dashboard.rb +45 -9
- data/lib/r2ui/dsl/resource.rb +12 -0
- data/lib/r2ui/ext/after.rb +54 -0
- data/lib/r2ui/ext/async.rb +81 -0
- data/lib/r2ui/ext/batch_sequence.rb +54 -0
- data/lib/r2ui/ext/countdown.rb +52 -0
- data/lib/r2ui/ext/data_table.rb +71 -0
- data/lib/r2ui/ext/every.rb +66 -0
- data/lib/r2ui/ext/exec.rb +116 -0
- data/lib/r2ui/ext/file_picker.rb +78 -0
- data/lib/r2ui/ext/focus_report.rb +69 -0
- data/lib/r2ui/ext/help.rb +62 -0
- data/lib/r2ui/ext/keys.rb +64 -0
- data/lib/r2ui/ext/list.rb +145 -0
- data/lib/r2ui/ext/messages.rb +87 -0
- data/lib/r2ui/ext/mouse.rb +134 -0
- data/lib/r2ui/ext/paginator.rb +86 -0
- data/lib/r2ui/ext/paste.rb +86 -0
- data/lib/r2ui/ext/println.rb +21 -0
- data/lib/r2ui/ext/progress.rb +85 -0
- data/lib/r2ui/ext/refresh.rb +56 -0
- data/lib/r2ui/ext/resize.rb +62 -0
- data/lib/r2ui/ext/screen.rb +47 -0
- data/lib/r2ui/ext/screen_mode.rb +63 -0
- data/lib/r2ui/ext/spinner.rb +59 -0
- data/lib/r2ui/ext/state.rb +45 -0
- data/lib/r2ui/ext/stopwatch.rb +67 -0
- data/lib/r2ui/ext/suspend.rb +56 -0
- data/lib/r2ui/ext/text_area.rb +73 -0
- data/lib/r2ui/ext/text_input.rb +83 -0
- data/lib/r2ui/ext/theme.rb +112 -0
- data/lib/r2ui/ext/view.rb +37 -0
- data/lib/r2ui/ext/viewport.rb +92 -0
- data/lib/r2ui/ext/window_title.rb +51 -0
- data/lib/r2ui/extension.rb +199 -0
- data/lib/r2ui/history.rb +4 -1
- data/lib/r2ui/keys.rb +35 -8
- data/lib/r2ui/motion.rb +169 -0
- data/lib/r2ui/panel_state.rb +46 -2
- data/lib/r2ui/renderer.rb +87 -22
- data/lib/r2ui/version.rb +1 -1
- data/lib/r2ui/widgets/box.rb +6 -3
- data/lib/r2ui/widgets/glyphs.rb +149 -0
- data/lib/r2ui/widgets/table.rb +109 -25
- data/lib/r2ui.rb +12 -1
- metadata +119 -4
- data/lib/r2ui/terminal.rb +0 -53
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c882ac44b504e3e363957635e8cb4333d278c7f7d446a9e9c0a6afebc0dae542
|
|
4
|
+
data.tar.gz: 2c59540c2b881f51d0934d3f574140375b6745b7dce753b27e7e232ee1488abe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a33b57ba6c51dd22b69b6e712e7599d6a32ba12f14dd6aed97c19852ba65a9051b1395d5b8064f6b427fd274559932dd7ea6cc38c4beb2091a0f3d8645c3aa04
|
|
7
|
+
data.tar.gz: ee66ef296a9e0f8b69af324d73efacd9c7f07fbfd4eed11b46e50895fa8a26590d778ba3f323168240bdfd00f466f9795438ee0e2c3a19de90848fa20c781581
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Add entries under `## Unreleased`. To cut a release, the owner renames that heading to `## X.Y.Z` and adds a fresh `## Unreleased` above it (see docs/releasing.md).
|
|
4
|
+
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
## 0.2.0
|
|
8
|
+
|
|
9
|
+
### Upgrading from 0.1.0
|
|
10
|
+
|
|
11
|
+
- Dashboards now run on r2ui's pure-Ruby Bubble Tea engine: `R2UI::App` is a Bubbletea model (`init`/`update`/`view`) and `R2UI.run` runs it on the compat runner. The old terminal loop and raw key parser are gone; keys, search, sorting and folding behave as before.
|
|
12
|
+
- Plain `require "r2ui"` loads the engine by path and leaves `require "bubbletea"` / `require "lipgloss"` alone; the drop-in is opt-in.
|
|
13
|
+
- Dashboards default to the alt screen at 20 fps with synchronized output and line-diffed redraws, and only draw a frame when something changed (an update, new feed data, an animation, a flash, or once a second when idle).
|
|
14
|
+
|
|
15
|
+
### Bubble Tea drop-in and compat engine
|
|
16
|
+
|
|
17
|
+
- **Pure-Ruby drop-in for Bubble Tea.** `require "r2ui/drop_in"` makes `require "bubbletea"` and `require "lipgloss"` load r2ui's own versions of bubbletea 0.1.4 and lipgloss 0.2.2 (Style, borders, join/place, Table, List, Tree, colour profiles): no native extensions, no Go toolchain, no runtime dependencies.
|
|
18
|
+
- **bubbles runs unchanged.** bubbles 0.1.1 (and other pure-Ruby gems built on bubbletea/lipgloss) loads on top of the drop-in as-is.
|
|
19
|
+
- **Conformance harness** (`bin/conformance`, `conformance/`). Cases run in a pty against the real gems to record goldens, then against r2ui to check them; the screen is decoded and compared cell by cell, styles included. `conformance/ratchet/` lists the cases that must keep passing, and CI fails if one regresses.
|
|
20
|
+
- **Known intentional deviations** (listed in `conformance/concessions/`; the goldens still show upstream's behaviour):
|
|
21
|
+
- Several keys arriving in one read become several events. Upstream makes one event from the first character of a read and drops the rest.
|
|
22
|
+
- Bracketed paste is delivered whole, even when it spans several reads. Upstream can break a long paste into key events.
|
|
23
|
+
- r2ui-only renderer options, off by default so the engine matches upstream byte for byte: `synchronized:` (each frame wrapped in DEC mode 2026) and `line_diff:` (in the alt screen, write only the lines that changed).
|
|
24
|
+
- Fixes: renderer write errors after a hangup are ignored, as upstream; ticks scheduled from command threads are no longer lost.
|
|
25
|
+
|
|
26
|
+
### Dashboard DSL: extension point and extensions
|
|
27
|
+
|
|
28
|
+
- `R2UI.extension(name) { ... }` registers DSL keywords, helpers, message handlers (`on`, `observe`, `after_update`), panel items, hosted Bubbletea-style components, status hints and program options; blocks run on an `R2UI::Context` (`state`, `flash`, `quit`, `command`, ...). See docs/dsl.md.
|
|
29
|
+
- Panels can have no resource (`panel :clock, resource: nil do ... end`) and hold only extension items.
|
|
30
|
+
- Timers and commands: `every`, `after`, `async`, `batch` / `sequence`, `execute` (run a program in the foreground), `println`, `refresh` (fetch a resource now).
|
|
31
|
+
- Keys and messages: `on_key` (with help labels), your own messages with `on` and `emit`, declared initial `state`.
|
|
32
|
+
- Terminal: `inline` vs alt screen (and `enter_alt_screen` / `exit_alt_screen`), `window_title`, `mouse` with `on_click` and the wheel, `paste` / `on_paste`, `report_focus` with `on_focus` / `on_blur`, `on_resize` and `min_size`, `suspendable` / `on_resume` / `suspend`.
|
|
33
|
+
- Drawing: `view` (a panel item drawn by a block), `screen` (the whole view drawn by a block), `theme` (lipgloss styles for titles, borders, focus) and a `style` helper.
|
|
34
|
+
- Hosted bubbles components as panel keywords (needs the bubbles gem): `text_input`, `text_area`, `list`, `data_table`, `viewport`, `spinner`, `progress`, `countdown` (Timer), `stopwatch`, `paginate` (Paginator), `help`, `file_picker`.
|
|
35
|
+
|
|
36
|
+
### Dashboard views
|
|
37
|
+
|
|
38
|
+
- `R2UI::Motion` (`app.motion`, `motion` in extension blocks): tweens, pulses and ages evaluated per frame, with eases; frames keep drawing only while something moves. `app.motion.enabled = false` turns it off.
|
|
39
|
+
- `R2UI::Widgets::Glyphs`: braille line and area charts, partial-block bars, 24-bit heat colours and gradients, as plain ANSI strings for panel items.
|
|
40
|
+
- `table(columns: [...])` picks and orders a table's columns (sort cycling follows them); `table(motion: true)` marks moved and new lines in a left gutter and keeps the selection on its line across re-sorts.
|
|
41
|
+
- Column options: `style:` (a callable per value), `priority:` (lowest dropped first when columns don't fit), `sparkline: :braille` with `spark_width:`, `spark_max:` and `spark_style:`.
|
|
42
|
+
- `panel(..., border_style:)` colours a panel's border and title (a style or a callable); `row(height:)` accepts a callable evaluated each frame.
|
|
43
|
+
|
|
44
|
+
### Performance
|
|
45
|
+
|
|
46
|
+
- Dashboard frames cost a third or less of what they did (median, YJIT off: dense animating 14.3 → 5.0 ms, visual animating 24.4 → 5.4 ms), with identical screens. See docs/performance.md.
|
|
47
|
+
- Exact fast path for ANSI string width and truncation on valid UTF-8; Canvas cells hold codepoints with memoized widths and SGR prefixes; extension hooks cached; table cells and braille drawn without per-cell allocations.
|
|
48
|
+
- `R2UI::App` turns on the line-diff renderer, so animating frames write about 30% fewer bytes.
|
|
49
|
+
- `rake bench` / `rake bench:profile` measure per-frame cost by stage against a baseline; `bench/cpu.rb` measures a real program's CPU in a pty.
|
|
50
|
+
|
|
51
|
+
### CLI toolkit (`require "r2ui/cli"`)
|
|
52
|
+
|
|
53
|
+
- A second product line for command-line tools, inline (no alt screen): redrawn in place on a terminal, plain stable lines in a pipe or CI. See docs/cli.md and `examples/cli/deployer.rb`.
|
|
54
|
+
- Commands DSL: `R2UI.cli`, nested `command`s with `aliases`, `argument`, typed `option` (defaults, `in:` choices, required), `flag`, generated `--help`, usage errors with "did you mean".
|
|
55
|
+
- Runner and exit codes: 0 success, 1 error, 2 usage error, 130 ctrl+c; `abort!`, `halt`, `usage_error!`; a closed stdout ends quietly.
|
|
56
|
+
- `Shell` decides live redraw, interactivity (`TERM=dumb` is neither) and colour (`NO_COLOR`, `FORCE_COLOR`) once; `Theme` styles output; `R2UI::CLI.extension` adds keywords, helpers, hooks and help sections; `run_cli` for tests.
|
|
57
|
+
- Output helpers: `say`, `tasks` / `step`, `spin`, `progress` (rate and ETA), `info` / `success` / `warn` / `error` / `debug`, `box`, `pairs`, `tree`, `table`, `list`, `diff`, `pager`, `parallel` / `job`, `heading` / `done`, and `duration`, `bytes`, `plural`, `link`.
|
|
58
|
+
- Prompts (inline Bubbletea, line-based off a terminal): `confirm`, `ask`, `password`, `choose`, `choose_many`, `filter`, `edit` ($VISUAL / $EDITOR).
|
|
59
|
+
- Command keywords: `version` (`-V, --version`), `option ..., env:`, `config_file` (YAML/JSON, `--config`), `trace_option` (`--trace`), `example` (Examples in help), `reference_command` (Markdown reference), `color_option` (`--color` / `--no-color`), `completion` (bash, zsh, fish scripts), and a `dashboard` helper that opens an r2ui dashboard from a command (one snapshot frame off a terminal).
|
|
60
|
+
|
|
61
|
+
## 0.1.0
|
|
62
|
+
|
|
63
|
+
- ActiveAdmin-style DSL for terminal dashboards: `R2UI.resource` (source, refresh, scopes, `group_by` including trees, columns with formats and sparklines, filters, actions) and `R2UI.dashboard` (rows, panels, gauges, stats, sparklines, tables).
|
|
64
|
+
- `exe/r2ui` runs a dashboard file interactively or prints one frame with `--snapshot`.
|
data/README.md
CHANGED
|
@@ -74,6 +74,85 @@ exe/r2ui --snapshot examples/agents.rb # print one frame and exit
|
|
|
74
74
|
|
|
75
75
|
`:text`, `:id`, `:integer`, `:number`, `:percent`, `:bytes`, `:bytes_per_sec`, `:ratio`, `:short_path`. The format decides display, alignment, how grouped lines aggregate (`:sum`, `:count` or a single shared value) and how search operands are parsed (`500M`, `5%`).
|
|
76
76
|
|
|
77
|
+
## Extensions
|
|
78
|
+
|
|
79
|
+
Everything a Bubble Tea program can do, a dashboard can declare: `on_key`, `state`, `every`/`after`/`async`, `emit`, `execute`, `println`, `inline` vs alt screen, `window_title`, `mouse`, `paste`, `on_resize`, `theme`, `view` and `screen` blocks, and bubbles components as panel keywords (`text_input`, `list`, `data_table`, `viewport`, `spinner`, `progress`, ...). Each is one file in `lib/r2ui/ext/` registered with `R2UI.extension`, the same hook you use to add your own. See [docs/dsl.md](docs/dsl.md).
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
R2UI.dashboard do
|
|
83
|
+
state deploys: 0
|
|
84
|
+
on_key("d", help: "deploy") { state[:deploys] += 1; flash "deploying" }
|
|
85
|
+
row height: 3 do
|
|
86
|
+
panel(:status, resource: nil) { view { "#{state[:deploys]} deploys" } }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Views: motion, glyphs, columns
|
|
92
|
+
|
|
93
|
+
For dense monitors: `R2UI::Motion` tweens, pulses and ages values while frames draw (and stops drawing when nothing moves); `R2UI::Widgets::Glyphs` draws braille charts, partial-block bars and heat colours as ANSI strings. Tables take `columns:` to pick and order columns and `motion: true` to mark moved rows; columns take `style:`, `priority:` and `sparkline: :braille`; panels take `border_style:`. Frame costs and how they're measured: [docs/performance.md](docs/performance.md).
|
|
94
|
+
|
|
95
|
+
## CLI toolkit
|
|
96
|
+
|
|
97
|
+
`require "r2ui/cli"` builds command-line tools that look like npm, pnpm or bun on a terminal and print plain, stable lines in a pipe or CI: a commands DSL with generated help and exit codes, task lists, spinners, progress bars, log levels, boxes, tables, trees, diffs, prompts (`confirm`, `ask`, `choose`, `filter`, ...), `--version`, env/config-file options, shell completion, and a command that opens a dashboard. See [docs/cli.md](docs/cli.md) and `examples/cli/deployer.rb`.
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
require "r2ui/cli"
|
|
101
|
+
|
|
102
|
+
R2UI.cli "deployer" do
|
|
103
|
+
command :deploy do
|
|
104
|
+
argument :app
|
|
105
|
+
run do
|
|
106
|
+
tasks do
|
|
107
|
+
step("Building") { build(args[:app]) }
|
|
108
|
+
step("Uploading") { upload }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end.start
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Drop-in for Bubble Tea
|
|
116
|
+
|
|
117
|
+
r2ui also ships pure-Ruby versions of the Charm gems bubbletea 0.1.4 and lipgloss 0.2.2: no native extensions, nothing to compile. Require `r2ui/drop_in` before anything else, and existing programs, including ones built on bubbles 0.1.1, run unchanged:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
require "r2ui/drop_in" # first: `require "bubbletea"` / `"lipgloss"` now load r2ui's versions
|
|
121
|
+
require "bubbletea"
|
|
122
|
+
require "bubbles" # the real bubbles gem, unmodified
|
|
123
|
+
|
|
124
|
+
class Prompt
|
|
125
|
+
include Bubbletea::Model
|
|
126
|
+
|
|
127
|
+
def initialize
|
|
128
|
+
@input = Bubbles::TextInput.new
|
|
129
|
+
@input.placeholder = "Your name"
|
|
130
|
+
@input.focus
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def init = [self, nil]
|
|
134
|
+
|
|
135
|
+
def update(message)
|
|
136
|
+
return [self, Bubbletea.quit] if message.is_a?(Bubbletea::KeyMessage) && message.to_s == "enter"
|
|
137
|
+
|
|
138
|
+
@input, command = @input.update(message)
|
|
139
|
+
[self, command]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def view = "What's your name?\n\n#{@input.view}\n\n(enter to finish)"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
Bubbletea.run(Prompt.new)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
bubbles itself is still installed as a gem (`gem install bubbles`); bubbletea and lipgloss don't need to be.
|
|
149
|
+
|
|
150
|
+
**How conformance is measured.** The upstream gems are the spec. Each case in `conformance/cases/` is a small program or lipgloss call; `bin/conformance record` runs it on the real gems in a pty and saves the decoded screen (text, styles, alt screen, cursor and mouse/paste modes) as a golden, and `bin/conformance check` runs it on r2ui and diffs. Cases listed in `conformance/ratchet/` must keep passing; CI runs `bin/conformance check --ratchet` on every PR and re-records the goldens from the real gems to catch stale ones. Where r2ui differs on purpose, the case is listed in `conformance/concessions/` with the reason (for example, r2ui keeps every key from a multi-key read and delivers long pastes whole, where upstream drops or splits them). See [conformance/README.md](conformance/README.md).
|
|
151
|
+
|
|
152
|
+
## Contributing and releases
|
|
153
|
+
|
|
154
|
+
Open PRs against `develop`; `main` holds released code only. Releases and hotfixes are cut from GitHub Actions; see [docs/releasing.md](docs/releasing.md).
|
|
155
|
+
|
|
77
156
|
## Next: active_tui
|
|
78
157
|
|
|
79
158
|
A Rails engine on top of the same DSL: `ActiveTui.register Order do ... end` reads columns, scopes and associations from the model, `source` defaults to the relation, and actions become model methods. This gem stays Rails-free.
|
data/lib/r2ui/app.rb
CHANGED
|
@@ -1,42 +1,171 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module R2UI
|
|
4
|
-
# The running program
|
|
4
|
+
# The running program, as a Bubbletea model: `init` starts the feeds and runs extension init
|
|
5
|
+
# hooks, `update` routes each message (extension observers and handlers, components, then the
|
|
6
|
+
# core keys) and returns their commands, `view` draws the dashboard. `run` runs it on r2ui's
|
|
7
|
+
# Bubbletea engine (lib/r2ui/compat/bubbletea).
|
|
5
8
|
class App
|
|
6
|
-
|
|
9
|
+
include Bubbletea::Model
|
|
10
|
+
|
|
7
11
|
FLASH_SECONDS = 3
|
|
12
|
+
# Runner options; extensions change them with `program_options`. `synchronized` wraps each
|
|
13
|
+
# frame in DEC 2026 synchronized output, so terminals paint it at once; `line_diff` writes only
|
|
14
|
+
# the lines that changed since the last frame (dashboards run in the alt screen).
|
|
15
|
+
PROGRAM_OPTIONS = { alt_screen: true, fps: 20, synchronized: true, line_diff: true }.freeze
|
|
16
|
+
# Seconds after which a frame is due even if nothing changed (clocks, ages, "3s ago" texts).
|
|
17
|
+
IDLE_FRAME = 1.0
|
|
18
|
+
# How often `view` forgets motion keys nothing evaluates any more.
|
|
19
|
+
SWEEP_SECONDS = 1.0
|
|
20
|
+
# Where the focused component takes keys among the `on` handlers: those with priority >= 50 run
|
|
21
|
+
# before it, the rest after.
|
|
22
|
+
COMPONENT_PRIORITY = 50
|
|
23
|
+
|
|
24
|
+
attr_reader :registry, :dashboard, :feeds, :focus, :state, :width, :height, :components
|
|
25
|
+
# The app's animation state (R2UI::Motion): tweens, pulses and ages evaluated while drawing.
|
|
26
|
+
attr_reader :motion
|
|
8
27
|
|
|
9
28
|
def initialize(registry, name = nil)
|
|
10
29
|
@registry = registry
|
|
11
30
|
@dashboard = registry.screen(name)
|
|
12
|
-
resources = @dashboard.panels.
|
|
31
|
+
resources = @dashboard.panels.filter_map(&:resource).uniq.map { |r| registry.resource(r) }
|
|
13
32
|
@feeds = resources.to_h { |r| [r.name, Feed.new(r)] }
|
|
14
33
|
@states = @dashboard.panels.to_h do |p|
|
|
15
|
-
[p, p.table && PanelState.new(registry.resource(p.resource), p.table)]
|
|
34
|
+
[p, p.table && p.resource && PanelState.new(registry.resource(p.resource), p.table)]
|
|
16
35
|
end
|
|
17
36
|
@focus = @dashboard.panels.find(&:table) || @dashboard.panels.first
|
|
18
|
-
@
|
|
37
|
+
@motion = Motion.new
|
|
38
|
+
@renderer = Renderer.new(registry, @feeds, @states, draw_item: method(:draw_item), motion: @motion)
|
|
19
39
|
@mode = :normal
|
|
20
40
|
@zoomed = false
|
|
41
|
+
@state = {}
|
|
42
|
+
@stores = Hash.new { |h, k| h[k] = {} }
|
|
43
|
+
@components = []
|
|
44
|
+
@width = 80
|
|
45
|
+
@height = 24
|
|
46
|
+
@changed = true # an init/update ran since the last view
|
|
47
|
+
@viewed_at = nil
|
|
48
|
+
@viewed_versions = {}
|
|
49
|
+
@viewed_flash = false
|
|
50
|
+
@swept_at = nil
|
|
51
|
+
setup = Context.new(self)
|
|
52
|
+
Extensions.hooks(:setup).each { |h| setup.call(h.block) }
|
|
21
53
|
end
|
|
22
54
|
|
|
55
|
+
# Runs on the terminal until quit. Feeds stop on every exit path.
|
|
23
56
|
def run
|
|
24
|
-
|
|
25
|
-
@resized = false
|
|
26
|
-
previous_winch = Signal.trap("WINCH") { @resized = true }
|
|
27
|
-
@feeds.each_value(&:start)
|
|
28
|
-
terminal.open do
|
|
29
|
-
loop do
|
|
30
|
-
width, height = terminal.size
|
|
31
|
-
terminal.clear if @resized
|
|
32
|
-
@resized = false
|
|
33
|
-
terminal.draw(frame(width, height))
|
|
34
|
-
break if terminal.read_keys(FRAME).any? { |key| handle(key) == :quit }
|
|
35
|
-
end
|
|
36
|
-
end
|
|
57
|
+
Bubbletea::Runner.new(self, **program_options).run
|
|
37
58
|
ensure
|
|
38
|
-
|
|
39
|
-
|
|
59
|
+
stop
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def stop = @feeds.each_value(&:stop)
|
|
63
|
+
|
|
64
|
+
# --- Bubbletea::Model ---
|
|
65
|
+
|
|
66
|
+
def init
|
|
67
|
+
ctx = Context.new(self)
|
|
68
|
+
@feeds.each_value(&:start) unless @started
|
|
69
|
+
@started = true
|
|
70
|
+
start_components(ctx)
|
|
71
|
+
Extensions.hooks(:init).each { |h| ctx.call(h.block) }
|
|
72
|
+
after_update(ctx)
|
|
73
|
+
@changed = true
|
|
74
|
+
[self, ctx.commands]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def update(message)
|
|
78
|
+
ctx = Context.new(self, message)
|
|
79
|
+
dispatch(ctx, message)
|
|
80
|
+
sync_component_focus(ctx) if @components_started
|
|
81
|
+
after_update(ctx)
|
|
82
|
+
@changed = true
|
|
83
|
+
[self, ctx.commands]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Whether the next frame slot should draw: an init/update ran or a feed has new data since the
|
|
87
|
+
# last `view`, something animates, a flash shows (or vanished since), or IDLE_FRAME passed.
|
|
88
|
+
# The runner asks this every frame slot, so it allocates nothing.
|
|
89
|
+
def frame_due?
|
|
90
|
+
return true if @changed || @viewed_at.nil?
|
|
91
|
+
return true if @feeds.any? { |name, feed| feed.version != @viewed_versions[name] }
|
|
92
|
+
return true if @motion.active?
|
|
93
|
+
return true if @viewed_flash || flash_showing?
|
|
94
|
+
|
|
95
|
+
@motion.now - @viewed_at > IDLE_FRAME
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def view
|
|
99
|
+
seen_view
|
|
100
|
+
width, height = frame_size
|
|
101
|
+
ctx = Context.new(self)
|
|
102
|
+
ctx.width = width
|
|
103
|
+
ctx.height = height
|
|
104
|
+
Extensions.hooks(:view_override).each do |h|
|
|
105
|
+
text = ctx.call(h.block)
|
|
106
|
+
return text.to_s if text
|
|
107
|
+
end
|
|
108
|
+
frame(width, height).ansi_lines.join("\n")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# --- for extensions and tests ---
|
|
112
|
+
|
|
113
|
+
# Runner options: the defaults merged with every extension's `program_options`.
|
|
114
|
+
def program_options
|
|
115
|
+
ctx = Context.new(self)
|
|
116
|
+
Extensions.hooks(:program_options).reduce(PROGRAM_OPTIONS) { |opts, h| opts.merge(ctx.call(h.block) || {}) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Private state for one extension.
|
|
120
|
+
def store(name) = @stores[name.to_sym]
|
|
121
|
+
|
|
122
|
+
def flash(text)
|
|
123
|
+
@flash = text
|
|
124
|
+
@flash_at = Time.now
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def selected_rows
|
|
128
|
+
return [] unless state_for_focus
|
|
129
|
+
|
|
130
|
+
lines[state_for_focus.selected]&.rows || []
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Panel => Rect where each panel was drawn in the last frame.
|
|
134
|
+
def panel_rects = @renderer.rects
|
|
135
|
+
|
|
136
|
+
def focus=(panel)
|
|
137
|
+
raise Error, "no panel #{panel.inspect}" unless @dashboard.panels.include?(panel)
|
|
138
|
+
|
|
139
|
+
@focus = panel
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The hosted model for the component item named `name` (nil if none).
|
|
143
|
+
def component(name)
|
|
144
|
+
build_components
|
|
145
|
+
@components.find { |c| c.name == name }&.model
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Gives key focus to the named component (blurring the others), or takes it away with `nil`:
|
|
149
|
+
# e.g. esc in a text input hands keys back to the core while its panel stays focused.
|
|
150
|
+
# Focus commands go on `ctx`. Panel focus changes move it to that panel's first focusable
|
|
151
|
+
# component again.
|
|
152
|
+
def focus_component(name, ctx = Context.new(self))
|
|
153
|
+
target = name && @components.find { |c| c.focusable? && c.name == name }
|
|
154
|
+
raise Error, "no focusable component #{name.inspect}" if name && !target
|
|
155
|
+
|
|
156
|
+
@components.each { |c| set_component_focus(ctx, c, c.equal?(target)) if c.focusable? }
|
|
157
|
+
ctx.commands
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The PanelState (scope, sort, search, selection, offset) of a table panel, or nil.
|
|
161
|
+
def panel_state(panel) = @states[panel]
|
|
162
|
+
|
|
163
|
+
# The lines a table panel showed in the last frame (Query::Line objects).
|
|
164
|
+
def panel_lines(panel) = @renderer.lines.fetch(panel, [])
|
|
165
|
+
|
|
166
|
+
# Status-bar hints as [key, label] pairs: extensions' first, then the core's.
|
|
167
|
+
def hint_pairs(ctx = Context.new(self))
|
|
168
|
+
Extensions.hooks(:hints).flat_map { |h| ctx.call(h.block) || [] } + Renderer::HINT_PAIRS
|
|
40
169
|
end
|
|
41
170
|
|
|
42
171
|
def snapshot(width:, height:, ticks: 1)
|
|
@@ -47,29 +176,178 @@ module R2UI
|
|
|
47
176
|
frame(width, height).plain_lines.join("\n")
|
|
48
177
|
end
|
|
49
178
|
|
|
50
|
-
#
|
|
51
|
-
|
|
179
|
+
# Feeds keys without a terminal, as if typed: core names (:up, "q") or Bubbletea names
|
|
180
|
+
# ("ctrl+r"). Returns the commands they produced.
|
|
181
|
+
def press(*keys) = keys.filter_map { |k| update(Keys.message(k)).last }
|
|
182
|
+
|
|
183
|
+
# [width, height] to draw at: the terminal size, changed by extensions' `frame_size`.
|
|
184
|
+
def frame_size
|
|
185
|
+
ctx = Context.new(self)
|
|
186
|
+
Extensions.hooks(:frame_size).reduce([@width, @height]) { |size, h| ctx.call(h.block, *size) }
|
|
187
|
+
end
|
|
52
188
|
|
|
53
189
|
def frame(width, height)
|
|
54
|
-
|
|
190
|
+
ctx = Context.new(self)
|
|
191
|
+
build_components(ctx)
|
|
192
|
+
styles = Extensions.hooks(:styles).reduce({}) { |acc, h| acc.merge(ctx.call(h.block) || {}) }
|
|
193
|
+
@renderer.render(@dashboard, width:, height:, focus: @focus, zoomed: @zoomed, prompt: prompt(ctx),
|
|
194
|
+
hints: hints(ctx), styles: styles.empty? ? nil : styles)
|
|
55
195
|
end
|
|
56
196
|
|
|
57
197
|
private
|
|
58
198
|
|
|
59
|
-
def
|
|
199
|
+
def dispatch(ctx, message)
|
|
200
|
+
if message.is_a?(Bubbletea::WindowSizeMessage)
|
|
201
|
+
@width = message.width
|
|
202
|
+
@height = message.height
|
|
203
|
+
end
|
|
204
|
+
Extensions.hooks(:observe).each { |h| ctx.call(h.block, message) if h.match?(message) }
|
|
205
|
+
key = message.is_a?(Bubbletea::KeyMessage) ? Keys.name(message) : nil
|
|
206
|
+
return ctx.quit if key == :interrupt
|
|
207
|
+
return core_key(ctx, key) if key && @mode != :normal
|
|
208
|
+
|
|
209
|
+
broadcast_to_components(ctx, message) unless key
|
|
210
|
+
before, after = Extensions.hooks(:on).partition { |h| h.priority >= COMPONENT_PRIORITY }
|
|
211
|
+
return if handled?(ctx, before, message)
|
|
212
|
+
return if key && focused_component_key(ctx, message, key)
|
|
213
|
+
return if handled?(ctx, after, message)
|
|
214
|
+
|
|
215
|
+
core_key(ctx, key) if key
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def after_update(ctx) = Extensions.hooks(:after_update).each { |h| ctx.call(h.block) }
|
|
219
|
+
|
|
220
|
+
# Records what this view draws, for frame_due?, and sweeps stale motion keys once a second.
|
|
221
|
+
def seen_view
|
|
222
|
+
now = @motion.now
|
|
223
|
+
@changed = false
|
|
224
|
+
@viewed_at = now
|
|
225
|
+
@feeds.each { |name, feed| @viewed_versions[name] = feed.version }
|
|
226
|
+
@flash = nil if @flash && !flash_showing?
|
|
227
|
+
@viewed_flash = !@flash.nil?
|
|
228
|
+
return if @swept_at && now - @swept_at < SWEEP_SECONDS
|
|
229
|
+
|
|
230
|
+
@swept_at = now
|
|
231
|
+
@motion.sweep!
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def flash_showing? = @flash ? Time.now - @flash_at < FLASH_SECONDS : false
|
|
235
|
+
|
|
236
|
+
def handled?(ctx, hooks, message) = hooks.any? { |h| h.match?(message) && ctx.handle(h.block, message) }
|
|
237
|
+
|
|
238
|
+
def core_key(ctx, key)
|
|
239
|
+
ctx.quit if handle(key) == :quit
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# --- components ---
|
|
243
|
+
|
|
244
|
+
# Builds the component models once (first frame or init, whichever comes first).
|
|
245
|
+
def build_components(ctx = Context.new(self))
|
|
246
|
+
return if @components_built
|
|
247
|
+
|
|
248
|
+
@components_built = true
|
|
249
|
+
specs = Extensions.hooks(:component)
|
|
250
|
+
@dashboard.panels.each do |panel|
|
|
251
|
+
panel.items.each do |item|
|
|
252
|
+
spec = specs.find { |h| h.match?(item) }&.block
|
|
253
|
+
next unless spec
|
|
254
|
+
|
|
255
|
+
instance = Component::Instance.new(panel:, item:, spec:, focused: false)
|
|
256
|
+
instance.model = ctx.call(spec.build, item)
|
|
257
|
+
@components << instance
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Runs each model's `init` once, in App#init, and gives the focused panel's component focus.
|
|
263
|
+
def start_components(ctx)
|
|
264
|
+
build_components(ctx)
|
|
265
|
+
return if @components_started
|
|
266
|
+
|
|
267
|
+
@components_started = true
|
|
268
|
+
@components.each do |instance|
|
|
269
|
+
next unless instance.model.respond_to?(:init)
|
|
270
|
+
|
|
271
|
+
instance.model, cmd = Component.split(instance.model, instance.model.init)
|
|
272
|
+
ctx.command(cmd)
|
|
273
|
+
end
|
|
274
|
+
sync_component_focus(ctx)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def broadcast_to_components(ctx, message)
|
|
278
|
+
@components.each { |c| update_component(ctx, c, message) }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def focused_component_key(ctx, message, key)
|
|
282
|
+
return false if Component::RESERVED.include?(key)
|
|
283
|
+
|
|
284
|
+
instance = @components.find { |c| c.focused && c.panel == @focus }
|
|
285
|
+
return false unless instance
|
|
286
|
+
|
|
287
|
+
update_component(ctx, instance, message)
|
|
288
|
+
true
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def set_component_focus(ctx, instance, want)
|
|
292
|
+
return if instance.focused == want
|
|
293
|
+
|
|
294
|
+
instance.focused = want
|
|
295
|
+
cmd = want ? Component.focus(instance) : Component.blur(instance)
|
|
296
|
+
ctx.command(cmd) if Context.command?(cmd)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def update_component(ctx, instance, message)
|
|
300
|
+
instance.model, cmd = Component.split(instance.model, instance.model.update(message))
|
|
301
|
+
ctx.command(cmd)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# When panel focus has changed: focus the first focusable component of the focused panel and
|
|
305
|
+
# blur the rest.
|
|
306
|
+
def sync_component_focus(ctx)
|
|
307
|
+
return if @component_focus_panel.equal?(@focus) && @components_synced
|
|
308
|
+
|
|
309
|
+
@components_synced = true
|
|
310
|
+
@component_focus_panel = @focus
|
|
311
|
+
active = @components.find { |c| c.focusable? && c.panel == @focus }
|
|
312
|
+
@components.each { |c| set_component_focus(ctx, c, c.equal?(active)) if c.focusable? }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def draw_item(panel, item, width, height)
|
|
316
|
+
ctx = Context.new(self)
|
|
317
|
+
ctx.panel = panel
|
|
318
|
+
ctx.width = width
|
|
319
|
+
ctx.height = height
|
|
320
|
+
drawer = Extensions.hooks(:panel_item).find { |h| h.match?(item) }
|
|
321
|
+
return ctx.call(drawer.block, item) if drawer
|
|
322
|
+
|
|
323
|
+
@components.find { |c| c.item.equal?(item) && c.panel == panel }&.model&.view
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# --- core keys and status bar ---
|
|
327
|
+
|
|
328
|
+
def state_for_focus = @states[@focus]
|
|
60
329
|
|
|
61
330
|
def lines = @renderer.lines.fetch(@focus, [])
|
|
62
331
|
|
|
63
332
|
def resource = @registry.resource(@focus.resource)
|
|
64
333
|
|
|
65
|
-
def prompt
|
|
334
|
+
def prompt(ctx)
|
|
66
335
|
case @mode
|
|
67
|
-
when :search then "/#{
|
|
336
|
+
when :search then "/#{state_for_focus.search}▏"
|
|
68
337
|
when Array then "#{@mode[1].label} #{@mode[2].size} #{@mode[2].size == 1 ? "row" : "rows"}? y/n"
|
|
69
|
-
else
|
|
338
|
+
else
|
|
339
|
+
return @flash if flash_showing?
|
|
340
|
+
|
|
341
|
+
Extensions.hooks(:status).each do |h|
|
|
342
|
+
text = ctx.call(h.block)
|
|
343
|
+
return text if text
|
|
344
|
+
end
|
|
345
|
+
nil
|
|
70
346
|
end
|
|
71
347
|
end
|
|
72
348
|
|
|
349
|
+
def hints(ctx) = hint_pairs(ctx).map { |key, label| "#{key} #{label}" }.join(" ")
|
|
350
|
+
|
|
73
351
|
def handle(key)
|
|
74
352
|
return :quit if key == :interrupt
|
|
75
353
|
|
|
@@ -87,11 +365,12 @@ module R2UI
|
|
|
87
365
|
when :back_tab then cycle_focus(-1)
|
|
88
366
|
when "z" then @zoomed = !@zoomed
|
|
89
367
|
end
|
|
90
|
-
table_key(key) if
|
|
368
|
+
table_key(key) if state_for_focus
|
|
91
369
|
nil
|
|
92
370
|
end
|
|
93
371
|
|
|
94
372
|
def table_key(key)
|
|
373
|
+
state = state_for_focus
|
|
95
374
|
case key
|
|
96
375
|
when :up, "k" then state.move(-1, lines.size)
|
|
97
376
|
when :down, "j" then state.move(1, lines.size)
|
|
@@ -113,6 +392,7 @@ module R2UI
|
|
|
113
392
|
end
|
|
114
393
|
|
|
115
394
|
def search_key(key)
|
|
395
|
+
state = state_for_focus
|
|
116
396
|
case key
|
|
117
397
|
when :enter then @mode = :normal
|
|
118
398
|
when :escape
|
|
@@ -124,7 +404,7 @@ module R2UI
|
|
|
124
404
|
end
|
|
125
405
|
|
|
126
406
|
def start_action(action)
|
|
127
|
-
rows = lines[
|
|
407
|
+
rows = lines[state_for_focus.selected]&.rows
|
|
128
408
|
return if rows.nil? || rows.empty?
|
|
129
409
|
|
|
130
410
|
action.confirm ? @mode = [:confirm, action, rows] : run_action(action, rows)
|
|
@@ -143,13 +423,10 @@ module R2UI
|
|
|
143
423
|
flash("#{action.label} failed: #{e.message}")
|
|
144
424
|
end
|
|
145
425
|
|
|
146
|
-
def flash(text)
|
|
147
|
-
@flash = text
|
|
148
|
-
@flash_at = Time.now
|
|
149
|
-
end
|
|
150
|
-
|
|
151
426
|
def cycle_focus(step)
|
|
152
427
|
panels = @dashboard.panels
|
|
428
|
+
return if panels.empty?
|
|
429
|
+
|
|
153
430
|
@focus = panels[(panels.index(@focus) + step) % panels.size]
|
|
154
431
|
end
|
|
155
432
|
end
|