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
@@ -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
- Table = Data.define(:scope, :group_by, :sort, :limit)
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
- Panel = Data.define(:name, :resource, :span, :title, :items) do
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; rows without one share the remaining space.
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
- def panel(name, resource: name, span: 1, title: nil, &block)
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
- items = [Table.new(scope: nil, group_by: nil, sort: nil, limit: nil)] if items.empty?
61
- @panels << Panel.new(name:, resource:, span:, title:, items:)
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
@@ -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