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
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s27-paginator: one page of a list at a time, with a bubbles Paginator under it.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# row do
|
|
7
|
+
# panel :deploys, resource: nil do
|
|
8
|
+
# paginate :history, items: -> { DEPLOYS.last(200) }, per_page: 10 do |deploy|
|
|
9
|
+
# "#{deploy[:sha]} #{deploy[:status]}"
|
|
10
|
+
# end
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# `items:` is read on every frame (a plain call of the lambda, so it sees the variables around
|
|
16
|
+
# it); the block turns one item into its line and runs on a Context (state, helpers). Under the
|
|
17
|
+
# page the panel shows a `Bubbles::Paginator`: dots ("● ○ ○", the default) or "1/3" with
|
|
18
|
+
# `type: :arabic`. While the panel has focus, left/h and right/l change the page; other keys
|
|
19
|
+
# (tab, q, ...) carry on as usual. When the list shrinks the page is clamped to the last one;
|
|
20
|
+
# an empty list is one empty page. `component(:history)` is the Bubbles::Paginator.
|
|
21
|
+
module R2UI
|
|
22
|
+
module Ext
|
|
23
|
+
module Paginator
|
|
24
|
+
Item = Data.define(:name, :items, :per_page, :type, :block)
|
|
25
|
+
|
|
26
|
+
TYPES = %i[dots arabic].freeze
|
|
27
|
+
STEPS = { left: -1, "h" => -1, right: 1, "l" => 1 }.freeze
|
|
28
|
+
|
|
29
|
+
# Bubbles::Paginator has no `update`; the component host sends it the non-key messages.
|
|
30
|
+
module Hosted
|
|
31
|
+
def update(_message) = [self, nil]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Re-reads the items, resizes `paginator` to them (clamping the page) and returns them.
|
|
37
|
+
def refresh(paginator, item)
|
|
38
|
+
rows = Array(item.items.call)
|
|
39
|
+
paginator.update_total_pages(rows.size)
|
|
40
|
+
rows
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def instance(app, item) = app.components.find { |c| c.item.equal?(item) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
extension :paginator do
|
|
48
|
+
dsl :panel do
|
|
49
|
+
def paginate(name, items: nil, per_page: 10, type: :dots, &block)
|
|
50
|
+
raise ArgumentError, "paginate needs items: -> { [...] }" unless items.respond_to?(:call)
|
|
51
|
+
raise ArgumentError, "paginate needs a block that draws one item" unless block
|
|
52
|
+
raise ArgumentError, "paginate per_page must be positive, got #{per_page}" unless per_page.to_i.positive?
|
|
53
|
+
unless Ext::Paginator::TYPES.include?(type)
|
|
54
|
+
raise ArgumentError, "paginate type must be one of #{Ext::Paginator::TYPES.join(", ")}, got #{type.inspect}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Component.require_bubbles!
|
|
58
|
+
item(Ext::Paginator::Item.new(name:, items:, per_page: per_page.to_i, type:, block:))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
component(Ext::Paginator::Item) do |item|
|
|
63
|
+
paginator = Bubbles::Paginator.new(type: item.type, per_page: item.per_page).extend(Ext::Paginator::Hosted)
|
|
64
|
+
Ext::Paginator.refresh(paginator, item)
|
|
65
|
+
paginator
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Keys the focused panel's paginator handles itself (50: before ordinary bindings).
|
|
69
|
+
on Bubbletea::KeyMessage, priority: 50 do |message|
|
|
70
|
+
step = Ext::Paginator::STEPS[Keys.name(message)]
|
|
71
|
+
hosted = step && app.components.find { |c| c.panel.equal?(focus) && c.item.is_a?(Ext::Paginator::Item) }
|
|
72
|
+
pass unless hosted
|
|
73
|
+
|
|
74
|
+
Ext::Paginator.refresh(hosted.model, hosted.item)
|
|
75
|
+
step.positive? ? hosted.model.next_page : hosted.model.prev_page
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
panel_item(Ext::Paginator::Item) do |item|
|
|
79
|
+
paginator = Ext::Paginator.instance(app, item).model
|
|
80
|
+
rows = Ext::Paginator.refresh(paginator, item)
|
|
81
|
+
from, to = paginator.slice_bounds(rows.size)
|
|
82
|
+
lines = rows[from...to].map { |row| call(item.block, row).to_s }
|
|
83
|
+
(lines.first([height - 1, 0].max) + [paginator.view]).join("\n")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s15-paste: bracketed paste (Bubbletea's `WithBracketedPaste`).
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# paste # turn bracketed paste on
|
|
7
|
+
# on_paste { |text| state[:clip] = text } # optional; on_paste alone turns it on too
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# With bracketed paste on, the terminal sends a paste as one message instead of one key per
|
|
11
|
+
# character, so pasted text never runs key bindings ("q" in a paste does not quit). A paste goes to:
|
|
12
|
+
#
|
|
13
|
+
# 1. the search prompt (`/`), if it is open: appended to the search, with newlines and other control
|
|
14
|
+
# characters dropped, so a paste never submits or cancels the prompt;
|
|
15
|
+
# 2. the focused component (a text input, say), as one KeyMessage carrying the whole text;
|
|
16
|
+
# 3. otherwise every `on_paste { |text| }` block, on a Context (it may flash, quit, return a
|
|
17
|
+
# command). With no `on_paste`, the paste is dropped.
|
|
18
|
+
#
|
|
19
|
+
# Bracketed paste is turned off again on every exit path (the runner restores the terminal).
|
|
20
|
+
module R2UI
|
|
21
|
+
module Ext
|
|
22
|
+
module Paste
|
|
23
|
+
# What the search prompt held before a paste, to clean what the core appended.
|
|
24
|
+
Pending = Data.define(:panel, :search, :text)
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# The engine decodes a paste as a runes KeyMessage named "[text]"; a typed key's name is its
|
|
29
|
+
# own text, so a typed "[" is not a paste.
|
|
30
|
+
def paste?(message)
|
|
31
|
+
message.is_a?(Bubbletea::KeyMessage) && message.runes? && !message.alt &&
|
|
32
|
+
!message.char.nil? && message.name == "[#{message.char}]"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
MATCHER = ->(message) { paste?(message) }
|
|
36
|
+
|
|
37
|
+
def enabled?(dashboard) = dashboard.declared(:paste).any? || dashboard.declared(:on_paste).any?
|
|
38
|
+
|
|
39
|
+
def clean(text) = text.gsub(/[[:cntrl:]]/, "")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
extension :paste do
|
|
44
|
+
dsl :dashboard do
|
|
45
|
+
def paste = declare(:paste, true)
|
|
46
|
+
|
|
47
|
+
def on_paste(&block)
|
|
48
|
+
raise ArgumentError, "on_paste needs a block" unless block
|
|
49
|
+
|
|
50
|
+
declare(:on_paste, block)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
program_options do
|
|
55
|
+
{ bracketed_paste: true } if Ext::Paste.enabled?(dashboard)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The core search prompt takes keys before any handler and appends a pasted String as is.
|
|
59
|
+
# Remember the search so after_update can drop the control characters it appended.
|
|
60
|
+
observe Ext::Paste::MATCHER do |message|
|
|
61
|
+
next unless Ext::Paste.enabled?(dashboard)
|
|
62
|
+
|
|
63
|
+
search = app.panel_state(focus)&.search
|
|
64
|
+
store(:paste)[:pending] = Ext::Paste::Pending.new(panel: focus, search:, text: message.char) if search
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
after_update do
|
|
68
|
+
pending = store(:paste).delete(:pending)
|
|
69
|
+
next unless pending
|
|
70
|
+
|
|
71
|
+
panel_state = app.panel_state(pending.panel)
|
|
72
|
+
if panel_state.search == pending.search + pending.text
|
|
73
|
+
panel_state.search = pending.search + Ext::Paste.clean(pending.text)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Runs after the focused component (priority 50) and before ordinary bindings (0), so a paste
|
|
78
|
+
# never reaches a key binding or the core keys.
|
|
79
|
+
on Ext::Paste::MATCHER, priority: App::COMPONENT_PRIORITY - 1 do |message|
|
|
80
|
+
pass unless Ext::Paste.enabled?(dashboard)
|
|
81
|
+
|
|
82
|
+
dashboard.declared(:on_paste).each { |block| call(block, message.char) }
|
|
83
|
+
nil # the blocks' commands are enqueued; returning the Array of blocks would enqueue them too
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s11-println: print a line above the program (Bubbletea's `puts` command).
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# every 60 do
|
|
7
|
+
# println "#{Time.now.strftime("%H:%M")} checked #{state[:count].to_i} deploys"
|
|
8
|
+
# end
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# `println(text)` enqueues `Bubbletea.puts(text)` and returns that command, so a block may also
|
|
12
|
+
# just end with it. In inline mode (no alt screen) the line is printed above the program's view
|
|
13
|
+
# and stays in the terminal's scrollback; the runner handles it exactly as it handles
|
|
14
|
+
# `Bubbletea.puts` returned from a plain Bubbletea model. Each call prints its own line, in call order. Available in every handler and user block.
|
|
15
|
+
module R2UI
|
|
16
|
+
extension :println do
|
|
17
|
+
helpers do
|
|
18
|
+
def println(text) = command(Bubbletea.puts(text))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s26-progress: a progress bar in a panel (bubbles Progress).
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# row height: 4 do
|
|
7
|
+
# panel :status, resource: nil do
|
|
8
|
+
# progress(:upload) { state[:sent].to_f / state[:size] } # a fraction, 0.0..1.0
|
|
9
|
+
# progress(:build, width: 30, gradient: ["#5A56E0", "#EE6FF8"], animate: true) { state[:built] }
|
|
10
|
+
# end
|
|
11
|
+
# panel :job do
|
|
12
|
+
# progress :done, of: :total # the record's done / total
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Hosts a `Bubbles::Progress` (`app.component(name)`) drawn on one line: the bar fills the
|
|
18
|
+
# panel's width (or `width:` columns) and ends with the percentage. The fraction comes from the
|
|
19
|
+
# block, run on a Context every frame, or with `of:` from the panel resource's first record
|
|
20
|
+
# (`done / total`, like the core `gauge`). Anything that isn't a finite number shows as 0;
|
|
21
|
+
# values are clamped to 0..1.
|
|
22
|
+
#
|
|
23
|
+
# `gradient: [from, to]` blends the fill between two colours. `animate: true` makes the bar
|
|
24
|
+
# spring towards the fraction (bubbles' animation) instead of jumping to it; the target is read
|
|
25
|
+
# after every update, so a change shows from the next message on.
|
|
26
|
+
module R2UI
|
|
27
|
+
module Ext
|
|
28
|
+
module Progress
|
|
29
|
+
Item = Data.define(:name, :width, :gradient, :animate, :of, :block)
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def build(item) = Bubbles::Progress.new(**{ width: item.width, gradient: item.gradient }.compact)
|
|
34
|
+
|
|
35
|
+
# The fraction an item shows, in 0..1, read on `ctx` for an item in `panel`.
|
|
36
|
+
def fraction(ctx, panel, item)
|
|
37
|
+
value = item.block ? ctx.call(item.block) : from_record(ctx.app, panel, item)
|
|
38
|
+
value = Float(value, exception: false) if value.is_a?(Numeric) || value.is_a?(String)
|
|
39
|
+
value.is_a?(Float) && value.finite? ? value.clamp(0.0, 1.0) : 0.0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def from_record(app, panel, item)
|
|
43
|
+
record = panel.resource && app.feeds[panel.resource]&.rows&.first
|
|
44
|
+
return unless record
|
|
45
|
+
|
|
46
|
+
resource = app.registry.resource(panel.resource)
|
|
47
|
+
done, total = [item.name, item.of].map { |key| (resource.column(key) || DSL::Column.build(key)).read(record) }
|
|
48
|
+
done.to_f / total.to_f if done.is_a?(Numeric) && total.is_a?(Numeric)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def instances(app) = app.components.select { |c| c.item.is_a?(Item) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
extension :progress do
|
|
56
|
+
dsl :panel do
|
|
57
|
+
def progress(name, of: nil, width: nil, gradient: nil, animate: false, &block)
|
|
58
|
+
raise ArgumentError, "progress #{name} needs a block or of:, not both" if block.nil? == of.nil?
|
|
59
|
+
raise ArgumentError, "progress width must be a positive Integer" unless width.nil? || (width.is_a?(Integer) && width.positive?)
|
|
60
|
+
raise ArgumentError, "progress gradient must be two colours" unless gradient.nil? || (gradient.is_a?(Array) && gradient.size == 2)
|
|
61
|
+
|
|
62
|
+
R2UI::Component.require_bubbles!
|
|
63
|
+
item(Ext::Progress::Item.new(name:, width:, gradient:, animate:, of:, block:))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
component(Ext::Progress::Item) { |item| Ext::Progress.build(item) }
|
|
68
|
+
|
|
69
|
+
# Animated bars: retarget when the fraction changed; set_percent returns the animation's tick.
|
|
70
|
+
after_update do
|
|
71
|
+
Ext::Progress.instances(app).each do |c|
|
|
72
|
+
next unless c.item.animate
|
|
73
|
+
|
|
74
|
+
target = Ext::Progress.fraction(self, c.panel, c.item)
|
|
75
|
+
command(c.model.set_percent(target)) if (target - c.model.percent).abs >= 0.001
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
panel_item(Ext::Progress::Item) do |item|
|
|
80
|
+
model = app.components.find { |c| c.item.equal?(item) }&.model || Ext::Progress.build(item)
|
|
81
|
+
model.width = item.width || width
|
|
82
|
+
item.animate ? model.view : model.view_as(Ext::Progress.fraction(self, panel, item))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s19-refresh: fetch a resource now instead of waiting for its interval.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# on_key "r", help: "refresh" do refresh end # the focused panel's resource
|
|
7
|
+
# every 60 do refresh(:process) end # a resource by name
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# `refresh` is a helper for any handler or user block. It enqueues a background command that
|
|
11
|
+
# fetches the resource once (the same fetch its interval runs, history included), so the update
|
|
12
|
+
# thread never waits on the source; the next frame shows the new rows. The interval keeps running
|
|
13
|
+
# as before. `refresh(:name)` refreshes that resource; an unknown name (one no panel shows) raises
|
|
14
|
+
# ArgumentError. Plain `refresh` uses the focused panel's resource and just flashes "nothing to
|
|
15
|
+
# refresh" when that panel has none. Returns the command.
|
|
16
|
+
module R2UI
|
|
17
|
+
module Ext
|
|
18
|
+
module Refresh
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# The feed `refresh` targets: the named one, or the focused panel's.
|
|
22
|
+
def feed(app, name)
|
|
23
|
+
if name.nil?
|
|
24
|
+
name = app.focus&.resource
|
|
25
|
+
return unless name
|
|
26
|
+
end
|
|
27
|
+
app.feeds.fetch(name.to_sym) do
|
|
28
|
+
raise ArgumentError, "refresh: unknown resource #{name.inspect} (known: #{app.feeds.keys.join(", ")})"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The background command that fetches `feed` once. It delivers no message: the frame reads
|
|
33
|
+
# the feed's rows directly.
|
|
34
|
+
def command(feed)
|
|
35
|
+
proc do
|
|
36
|
+
feed.refresh!
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
extension :refresh do
|
|
44
|
+
helpers do
|
|
45
|
+
def refresh(name = nil)
|
|
46
|
+
feed = Ext::Refresh.feed(app, name)
|
|
47
|
+
if feed
|
|
48
|
+
command(Ext::Refresh.command(feed))
|
|
49
|
+
else
|
|
50
|
+
flash("nothing to refresh")
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s17-resize: react to the window size, and refuse to draw below a minimum.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# on_resize { |width, height| state[:narrow] = width < 100 }
|
|
7
|
+
# min_size 80, 20
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# `on_resize` runs on every Bubbletea::WindowSizeMessage, the first one (sent at startup)
|
|
11
|
+
# included, on the update thread (a Context); a command it returns runs. It only observes the
|
|
12
|
+
# message, so the core still records the new size.
|
|
13
|
+
#
|
|
14
|
+
# Below `min_size` (width or height short) the whole view is a centered
|
|
15
|
+
# "terminal too small (WxH, need 80x20)" message instead of the dashboard.
|
|
16
|
+
module R2UI
|
|
17
|
+
module Ext
|
|
18
|
+
module Resize
|
|
19
|
+
MinSize = Data.define(:width, :height)
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# `height` lines with `text` centered in them (clipped to `width`).
|
|
24
|
+
def too_small(width, height, min)
|
|
25
|
+
text = "terminal too small (#{width}x#{height}, need #{min.width}x#{min.height})"[0, [width, 0].max]
|
|
26
|
+
lines = Array.new([height, 1].max, "")
|
|
27
|
+
lines[(lines.size - 1) / 2] = (" " * ((width - text.length) / 2)) + text
|
|
28
|
+
lines.join("\n")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
extension :resize do
|
|
34
|
+
dsl :dashboard do
|
|
35
|
+
def on_resize(&block)
|
|
36
|
+
raise ArgumentError, "on_resize needs a block" unless block
|
|
37
|
+
|
|
38
|
+
declare(:on_resize, block)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def min_size(width, height)
|
|
42
|
+
unless width.is_a?(Integer) && height.is_a?(Integer) && width.positive? && height.positive?
|
|
43
|
+
raise ArgumentError, "min_size needs positive integer width and height, got #{width}x#{height}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
declare(:min_size, Ext::Resize::MinSize.new(width:, height:))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
observe Bubbletea::WindowSizeMessage do |message|
|
|
51
|
+
dashboard.declared(:on_resize).each { |block| call(block, message.width, message.height) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
view_override do
|
|
55
|
+
min = dashboard.declared(:min_size).last
|
|
56
|
+
next unless min && width && height
|
|
57
|
+
next if width >= min.width && height >= min.height
|
|
58
|
+
|
|
59
|
+
Ext::Resize.too_small(width, height, min)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s32-screen: a program that draws itself, like a plain Bubble Tea model's `view`.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# state count: 0 # s05
|
|
7
|
+
# on_key("+") { state[:count] += 1 } # s03
|
|
8
|
+
# on_key("-") { state[:count] -= 1 }
|
|
9
|
+
# screen do |width, height|
|
|
10
|
+
# "count: #{state[:count]}\n\n+/- to change, q to quit (#{width}x#{height})"
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# The block runs on a Context (state, helpers) every frame with the window size and returns the
|
|
15
|
+
# whole view as a String ("\n" between lines, ANSI styling such as a lipgloss render kept). No
|
|
16
|
+
# panels and no status bar are drawn, so the dashboard has no rows. Keys still go through the
|
|
17
|
+
# usual routing: `on_key` / `on` handlers first, then the core, so `q` and ctrl+c quit unless a
|
|
18
|
+
# binding takes `q`.
|
|
19
|
+
module R2UI
|
|
20
|
+
module Ext
|
|
21
|
+
module Screen
|
|
22
|
+
Declaration = Data.define(:block)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
extension :screen do
|
|
27
|
+
dsl :dashboard do
|
|
28
|
+
def screen(&block)
|
|
29
|
+
raise ArgumentError, "screen needs a block" unless block
|
|
30
|
+
raise ArgumentError, "screen is already set for this dashboard" if @d.declared(:screen).any?
|
|
31
|
+
|
|
32
|
+
declare(:screen, Ext::Screen::Declaration.new(block:))
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
setup do
|
|
37
|
+
if dashboard.declared(:screen).any? && dashboard.rows.any?
|
|
38
|
+
raise Error, "dashboard #{dashboard.name}: screen draws the whole view, so it can't have rows"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
view_override do
|
|
43
|
+
screen = dashboard.declared(:screen).first
|
|
44
|
+
call(screen.block, width, height).to_s if screen
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s13-screen-mode: alt screen vs inline (Bubbletea's WithAltScreen, EnterAltScreen, ExitAltScreen).
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# inline height: 10 # no alt screen; draw 10 lines under the prompt
|
|
7
|
+
# row { panel(:log, resource: nil) { view { state[:last].to_s } } }
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# A dashboard takes the whole terminal in the alt screen by default. `inline height: N` runs it in
|
|
11
|
+
# the normal screen instead, drawn N lines tall (at the terminal's width), so the last frame stays
|
|
12
|
+
# in the scrollback when the program quits.
|
|
13
|
+
#
|
|
14
|
+
# Any block can switch at runtime with the helpers, which enqueue Bubbletea's commands:
|
|
15
|
+
#
|
|
16
|
+
# on_key "f" do enter_alt_screen end # full screen (an inline dashboard then draws full height)
|
|
17
|
+
# on_key "i" do exit_alt_screen end # back to the normal screen
|
|
18
|
+
module R2UI
|
|
19
|
+
module Ext
|
|
20
|
+
module ScreenMode
|
|
21
|
+
# What `inline` declares on the dashboard.
|
|
22
|
+
Inline = Data.define(:height)
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# The inline height the dashboard declared, or nil.
|
|
27
|
+
def inline_height(dashboard) = dashboard.declared(:inline).last&.height
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
extension :screen_mode do
|
|
32
|
+
dsl :dashboard do
|
|
33
|
+
def inline(height:)
|
|
34
|
+
unless height.is_a?(Integer) && height.positive?
|
|
35
|
+
raise ArgumentError, "inline needs a positive integer height, got #{height.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
declare(:inline, Ext::ScreenMode::Inline.new(height:))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
helpers do
|
|
43
|
+
def enter_alt_screen
|
|
44
|
+
store(:screen_mode)[:alt] = true
|
|
45
|
+
command(Bubbletea.enter_alt_screen)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def exit_alt_screen
|
|
49
|
+
store(:screen_mode)[:alt] = false
|
|
50
|
+
command(Bubbletea.exit_alt_screen)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
program_options do
|
|
55
|
+
Ext::ScreenMode.inline_height(dashboard) ? { alt_screen: false } : {}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
frame_size do |width, height|
|
|
59
|
+
inline = Ext::ScreenMode.inline_height(dashboard)
|
|
60
|
+
inline && !store(:screen_mode)[:alt] ? [width, inline] : [width, height]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s25-spinner: bubbles' Spinner as a panel item.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# row height: 3 do
|
|
7
|
+
# panel :work, resource: nil do
|
|
8
|
+
# spinner :busy, style: :dot, label: "deploying", while: -> { state[:busy] }
|
|
9
|
+
# end
|
|
10
|
+
# end
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# `style` names any of bubbles' spinners (`Bubbles::Spinners::DOT` is `:dot`, `MINI_DOT` is
|
|
14
|
+
# `:mini_dot`, ...). The spinner starts ticking at init on its own TickMessages (bubbles scopes them
|
|
15
|
+
# by id, so two spinners animate independently). With `while:` the item draws only while the lambda
|
|
16
|
+
# (run on a Context) is true; it keeps ticking meanwhile, so it resumes in step.
|
|
17
|
+
module R2UI
|
|
18
|
+
module Ext
|
|
19
|
+
module Spinner
|
|
20
|
+
Item = Data.define(:name, :style, :label, :visible)
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# The Bubbles::Spinners entry for `style` (a Symbol or String such as :dot or :mini_dot).
|
|
25
|
+
def frames_for(style)
|
|
26
|
+
Component.require_bubbles!
|
|
27
|
+
const = style.to_s.upcase
|
|
28
|
+
spec = Bubbles::Spinners.const_get(const, false) if Bubbles::Spinners.const_defined?(const, false)
|
|
29
|
+
return spec if spec.is_a?(Hash) && spec[:frames]
|
|
30
|
+
|
|
31
|
+
known = Bubbles::Spinners.constants.select { |c| Bubbles::Spinners.const_get(c).is_a?(Hash) }
|
|
32
|
+
raise ArgumentError, "unknown spinner style #{style.inspect} (one of #{known.map { |c| c.to_s.downcase }.join(", ")})"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
extension :spinner do
|
|
38
|
+
dsl :panel do
|
|
39
|
+
def spinner(name, style: :dot, label: nil, while: nil)
|
|
40
|
+
visible = binding.local_variable_get(:while)
|
|
41
|
+
raise ArgumentError, "spinner while: needs a callable" if visible && !visible.respond_to?(:call)
|
|
42
|
+
|
|
43
|
+
Ext::Spinner.frames_for(style)
|
|
44
|
+
item(Ext::Spinner::Item.new(name:, style:, label:, visible:))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
component(Ext::Spinner::Item) { |item| Bubbles::Spinner.new(spinner: Ext::Spinner.frames_for(item.style)) }
|
|
49
|
+
|
|
50
|
+
panel_item(Ext::Spinner::Item) do |item|
|
|
51
|
+
next "" if item.visible && !call(item.visible)
|
|
52
|
+
|
|
53
|
+
model = app.components.find { |c| c.item.equal?(item) }&.model
|
|
54
|
+
next "" unless model
|
|
55
|
+
|
|
56
|
+
[model.view, item.label].compact.join(" ")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s05-state: declared initial state (a Bubbletea model's starting fields).
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# state count: 0, log: []
|
|
7
|
+
# state theme: :dark # a second call merges (later keys win)
|
|
8
|
+
#
|
|
9
|
+
# every 1 do state[:count] += 1 end
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# The declared values seed the app's `state` Hash in `setup`, before init and before any frame,
|
|
13
|
+
# so snapshots and the first frame see them. Each app gets a deep copy (Hashes, Arrays and
|
|
14
|
+
# Strings are copied all the way down), so two apps never share the `log` array and mutating
|
|
15
|
+
# one app's state never changes the declaration.
|
|
16
|
+
module R2UI
|
|
17
|
+
module Ext
|
|
18
|
+
module State
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# A copy of `value` whose Hashes, Arrays and unfrozen Strings are fresh objects.
|
|
22
|
+
def deep_copy(value)
|
|
23
|
+
case value
|
|
24
|
+
when Hash then value.to_h { |k, v| [deep_copy(k), deep_copy(v)] }
|
|
25
|
+
when Array then value.map { |v| deep_copy(v) }
|
|
26
|
+
when String then value.frozen? ? value : value.dup
|
|
27
|
+
else value
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
extension :state do
|
|
34
|
+
dsl :dashboard do
|
|
35
|
+
def state(values = nil, **fields)
|
|
36
|
+
values = (values || {}).merge(fields)
|
|
37
|
+
declare(:state, Ext::State.deep_copy(values))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
setup do
|
|
42
|
+
dashboard.declared(:state).each { |values| state.merge!(Ext::State.deep_copy(values)) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s30-stopwatch: an elapsed-time counter (bubbles' Stopwatch) as a panel item.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# row height: 3 do
|
|
7
|
+
# panel :lap, resource: nil do
|
|
8
|
+
# stopwatch :lap # starts counting when the app starts
|
|
9
|
+
# # stopwatch :lap, autostart: false # waits for `s`
|
|
10
|
+
# end
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# The panel shows the elapsed time ("0:42.00", "1:02:03.00" past an hour), counting in whole
|
|
15
|
+
# seconds. While the panel is focused, `s` starts or stops it and `r` resets it to zero; other keys
|
|
16
|
+
# go on to your bindings and the core as usual. `component(:lap)` is the Bubbles::Stopwatch, for
|
|
17
|
+
# reading `elapsed` / `running?` or sending its `start`, `stop`, `toggle` and `reset` commands.
|
|
18
|
+
#
|
|
19
|
+
# Needs the bubbles gem (it is loaded the first time `stopwatch` is used).
|
|
20
|
+
module R2UI
|
|
21
|
+
module Ext
|
|
22
|
+
module Stopwatch
|
|
23
|
+
Item = Data.define(:name, :autostart)
|
|
24
|
+
|
|
25
|
+
HINTS = [%w[s start/stop], %w[r reset]].freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def build(item)
|
|
30
|
+
model = Bubbles::Stopwatch.new
|
|
31
|
+
model.define_singleton_method(:init) { nil } unless item.autostart
|
|
32
|
+
model
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The stopwatch hosted in the focused panel, if any.
|
|
36
|
+
def focused(app)
|
|
37
|
+
app.components.find { |c| c.item.is_a?(Item) && c.panel == app.focus }&.model
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
extension :stopwatch do
|
|
43
|
+
dsl :panel do
|
|
44
|
+
def stopwatch(name, autostart: true)
|
|
45
|
+
raise ArgumentError, "stopwatch needs a Symbol name, got #{name.inspect}" unless name.is_a?(Symbol)
|
|
46
|
+
|
|
47
|
+
Component.require_bubbles!
|
|
48
|
+
item(Ext::Stopwatch::Item.new(name:, autostart:))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
component(Ext::Stopwatch::Item) { |item| Ext::Stopwatch.build(item) }
|
|
53
|
+
|
|
54
|
+
on Bubbletea::KeyMessage, priority: 50 do |message|
|
|
55
|
+
watch = Ext::Stopwatch.focused(app)
|
|
56
|
+
pass unless watch
|
|
57
|
+
|
|
58
|
+
case Keys.name(message)
|
|
59
|
+
when "s" then command(watch.toggle)
|
|
60
|
+
when "r" then command(watch.reset)
|
|
61
|
+
else pass
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
hints { focus.items.any?(Ext::Stopwatch::Item) ? Ext::Stopwatch::HINTS : [] }
|
|
66
|
+
end
|
|
67
|
+
end
|