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
data/lib/r2ui/dsl/dashboard.rb
CHANGED
|
@@ -5,17 +5,27 @@ module R2UI
|
|
|
5
5
|
Gauge = Data.define(:attr, :of, :label)
|
|
6
6
|
Stat = Data.define(:attrs)
|
|
7
7
|
Sparkline = Data.define(:attr, :label, :height)
|
|
8
|
-
|
|
8
|
+
# `motion`: marks moved/new lines in a left gutter and keeps the selection on its line across
|
|
9
|
+
# re-sorts (needs the app's Motion). `columns`: an Array of column keys to draw, in that order
|
|
10
|
+
# (nil: all of the resource's index columns).
|
|
11
|
+
Table = Data.define(:scope, :group_by, :sort, :limit, :motion, :columns) do
|
|
12
|
+
def initialize(scope: nil, group_by: nil, sort: nil, limit: nil, motion: false, columns: nil)
|
|
13
|
+
super(scope:, group_by:, sort:, limit:, motion:, columns: columns && Array(columns).map(&:to_sym).freeze)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
9
16
|
|
|
10
|
-
|
|
17
|
+
# `resource` is nil for a panel that shows only extension items (a text input, a spinner, ...).
|
|
18
|
+
# `options` holds keyword options the core doesn't know, for extensions to read.
|
|
19
|
+
Panel = Data.define(:name, :resource, :span, :title, :items, :options) do
|
|
11
20
|
def table = items.find { |i| i.is_a?(Table) }
|
|
12
21
|
end
|
|
13
22
|
|
|
14
23
|
Row = Data.define(:height, :panels)
|
|
15
24
|
|
|
16
25
|
# A screen made of rows of panels. Each panel shows one resource.
|
|
26
|
+
# Extensions record their own declarations (timers, key bindings, ...) under `declarations`.
|
|
17
27
|
class Dashboard
|
|
18
|
-
attr_reader :name, :title, :rows
|
|
28
|
+
attr_reader :name, :title, :rows, :declarations
|
|
19
29
|
|
|
20
30
|
def self.build(name, &block)
|
|
21
31
|
dashboard = new(name)
|
|
@@ -32,21 +42,34 @@ module R2UI
|
|
|
32
42
|
@name = name.to_sym
|
|
33
43
|
@title = name.to_s.tr("_", " ").capitalize
|
|
34
44
|
@rows = []
|
|
45
|
+
@declarations = Hash.new { |h, k| h[k] = [] }
|
|
35
46
|
end
|
|
36
47
|
|
|
37
48
|
def panels = rows.flat_map(&:panels)
|
|
38
49
|
|
|
50
|
+
# Everything an extension declared under `key`, in order ([] if none).
|
|
51
|
+
def declared(key) = declarations.fetch(key, [])
|
|
52
|
+
|
|
39
53
|
class Builder
|
|
40
54
|
def initialize(dashboard) = @d = dashboard
|
|
41
55
|
|
|
42
56
|
def title(text) = @d.instance_variable_set(:@title, text)
|
|
43
57
|
|
|
44
|
-
# `height:` in terminal lines
|
|
58
|
+
# `height:` in terminal lines (or a callable returning them, evaluated each frame); rows
|
|
59
|
+
# without one share the remaining space.
|
|
45
60
|
def row(height: nil, &block)
|
|
46
61
|
builder = RowBuilder.new
|
|
47
62
|
builder.instance_eval(&block)
|
|
48
63
|
@d.rows << Row.new(height:, panels: builder.panels)
|
|
49
64
|
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# For extension keywords: record `value` under `key` on the dashboard. Returns `value`.
|
|
69
|
+
def declare(key, value)
|
|
70
|
+
@d.declarations[key] << value
|
|
71
|
+
value
|
|
72
|
+
end
|
|
50
73
|
end
|
|
51
74
|
|
|
52
75
|
class RowBuilder
|
|
@@ -55,10 +78,15 @@ module R2UI
|
|
|
55
78
|
def initialize = @panels = []
|
|
56
79
|
|
|
57
80
|
# `span:` is a relative width; panels in a row split it by their spans.
|
|
58
|
-
|
|
81
|
+
# `resource: nil` makes a panel with no data source (extension items only).
|
|
82
|
+
def panel(name, resource: name, span: 1, title: nil, **options, &block)
|
|
59
83
|
items = PanelBuilder.new.tap { |b| b.instance_eval(&block) if block }.items
|
|
60
|
-
|
|
61
|
-
|
|
84
|
+
if items.empty?
|
|
85
|
+
raise Error, "panel #{name} has no resource and nothing to show" unless resource
|
|
86
|
+
|
|
87
|
+
items = [Table.new(scope: nil, group_by: nil, sort: nil, limit: nil)]
|
|
88
|
+
end
|
|
89
|
+
@panels << Panel.new(name:, resource:, span:, title:, items:, options:)
|
|
62
90
|
end
|
|
63
91
|
end
|
|
64
92
|
|
|
@@ -73,8 +101,16 @@ module R2UI
|
|
|
73
101
|
|
|
74
102
|
def sparkline(attr, label: nil, height: 3) = @items << Sparkline.new(attr:, label:, height:)
|
|
75
103
|
|
|
76
|
-
def table(scope: nil, group_by: nil, sort: nil, limit: nil)
|
|
77
|
-
@items << Table.new(scope:, group_by:, sort:, limit:)
|
|
104
|
+
def table(scope: nil, group_by: nil, sort: nil, limit: nil, motion: false, columns: nil)
|
|
105
|
+
@items << Table.new(scope:, group_by:, sort:, limit:, motion:, columns:)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# For extension keywords: add an item (any object) to the panel. Returns it.
|
|
111
|
+
def item(value)
|
|
112
|
+
@items << value
|
|
113
|
+
value
|
|
78
114
|
end
|
|
79
115
|
end
|
|
80
116
|
end
|
data/lib/r2ui/dsl/resource.rb
CHANGED
|
@@ -33,8 +33,14 @@ module R2UI
|
|
|
33
33
|
@columns = []
|
|
34
34
|
@searchable = []
|
|
35
35
|
@actions = []
|
|
36
|
+
@declarations = Hash.new { |h, k| h[k] = [] }
|
|
36
37
|
end
|
|
37
38
|
|
|
39
|
+
# What extensions declared on this resource, by key.
|
|
40
|
+
attr_reader :declarations
|
|
41
|
+
|
|
42
|
+
def declared(key) = declarations.fetch(key, [])
|
|
43
|
+
|
|
38
44
|
def column(key) = columns.find { |c| c.key == key }
|
|
39
45
|
|
|
40
46
|
def default_scope = scopes.find(&:default) || scopes.first
|
|
@@ -99,6 +105,12 @@ module R2UI
|
|
|
99
105
|
|
|
100
106
|
private
|
|
101
107
|
|
|
108
|
+
# For extension keywords: record `value` under `key` on the resource. Returns `value`.
|
|
109
|
+
def declare(key, value)
|
|
110
|
+
@r.declarations[key] << value
|
|
111
|
+
value
|
|
112
|
+
end
|
|
113
|
+
|
|
102
114
|
def set(ivar, value) = @r.instance_variable_set(:"@#{ivar}", value)
|
|
103
115
|
|
|
104
116
|
def humanize(sym) = sym.to_s.tr("_", " ").capitalize
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s06-after: a one-shot delay (a single Bubbletea tick).
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# every 60 do
|
|
7
|
+
# state[:banner] = "syncing…"
|
|
8
|
+
# after 3 do # seconds, or anything with #to_f
|
|
9
|
+
# state[:banner] = nil # runs once, 3 s later, on the update thread
|
|
10
|
+
# flash "synced" # any Context helper; a returned command runs too
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# `after` is a helper, so it works from any block: a key binding, a timer, an `on` handler, a
|
|
16
|
+
# component callback. Each call schedules its own one-shot; a later `after` never cancels an
|
|
17
|
+
# earlier one. It returns the Bubbletea command (already enqueued). The delay may be 0 (run on the
|
|
18
|
+
# next update); a negative delay or a missing block raises.
|
|
19
|
+
module R2UI
|
|
20
|
+
module Ext
|
|
21
|
+
module After
|
|
22
|
+
# The message a one-shot delivers. `app` scopes it to the app that scheduled it.
|
|
23
|
+
class Fire < Bubbletea::Message
|
|
24
|
+
attr_reader :block, :app
|
|
25
|
+
|
|
26
|
+
def initialize(block:, app:)
|
|
27
|
+
super()
|
|
28
|
+
@block = block
|
|
29
|
+
@app = app
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
extension :after do
|
|
36
|
+
helpers do
|
|
37
|
+
def after(seconds, &block)
|
|
38
|
+
raise ArgumentError, "after needs a block" unless block
|
|
39
|
+
|
|
40
|
+
seconds = seconds.to_f
|
|
41
|
+
raise ArgumentError, "after needs a non-negative delay, got #{seconds}" if seconds.negative?
|
|
42
|
+
|
|
43
|
+
target = app
|
|
44
|
+
command(Bubbletea.tick(seconds) { Ext::After::Fire.new(block:, app: target) })
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
on Ext::After::Fire do |fire|
|
|
49
|
+
pass unless fire.app.equal?(app)
|
|
50
|
+
|
|
51
|
+
call(fire.block)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s08-async: background work (a Bubbletea Proc command, with its result routed back).
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# every 30 do
|
|
7
|
+
# async(-> { Net::HTTP.get(URI("https://example.com/status")) }) do |body, error|
|
|
8
|
+
# if error
|
|
9
|
+
# flash "status failed: #{error.message}"
|
|
10
|
+
# else
|
|
11
|
+
# state[:status] = body # any Context helper; a returned command runs too
|
|
12
|
+
# end
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# `async(callable) { |value, error| }` is a helper, usable from any block (a timer, a key
|
|
18
|
+
# binding, a handler). The callable runs off the update thread, so the UI keeps drawing and taking
|
|
19
|
+
# keys meanwhile; it must not touch app state. When it finishes, the block runs on the update
|
|
20
|
+
# thread with its return value and nil, or with nil and the exception if it raised (StandardError).
|
|
21
|
+
# Returns the command (already enqueued). Results that arrive after the app has quit are dropped.
|
|
22
|
+
module R2UI
|
|
23
|
+
module Ext
|
|
24
|
+
module Async
|
|
25
|
+
# The message a finished callable delivers. `app` scopes it to the app that started it.
|
|
26
|
+
class Done < Bubbletea::Message
|
|
27
|
+
attr_reader :app, :block, :value, :error
|
|
28
|
+
|
|
29
|
+
def initialize(app:, block:, value:, error:)
|
|
30
|
+
super()
|
|
31
|
+
@app = app
|
|
32
|
+
@block = block
|
|
33
|
+
@value = value
|
|
34
|
+
@error = error
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# The Proc command the runner calls on its own thread; it returns the Done message.
|
|
41
|
+
def command(app, callable, block)
|
|
42
|
+
proc do
|
|
43
|
+
Done.new(app:, block:, value: callable.call, error: nil)
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
Done.new(app:, block:, value: nil, error: e)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# True if `command` quits the program (alone, or inside a batch or sequence).
|
|
50
|
+
def quits?(command)
|
|
51
|
+
case command
|
|
52
|
+
when Bubbletea::QuitCommand then true
|
|
53
|
+
when Bubbletea::BatchCommand, Bubbletea::SequenceCommand then command.commands.any? { |c| quits?(c) }
|
|
54
|
+
else false
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
extension :async do
|
|
61
|
+
helpers do
|
|
62
|
+
def async(callable, &block)
|
|
63
|
+
raise ArgumentError, "async needs a callable, got #{callable.inspect}" unless callable.respond_to?(:call)
|
|
64
|
+
raise ArgumentError, "async needs a block" unless block
|
|
65
|
+
|
|
66
|
+
command(Ext::Async.command(app, callable, block))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Remember that this app quit, so late results are dropped.
|
|
71
|
+
after_update do
|
|
72
|
+
store(:async)[:quit] = true if Ext::Async.quits?(commands)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
on Ext::Async::Done do |done|
|
|
76
|
+
pass unless done.app.equal?(app)
|
|
77
|
+
|
|
78
|
+
call(done.block, done.value, done.error) unless store(:async)[:quit]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s07-batch-sequence: command composition (Bubbletea.batch / Bubbletea.sequence, as blocks).
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# on_key "d", help: "deploy" do
|
|
7
|
+
# sequence do # in order; stops at quit
|
|
8
|
+
# flash "deploying"
|
|
9
|
+
# command(-> { Deploy.run }) # any command a helper enqueues, or the block returns
|
|
10
|
+
# batch do # concurrently
|
|
11
|
+
# command(-> { Slack.notify })
|
|
12
|
+
# command(-> { Metrics.bump })
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# `batch { }` groups every command enqueued inside the block (by `command`, `quit`, any command
|
|
19
|
+
# helper, or the block's return value) into one Bubbletea.batch; `sequence { }` into one
|
|
20
|
+
# Bubbletea.sequence. The grouped commands don't also run on their own. Blocks nest. Each returns
|
|
21
|
+
# the grouped command (already enqueued), or nil if the block enqueued nothing.
|
|
22
|
+
module R2UI
|
|
23
|
+
module Ext
|
|
24
|
+
module BatchSequence
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Runs `block` on `context` with an empty command list, then enqueues what it collected as
|
|
28
|
+
# one command built by `combine` (Bubbletea.batch or Bubbletea.sequence).
|
|
29
|
+
def group(context, combine, block)
|
|
30
|
+
raise ArgumentError, "#{combine} needs a block" unless block
|
|
31
|
+
|
|
32
|
+
collected = context.instance_variable_get(:@commands)
|
|
33
|
+
context.instance_variable_set(:@commands, [])
|
|
34
|
+
begin
|
|
35
|
+
context.call(block)
|
|
36
|
+
inner = context.instance_variable_get(:@commands)
|
|
37
|
+
ensure
|
|
38
|
+
context.instance_variable_set(:@commands, collected)
|
|
39
|
+
end
|
|
40
|
+
return nil if inner.empty?
|
|
41
|
+
|
|
42
|
+
context.command(Bubbletea.public_send(combine, *inner))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
extension :batch_sequence do
|
|
48
|
+
helpers do
|
|
49
|
+
def batch(&block) = Ext::BatchSequence.group(self, :batch, block)
|
|
50
|
+
|
|
51
|
+
def sequence(&block) = Ext::BatchSequence.group(self, :sequence, block)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s29-timer: a countdown in a panel (a hosted Bubbles::Timer).
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# row height: 3 do
|
|
7
|
+
# panel :clock, resource: nil do
|
|
8
|
+
# countdown :deadline, 90, on_timeout: -> { flash "time's up" } # seconds, or anything with #to_f
|
|
9
|
+
# end
|
|
10
|
+
# end
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# The timer starts when the app starts, ticks once a second and shows the time left ("1m30s",
|
|
14
|
+
# "5s", "0s"). When it ends, `on_timeout` runs once on the update thread (a Context: state, flash,
|
|
15
|
+
# quit, ...); a command it returns runs too. `component(:deadline)` is the Bubbles::Timer, so a
|
|
16
|
+
# block can `command(component(:deadline).stop)` / `.start` / `.toggle` it.
|
|
17
|
+
#
|
|
18
|
+
# Needs the bubbles gem (optional for apps).
|
|
19
|
+
module R2UI
|
|
20
|
+
module Ext
|
|
21
|
+
module Countdown
|
|
22
|
+
# What `countdown` adds to the panel.
|
|
23
|
+
Item = Data.define(:name, :seconds, :on_timeout)
|
|
24
|
+
|
|
25
|
+
# Matches a Bubbles::Timer::TimeoutMessage without naming Bubbles at load time.
|
|
26
|
+
TIMEOUT = ->(msg) { defined?(::Bubbles::Timer::TimeoutMessage) && msg.is_a?(::Bubbles::Timer::TimeoutMessage) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
extension :countdown do
|
|
31
|
+
dsl :panel do
|
|
32
|
+
def countdown(name, seconds, on_timeout: nil)
|
|
33
|
+
seconds = seconds.to_f
|
|
34
|
+
raise ArgumentError, "countdown needs a positive duration, got #{seconds}" unless seconds.positive?
|
|
35
|
+
|
|
36
|
+
Component.require_bubbles!
|
|
37
|
+
item(Ext::Countdown::Item.new(name:, seconds:, on_timeout:))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
component(Ext::Countdown::Item) { |item| Bubbles::Timer.new(item.seconds) }
|
|
42
|
+
|
|
43
|
+
observe Ext::Countdown::TIMEOUT do |msg|
|
|
44
|
+
instance = app.components.find { |c| c.item.is_a?(Ext::Countdown::Item) && c.model.id == msg.id }
|
|
45
|
+
fired = (store(:countdown)[:fired] ||= {})
|
|
46
|
+
if instance && !fired[msg.id]
|
|
47
|
+
fired[msg.id] = true
|
|
48
|
+
call(instance.item.on_timeout) if instance.item.on_timeout
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s23-data-table: a bubbles Table in a panel.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# row height: 12 do
|
|
7
|
+
# panel :services, resource: nil do
|
|
8
|
+
# data_table :services,
|
|
9
|
+
# columns: [["Name", 20], ["Count", 5]], # [title, width], ...
|
|
10
|
+
# rows: -> { state[:services] || [] }, # Array of Arrays, read every frame
|
|
11
|
+
# on_select: ->(row) { flash "picked #{row.first}" }
|
|
12
|
+
# end
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# The panel hosts a `Bubbles::Table` (`component(:services)` returns it). Its height follows the
|
|
17
|
+
# panel, and `rows` (run on a Context) is re-read on every frame. While the panel has focus the
|
|
18
|
+
# table takes its keys (up/down, j/k, pgup/pgdown, home/end, as bubbles defines them); enter calls
|
|
19
|
+
# `on_select` with the row under the cursor. Needs the bubbles gem.
|
|
20
|
+
module R2UI
|
|
21
|
+
module Ext
|
|
22
|
+
module DataTable
|
|
23
|
+
Item = Data.define(:name, :columns, :rows, :on_select)
|
|
24
|
+
|
|
25
|
+
# Rows for the header and the separator under it.
|
|
26
|
+
CHROME = 2
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def enter?(message) = message.is_a?(Bubbletea::KeyMessage) && Keys.name(message) == :enter
|
|
31
|
+
|
|
32
|
+
def columns(item) = item.columns.map { |title, width| { title: title.to_s, width: Integer(width) } }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
extension :data_table do
|
|
37
|
+
dsl :panel do
|
|
38
|
+
def data_table(name = nil, columns: nil, rows: nil, on_select: nil)
|
|
39
|
+
raise ArgumentError, "data_table needs a name" unless name
|
|
40
|
+
raise ArgumentError, "data_table needs columns: [[title, width], ...]" if columns.nil? || columns.empty?
|
|
41
|
+
raise ArgumentError, "data_table needs rows: -> { [...] }" unless rows.respond_to?(:call)
|
|
42
|
+
|
|
43
|
+
R2UI::Component.require_bubbles!
|
|
44
|
+
item(Ext::DataTable::Item.new(name: name.to_sym, columns:, rows:, on_select:))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
component(Ext::DataTable::Item, focusable: true) do |item|
|
|
49
|
+
Bubbles::Table.new(columns: Ext::DataTable.columns(item), rows: Array(call(item.rows)))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
panel_item(Ext::DataTable::Item) do |item|
|
|
53
|
+
table = component(item.name)
|
|
54
|
+
table.height = [height - Ext::DataTable::CHROME, 1].max
|
|
55
|
+
table.rows = Array(call(item.rows))
|
|
56
|
+
table.view
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Enter on a focused table selects its row; runs before the table itself (which ignores enter).
|
|
60
|
+
on(->(message) { Ext::DataTable.enter?(message) }, priority: 50) do
|
|
61
|
+
item = focus&.items&.find { |i| i.is_a?(Ext::DataTable::Item) }
|
|
62
|
+
table = item && component(item.name)
|
|
63
|
+
pass unless table&.focused? && item.on_select
|
|
64
|
+
|
|
65
|
+
row = table.selected_row_data
|
|
66
|
+
pass unless row
|
|
67
|
+
|
|
68
|
+
call(item.on_select, row)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s01-every: repeating timers (Bubbletea's tick loop, declared).
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# every 5 do # seconds, or anything with #to_f (5.seconds)
|
|
7
|
+
# state[:checked_at] = Time.now
|
|
8
|
+
# flash "checked" # any Context helper; a returned command runs too
|
|
9
|
+
# end
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# Each timer starts when the app starts and fires every `seconds` until it quits. The block runs
|
|
13
|
+
# on the update thread (a Context), so it may touch app state freely.
|
|
14
|
+
#
|
|
15
|
+
# This is the reference extension: a story copies its shape (a DSL keyword that `declare`s plain
|
|
16
|
+
# data, an `init` hook that starts things, an `on` handler for its own message) and its test
|
|
17
|
+
# (test/ext/every_test.rb).
|
|
18
|
+
module R2UI
|
|
19
|
+
module Ext
|
|
20
|
+
module Every
|
|
21
|
+
# What `every` declares on the dashboard.
|
|
22
|
+
Timer = Data.define(:seconds, :block)
|
|
23
|
+
|
|
24
|
+
# The message a timer's tick delivers. `app` scopes it to the app that scheduled it.
|
|
25
|
+
# Messages subclass Bubbletea::Message: the runner only delivers those from background commands.
|
|
26
|
+
class Tick < Bubbletea::Message
|
|
27
|
+
attr_reader :timer, :app
|
|
28
|
+
|
|
29
|
+
def initialize(timer:, app:)
|
|
30
|
+
super()
|
|
31
|
+
@timer = timer
|
|
32
|
+
@app = app
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# The command that delivers `timer`'s next Tick to `app`.
|
|
39
|
+
def schedule(app, timer) = Bubbletea.tick(timer.seconds) { Tick.new(timer:, app:) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
extension :every do
|
|
44
|
+
dsl :dashboard do
|
|
45
|
+
def every(seconds, &block)
|
|
46
|
+
raise ArgumentError, "every needs a block" unless block
|
|
47
|
+
|
|
48
|
+
seconds = seconds.to_f
|
|
49
|
+
raise ArgumentError, "every needs a positive interval, got #{seconds}" unless seconds.positive?
|
|
50
|
+
|
|
51
|
+
declare(:every, Ext::Every::Timer.new(seconds:, block:))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
init do
|
|
56
|
+
dashboard.declared(:every).each { |timer| command(Ext::Every.schedule(app, timer)) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
on Ext::Every::Tick do |tick|
|
|
60
|
+
pass unless tick.app.equal?(app)
|
|
61
|
+
|
|
62
|
+
call(tick.timer.block)
|
|
63
|
+
command(Ext::Every.schedule(app, tick.timer))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s09-exec: run an external program in the foreground (Bubble Tea's ExecProcess).
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# on_key "ctrl+e" do
|
|
7
|
+
# execute(ENV.fetch("EDITOR", "vi"), "notes.md") { |status| flash "editor: #{status.exitstatus}" }
|
|
8
|
+
# end
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# `execute(*argv) { |status| }` enqueues a Bubbletea::ExecCommand and returns it. When the runner
|
|
12
|
+
# runs it, the terminal is released (mouse off, cursor shown, cooked mode; the alt screen is left if
|
|
13
|
+
# the app is in it), the program runs with the app's stdin/stdout/stderr and the app waits for it.
|
|
14
|
+
# Then the terminal is taken back (alt screen entered again, the whole frame redrawn) and the block
|
|
15
|
+
# runs on the update thread (a Context) with the program's Process::Status. A program that can't be
|
|
16
|
+
# started gives a status with exit code 127, like a shell.
|
|
17
|
+
#
|
|
18
|
+
# The compat runner (upstream bubbletea 0.1.4, copied unchanged) only toggles raw mode, cursor and
|
|
19
|
+
# mouse around an ExecCommand; it neither leaves the alt screen nor repaints. Go's Bubble Tea does
|
|
20
|
+
# both (ReleaseTerminal / RestoreTerminal), so this extension finds the runner driving the app and
|
|
21
|
+
# does them on its Program, which keeps the terminal's mode flags right for the cleanup at exit.
|
|
22
|
+
module R2UI
|
|
23
|
+
module Ext
|
|
24
|
+
module Exec
|
|
25
|
+
# The message the runner delivers once the program has exited.
|
|
26
|
+
class Done < Bubbletea::Message
|
|
27
|
+
attr_reader :app, :block, :status
|
|
28
|
+
|
|
29
|
+
def initialize(app:, block:)
|
|
30
|
+
super()
|
|
31
|
+
@app = app
|
|
32
|
+
@block = block
|
|
33
|
+
@status = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def finish(status) = @status = status
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# The ExecCommand that runs `argv` for `app`, then delivers a Done for `block`.
|
|
42
|
+
def command(app, argv, block)
|
|
43
|
+
done = Done.new(app:, block:)
|
|
44
|
+
Bubbletea.exec(-> { done.finish(run(app, argv)) }, message: done)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Runs `argv` in the foreground with the terminal released; returns its Process::Status.
|
|
48
|
+
def run(app, argv)
|
|
49
|
+
terminal = Terminal.find(app)
|
|
50
|
+
terminal&.release
|
|
51
|
+
system(*argv)
|
|
52
|
+
$?
|
|
53
|
+
ensure
|
|
54
|
+
terminal&.restore
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The runner's Program and renderer for one app, for leaving and re-entering the alt screen.
|
|
58
|
+
class Terminal
|
|
59
|
+
def self.find(app)
|
|
60
|
+
runner = ObjectSpace.each_object(Bubbletea::Runner).find do |r|
|
|
61
|
+
r.instance_variable_get(:@model).equal?(app)
|
|
62
|
+
end
|
|
63
|
+
runner && new(runner)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def initialize(runner)
|
|
67
|
+
@runner = runner
|
|
68
|
+
@program = runner.instance_variable_get(:@program)
|
|
69
|
+
@renderer_id = runner.instance_variable_get(:@renderer_id)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def release
|
|
73
|
+
@alt_screen = @runner.instance_variable_get(:@in_alt_screen)
|
|
74
|
+
@program.exit_alt_screen if @alt_screen
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Re-enters the alt screen (which clears it) and makes the next frame draw in full.
|
|
78
|
+
def restore
|
|
79
|
+
@program.enter_alt_screen if @alt_screen
|
|
80
|
+
repaint
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Go's repaint + resetLinesRendered: forget the last frame so the next one is drawn whole,
|
|
86
|
+
# below the program's output when inline.
|
|
87
|
+
def repaint
|
|
88
|
+
renderer = @renderer_id && defined?(R2UI::Compat::Tea) && R2UI::Compat::Tea.renderer(@renderer_id)
|
|
89
|
+
return unless renderer
|
|
90
|
+
|
|
91
|
+
renderer.instance_variable_get(:@mutex).synchronize do
|
|
92
|
+
renderer.instance_variable_set(:@last_render, "".b)
|
|
93
|
+
renderer.instance_variable_set(:@last_lines, [])
|
|
94
|
+
renderer.instance_variable_set(:@lines_rendered, 0)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
extension :exec do
|
|
102
|
+
helpers do
|
|
103
|
+
def execute(*argv, &block)
|
|
104
|
+
raise ArgumentError, "execute needs a program" if argv.empty?
|
|
105
|
+
|
|
106
|
+
command(Ext::Exec.command(app, argv.map(&:to_s), block))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
on Ext::Exec::Done do |done|
|
|
111
|
+
pass unless done.app.equal?(app)
|
|
112
|
+
|
|
113
|
+
call(done.block, done.status) if done.block
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|