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
@@ -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