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.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +79 -0
  4. data/lib/r2ui/app.rb +311 -34
  5. data/lib/r2ui/canvas.rb +135 -14
  6. data/lib/r2ui/cli/builder.rb +72 -0
  7. data/lib/r2ui/cli/context.rb +45 -0
  8. data/lib/r2ui/cli/definition.rb +174 -0
  9. data/lib/r2ui/cli/ext/ask.rb +133 -0
  10. data/lib/r2ui/cli/ext/box.rb +112 -0
  11. data/lib/r2ui/cli/ext/choose.rb +162 -0
  12. data/lib/r2ui/cli/ext/choose_many.rb +241 -0
  13. data/lib/r2ui/cli/ext/color_option.rb +64 -0
  14. data/lib/r2ui/cli/ext/completion.rb +520 -0
  15. data/lib/r2ui/cli/ext/config.rb +179 -0
  16. data/lib/r2ui/cli/ext/confirm.rb +110 -0
  17. data/lib/r2ui/cli/ext/dashboard.rb +105 -0
  18. data/lib/r2ui/cli/ext/diff.rb +195 -0
  19. data/lib/r2ui/cli/ext/editor.rb +94 -0
  20. data/lib/r2ui/cli/ext/env.rb +108 -0
  21. data/lib/r2ui/cli/ext/examples.rb +62 -0
  22. data/lib/r2ui/cli/ext/filter.rb +269 -0
  23. data/lib/r2ui/cli/ext/format.rb +104 -0
  24. data/lib/r2ui/cli/ext/list.rb +117 -0
  25. data/lib/r2ui/cli/ext/log.rb +89 -0
  26. data/lib/r2ui/cli/ext/pager.rb +97 -0
  27. data/lib/r2ui/cli/ext/pairs.rb +62 -0
  28. data/lib/r2ui/cli/ext/parallel.rb +274 -0
  29. data/lib/r2ui/cli/ext/password.rb +158 -0
  30. data/lib/r2ui/cli/ext/progress.rb +330 -0
  31. data/lib/r2ui/cli/ext/reference.rb +129 -0
  32. data/lib/r2ui/cli/ext/spinner.rb +165 -0
  33. data/lib/r2ui/cli/ext/summary.rb +63 -0
  34. data/lib/r2ui/cli/ext/table.rb +141 -0
  35. data/lib/r2ui/cli/ext/tasks.rb +216 -0
  36. data/lib/r2ui/cli/ext/trace.rb +64 -0
  37. data/lib/r2ui/cli/ext/tree.rb +114 -0
  38. data/lib/r2ui/cli/ext/version.rb +67 -0
  39. data/lib/r2ui/cli/extension.rb +129 -0
  40. data/lib/r2ui/cli/help.rb +115 -0
  41. data/lib/r2ui/cli/helpers.rb +20 -0
  42. data/lib/r2ui/cli/live.rb +150 -0
  43. data/lib/r2ui/cli/parser.rb +165 -0
  44. data/lib/r2ui/cli/prompt.rb +133 -0
  45. data/lib/r2ui/cli/runner.rb +86 -0
  46. data/lib/r2ui/cli/shell.rb +185 -0
  47. data/lib/r2ui/cli/testing.rb +49 -0
  48. data/lib/r2ui/cli.rb +129 -0
  49. data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
  50. data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
  51. data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
  52. data/lib/r2ui/compat/bubbletea/input.rb +269 -0
  53. data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
  54. data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
  55. data/lib/r2ui/compat/bubbletea/model.rb +65 -0
  56. data/lib/r2ui/compat/bubbletea/native.rb +254 -0
  57. data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
  58. data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
  59. data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
  60. data/lib/r2ui/compat/bubbletea/version.rb +8 -0
  61. data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
  62. data/lib/r2ui/compat/bubbletea.rb +16 -0
  63. data/lib/r2ui/compat/lipgloss/border.rb +17 -0
  64. data/lib/r2ui/compat/lipgloss/color.rb +78 -0
  65. data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
  66. data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
  67. data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
  68. data/lib/r2ui/compat/lipgloss/list.rb +85 -0
  69. data/lib/r2ui/compat/lipgloss/position.rb +30 -0
  70. data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
  71. data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
  72. data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
  73. data/lib/r2ui/compat/lipgloss/table.rb +185 -0
  74. data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
  75. data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
  76. data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
  77. data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
  78. data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
  79. data/lib/r2ui/compat/lipgloss/version.rb +15 -0
  80. data/lib/r2ui/compat/lipgloss.rb +64 -0
  81. data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
  82. data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
  83. data/lib/r2ui/component.rb +67 -0
  84. data/lib/r2ui/context.rb +87 -0
  85. data/lib/r2ui/drop_in.rb +16 -0
  86. data/lib/r2ui/dsl/column.rb +11 -3
  87. data/lib/r2ui/dsl/dashboard.rb +45 -9
  88. data/lib/r2ui/dsl/resource.rb +12 -0
  89. data/lib/r2ui/ext/after.rb +54 -0
  90. data/lib/r2ui/ext/async.rb +81 -0
  91. data/lib/r2ui/ext/batch_sequence.rb +54 -0
  92. data/lib/r2ui/ext/countdown.rb +52 -0
  93. data/lib/r2ui/ext/data_table.rb +71 -0
  94. data/lib/r2ui/ext/every.rb +66 -0
  95. data/lib/r2ui/ext/exec.rb +116 -0
  96. data/lib/r2ui/ext/file_picker.rb +78 -0
  97. data/lib/r2ui/ext/focus_report.rb +69 -0
  98. data/lib/r2ui/ext/help.rb +62 -0
  99. data/lib/r2ui/ext/keys.rb +64 -0
  100. data/lib/r2ui/ext/list.rb +145 -0
  101. data/lib/r2ui/ext/messages.rb +87 -0
  102. data/lib/r2ui/ext/mouse.rb +134 -0
  103. data/lib/r2ui/ext/paginator.rb +86 -0
  104. data/lib/r2ui/ext/paste.rb +86 -0
  105. data/lib/r2ui/ext/println.rb +21 -0
  106. data/lib/r2ui/ext/progress.rb +85 -0
  107. data/lib/r2ui/ext/refresh.rb +56 -0
  108. data/lib/r2ui/ext/resize.rb +62 -0
  109. data/lib/r2ui/ext/screen.rb +47 -0
  110. data/lib/r2ui/ext/screen_mode.rb +63 -0
  111. data/lib/r2ui/ext/spinner.rb +59 -0
  112. data/lib/r2ui/ext/state.rb +45 -0
  113. data/lib/r2ui/ext/stopwatch.rb +67 -0
  114. data/lib/r2ui/ext/suspend.rb +56 -0
  115. data/lib/r2ui/ext/text_area.rb +73 -0
  116. data/lib/r2ui/ext/text_input.rb +83 -0
  117. data/lib/r2ui/ext/theme.rb +112 -0
  118. data/lib/r2ui/ext/view.rb +37 -0
  119. data/lib/r2ui/ext/viewport.rb +92 -0
  120. data/lib/r2ui/ext/window_title.rb +51 -0
  121. data/lib/r2ui/extension.rb +199 -0
  122. data/lib/r2ui/history.rb +4 -1
  123. data/lib/r2ui/keys.rb +35 -8
  124. data/lib/r2ui/motion.rb +169 -0
  125. data/lib/r2ui/panel_state.rb +46 -2
  126. data/lib/r2ui/renderer.rb +87 -22
  127. data/lib/r2ui/version.rb +1 -1
  128. data/lib/r2ui/widgets/box.rb +6 -3
  129. data/lib/r2ui/widgets/glyphs.rb +149 -0
  130. data/lib/r2ui/widgets/table.rb +109 -25
  131. data/lib/r2ui.rb +12 -1
  132. metadata +119 -4
  133. data/lib/r2ui/terminal.rb +0 -53
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd8c94c82db6c74efb8c77dba2e80155c61336aa2e2f7e0c49ada508f0ea0ddf
4
- data.tar.gz: 338646823240fdbecd726268b2ef4f72388a5e7633cbdbd0eb3acd0cc143d96a
3
+ metadata.gz: c882ac44b504e3e363957635e8cb4333d278c7f7d446a9e9c0a6afebc0dae542
4
+ data.tar.gz: 2c59540c2b881f51d0934d3f574140375b6745b7dce753b27e7e232ee1488abe
5
5
  SHA512:
6
- metadata.gz: 91cc25cf40c7a4beedc55caf86f4dc13703c92c493bbadaccf326a60dea821611024f6a5f9ed8fdb8c6924e25596748c9d0948c6c35cab6291e526321b494ca5
7
- data.tar.gz: 6c5f8747986ac1b5219ecfe3bc415ccac2ed79d23b107af5ec00616daac862e1e842ef030c0fbc214bba8ff7999351aa513bcda0a88528a77884ca0517bb373b
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: feeds, panel focus, key handling and the draw loop.
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
- FRAME = 0.25
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.map(&:resource).uniq.map { |r| registry.resource(r) }
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
- @renderer = Renderer.new(registry, @feeds, @states)
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
- terminal = Terminal.new
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
- @feeds.each_value(&:stop)
39
- Signal.trap("WINCH", previous_winch || "DEFAULT")
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
- # Exposed for tests: feed keys without a terminal.
51
- def press(*keys) = keys.each { |k| handle(k) }
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
- @renderer.render(@dashboard, width:, height:, focus: @focus, zoomed: @zoomed, prompt:)
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 state = @states[@focus]
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 "/#{state.search}▏"
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 @flash if @flash && Time.now - @flash_at < FLASH_SECONDS
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 state
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[state.selected]&.rows
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