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,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # s31-file-picker: a file browser in a panel (bubbles' FilePicker).
4
+ #
5
+ # R2UI.dashboard do
6
+ # row do
7
+ # panel :files, resource: nil do
8
+ # file_picker :browser, dir: ".", allowed: %w[.rb .md],
9
+ # on_select: ->(path) { state[:open] = path; flash "picked #{path}" }
10
+ # end
11
+ # end
12
+ # end
13
+ #
14
+ # Hosts a `Bubbles::FilePicker` on `dir` (default "."), sized to the panel's height. While the
15
+ # panel has focus the picker takes the keys: up/down (k/j), pgup/pgdown, home/end (g/G),
16
+ # enter/right (l) to open a directory or select a file, left (h)/backspace to go back up.
17
+ # `allowed` lists the selectable extensions, with or without the dot; empty (the default) allows
18
+ # every file. `on_select` runs on a Context with the selected file's absolute path, once per
19
+ # selection; a command it returns runs. `component(:browser)` is the picker itself.
20
+ module R2UI
21
+ module Ext
22
+ module FilePicker
23
+ Item = Data.define(:name, :dir, :allowed, :on_select)
24
+
25
+ module_function
26
+
27
+ # The bubbles model for `item`. Each selection it makes is pushed onto `selections` as
28
+ # [item, path], for the after_update hook to hand to on_select.
29
+ def build(item, selections)
30
+ picker = Bubbles::FilePicker.new(directory: item.dir)
31
+ picker.allowed_types = item.allowed.map { |ext| ext.to_s.delete_prefix(".") }
32
+ picker.singleton_class.prepend(Module.new do
33
+ define_method(:update) do |message|
34
+ super(message).tap { selections << [item, path] if did_select_file? }
35
+ end
36
+ end)
37
+ picker
38
+ end
39
+
40
+ # Fits the listing under the picker's two header lines (path, blank).
41
+ def fit(picker, height)
42
+ rows = [height - 2, 1].max
43
+ return if picker.height == rows
44
+
45
+ picker.height = rows
46
+ picker.send(:update_offset) if picker.respond_to?(:update_offset, true)
47
+ end
48
+ end
49
+ end
50
+
51
+ extension :file_picker do
52
+ dsl :panel do
53
+ def file_picker(name, dir: ".", allowed: [], on_select: nil)
54
+ Component.require_bubbles!
55
+ item(Ext::FilePicker::Item.new(name: name.to_sym, dir: dir.to_s, allowed: Array(allowed), on_select:))
56
+ end
57
+ end
58
+
59
+ component(Ext::FilePicker::Item, focusable: true) do |item|
60
+ Ext::FilePicker.build(item, store(:file_picker)[:selections] ||= [])
61
+ end
62
+
63
+ after_update do
64
+ selections = store(:file_picker)[:selections]
65
+ while (item, path = selections&.shift)
66
+ call(item.on_select, path) if item.on_select
67
+ end
68
+ end
69
+
70
+ panel_item(Ext::FilePicker::Item) do |item|
71
+ instance = app.components.find { |c| c.item.equal?(item) && c.panel == panel }
72
+ next "" unless instance
73
+
74
+ Ext::FilePicker.fit(instance.model, height)
75
+ instance.model.view
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ # s16-focus-report: terminal focus in/out (Bubbletea's report_focus, FocusMessage, BlurMessage).
4
+ #
5
+ # R2UI.dashboard do
6
+ # report_focus # ask the terminal to report focus changes
7
+ # on_blur { state[:paused] = true } # the terminal window lost focus
8
+ # on_focus { state[:paused] = false } # ... and got it back
9
+ #
10
+ # row do
11
+ # panel :status, resource: nil do
12
+ # view { state[:terminal_focused] ? "watching" : "paused" }
13
+ # end
14
+ # end
15
+ # end
16
+ #
17
+ # `state[:terminal_focused]` tracks the terminal's focus: true at start, false after a blur, true
18
+ # again after a focus. `on_focus` / `on_blur` blocks run on the update thread (a Context) in the
19
+ # order they were declared, after the state is updated; a command they return or enqueue runs.
20
+ # Declaring `on_focus` or `on_blur` turns focus reporting on as well (without it they would never
21
+ # fire). The runner turns reporting off again on exit.
22
+ module R2UI
23
+ module Ext
24
+ module FocusReport
25
+ module_function
26
+
27
+ # Whether the dashboard uses focus reporting at all.
28
+ def enabled?(dashboard) = %i[report_focus on_focus on_blur].any? { |k| dashboard.declared(k).any? }
29
+ end
30
+ end
31
+
32
+ extension :focus_report do
33
+ dsl :dashboard do
34
+ def report_focus = declare(:report_focus, true)
35
+
36
+ def on_focus(&block)
37
+ raise ArgumentError, "on_focus needs a block" unless block
38
+
39
+ declare(:on_focus, block)
40
+ end
41
+
42
+ def on_blur(&block)
43
+ raise ArgumentError, "on_blur needs a block" unless block
44
+
45
+ declare(:on_blur, block)
46
+ end
47
+ end
48
+
49
+ setup do
50
+ state[:terminal_focused] = true if Ext::FocusReport.enabled?(dashboard) && !state.key?(:terminal_focused)
51
+ end
52
+
53
+ program_options { { report_focus: true } if Ext::FocusReport.enabled?(dashboard) }
54
+
55
+ observe(Bubbletea::FocusMessage) { state[:terminal_focused] = true if Ext::FocusReport.enabled?(dashboard) }
56
+ observe(Bubbletea::BlurMessage) { state[:terminal_focused] = false if Ext::FocusReport.enabled?(dashboard) }
57
+
58
+ # `call` enqueues each block's command; return nil so the declared blocks aren't taken as commands.
59
+ on Bubbletea::FocusMessage do
60
+ dashboard.declared(:on_focus).each { |block| call(block) }
61
+ nil
62
+ end
63
+
64
+ on Bubbletea::BlurMessage do
65
+ dashboard.declared(:on_blur).each { |block| call(block) }
66
+ nil
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # s28-help: a panel showing the app's keys with bubbles' Help.
4
+ #
5
+ # R2UI.dashboard do
6
+ # on_key "r", help: "refresh" do refresh end
7
+ # row do panel :deploys do table end end
8
+ # row(height: 3) { panel(:keys, resource: nil) { help } }
9
+ # end
10
+ #
11
+ # `help` draws `Bubbles::Help` short help ("? help • r refresh • tab panel • … • q quit") from
12
+ # `app.hint_pairs`: every extension's hints, then the core's. `?` toggles full help, which lays the
13
+ # same hints out in columns as tall as the panel. Both are cut to the panel's width. While a help
14
+ # panel is on the dashboard, `?` is bound (an ordinary binding, priority 0) and "? help" leads the
15
+ # hints. Needs the bubbles gem.
16
+ module R2UI
17
+ module Ext
18
+ module Help
19
+ # What `help` adds to the panel.
20
+ Item = Data.define
21
+
22
+ # The keymap Bubbles::Help reads: one binding per [key, label] hint pair.
23
+ Keymap = Data.define(:bindings, :column_height) do
24
+ def short_help = bindings
25
+ def full_help = bindings.each_slice(column_height).to_a
26
+ end
27
+
28
+ module_function
29
+
30
+ def present?(dashboard) = dashboard.panels.any? { |panel| panel.items.any?(Item) }
31
+
32
+ def keymap(pairs, column_height)
33
+ bindings = pairs.map { |key, label| Bubbles::Key.binding(keys: [key], help: [key, label]) }
34
+ Keymap.new(bindings:, column_height: [column_height, 1].max)
35
+ end
36
+ end
37
+ end
38
+
39
+ extension :help do
40
+ dsl :panel do
41
+ def help
42
+ Component.require_bubbles!
43
+ item(Ext::Help::Item.new)
44
+ end
45
+ end
46
+
47
+ hints { [["?", "help"]] if Ext::Help.present?(dashboard) }
48
+
49
+ on ->(msg) { msg.is_a?(Bubbletea::KeyMessage) && Keys.name(msg) == "?" } do
50
+ pass unless Ext::Help.present?(dashboard)
51
+
52
+ store(:help)[:full] = !store(:help)[:full]
53
+ end
54
+
55
+ panel_item Ext::Help::Item do
56
+ help = Bubbles::Help.new
57
+ help.width = width
58
+ help.show_all = store(:help)[:full] ? true : false
59
+ help.view(Ext::Help.keymap(app.hint_pairs(self), height))
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ # s03-keys: custom key bindings.
4
+ #
5
+ # R2UI.dashboard do
6
+ # on_key "r", help: "refresh" do # typed text, as the core names it
7
+ # state[:refreshed_at] = Time.now
8
+ # flash "refreshed" # any Context helper; a returned command runs too
9
+ # end
10
+ # on_key "ctrl+r", :f1, "enter" do # Bubbletea names ("ctrl+r", "f1", "pgup") or core
11
+ # quit # names (:enter, :page_up), as String or Symbol
12
+ # end
13
+ # end
14
+ #
15
+ # The block runs on the update thread (a Context; `message` is the Bubbletea::KeyMessage). A
16
+ # binding beats the core's own key (binding "s" stops the sort key), but not the search (/) or
17
+ # confirm (y/n) prompts while they're open, nor a focused component. `help:` adds a status-bar
18
+ # hint. Binding the same key twice in one dashboard raises.
19
+ module R2UI
20
+ module Ext
21
+ # Not `Keys`: that would shadow R2UI::Keys for every other extension under R2UI::Ext.
22
+ module KeyBindings
23
+ # What `on_key` declares on the dashboard: `keys` are core key names (R2UI::Keys.name).
24
+ Binding = Data.define(:keys, :label, :help, :block)
25
+
26
+ module_function
27
+
28
+ # The core name of a key given as a core or Bubbletea name ("r", "ctrl+r", :f1, "enter").
29
+ def normalize(key) = R2UI::Keys.name(R2UI::Keys.message(key))
30
+ end
31
+ end
32
+
33
+ extension :keys do
34
+ dsl :dashboard do
35
+ def on_key(*keys, help: nil, &block)
36
+ raise ArgumentError, "on_key needs at least one key" if keys.empty?
37
+ raise ArgumentError, "on_key needs a block" unless block
38
+
39
+ names = keys.map { |key| Ext::KeyBindings.normalize(key) }
40
+ taken = @d.declared(:on_key).flat_map(&:keys)
41
+ names.each do |name|
42
+ raise ArgumentError, "on_key: #{name.inspect} is already bound" if taken.include?(name)
43
+
44
+ taken << name
45
+ end
46
+
47
+ label = keys.map(&:to_s).join("/")
48
+ declare(:on_key, Ext::KeyBindings::Binding.new(keys: names, label:, help:, block:))
49
+ end
50
+ end
51
+
52
+ hints do
53
+ dashboard.declared(:on_key).filter_map { |b| [b.label, b.help.to_s] if b.help }
54
+ end
55
+
56
+ on Bubbletea::KeyMessage do |key|
57
+ name = R2UI::Keys.name(key)
58
+ binding = dashboard.declared(:on_key).find { |b| b.keys.include?(name) }
59
+ pass unless binding
60
+
61
+ call(binding.block)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ # s22-list: a bubbles List in a panel (browse, filter with `/`, pick with enter).
4
+ #
5
+ # R2UI.dashboard do
6
+ # row do
7
+ # panel :fruit, resource: nil do
8
+ # list :fruit, items: -> { state[:fruits] }, title: "Fruit",
9
+ # on_select: ->(fruit) { flash "picked #{fruit}" }
10
+ # end
11
+ # panel :process do
12
+ # list :procs, resource: :process, label: :name, filter: true,
13
+ # on_select: ->(row) { state[:pid] = row.pid }
14
+ # end
15
+ # end
16
+ # end
17
+ #
18
+ # Options:
19
+ # - `items:` a lambda returning the items, re-read on every frame (it runs on a Context, so it can
20
+ # read `state`); or `resource:` a resource name, whose latest rows are the items (some panel
21
+ # must show that resource, so its feed runs). Give exactly one.
22
+ # - `title:` the list's title line; without it the list shows no title (the panel has one).
23
+ # - `filter:` (default true) whether `/` starts filtering; false turns filtering off.
24
+ # - `label:` how an item reads: an attribute name (`:name`) or a lambda (`->(item) { ... }`).
25
+ # Without it, bubbles' own rule applies: `title`, then a Hash's `:title`, then `to_s`. Filtering
26
+ # matches the same text.
27
+ # - `on_select:` called with the selected item (the original item or row, not its label) when
28
+ # enter is pressed; it runs on a Context, so it can touch `state`, `flash`, return commands.
29
+ #
30
+ # The list is a `Bubbles::List`, sized to the panel on every frame, and takes keys while its
31
+ # panel has focus (tab and shift+tab still move focus): up/down/k/j, home/end/g/G, pgup/pgdown,
32
+ # `/` to filter (enter applies, esc clears). Item changes keep the selection and the filter.
33
+ # `component(:name)` gives the `Bubbles::List`.
34
+ module R2UI
35
+ module Ext
36
+ module List
37
+ Item = Data.define(:name, :items, :resource, :title, :filter, :label, :on_select)
38
+
39
+ # What the list holds when `label:` is given: bubbles shows and filters by `title`.
40
+ Entry = Data.define(:item, :title)
41
+
42
+ module_function
43
+
44
+ def build(item)
45
+ Component.require_bubbles!
46
+ list = Bubbles::List.new([], width: 0, height: 1)
47
+ list.title = item.title.to_s
48
+ list.show_title = !item.title.nil?
49
+ list.show_filter = item.filter
50
+ list
51
+ end
52
+
53
+ # The current items: from the lambda or the resource's feed, mapped through `label:`.
54
+ def source(ctx, item)
55
+ raw = if item.resource
56
+ feed = ctx.app.feeds[item.resource]
57
+ raise Error, "list #{item.name}: no panel shows resource #{item.resource.inspect}" unless feed
58
+
59
+ feed.rows
60
+ else
61
+ ctx.call(item.items)
62
+ end
63
+ Array(raw)
64
+ end
65
+
66
+ def entries(ctx, item, raw)
67
+ case item.label
68
+ when nil then raw
69
+ when Symbol, String then raw.map { |r| Entry.new(item: r, title: Value.fetch(r, item.label.to_sym).to_s) }
70
+ else raw.map { |r| Entry.new(item: r, title: ctx.call(item.label, r).to_s) }
71
+ end
72
+ end
73
+
74
+ # Gives `list` new items, keeping its filter (state and text) and selection; bubbles'
75
+ # `items=` alone would clear both.
76
+ def replace(list, items)
77
+ filter_state = list.filter_state
78
+ query = list.filter_input.value
79
+ selected = list.selected_index
80
+
81
+ list.items = items
82
+ unless filter_state == Bubbles::List::UNFILTERED
83
+ list.start_filtering
84
+ list.filter_input.value = query
85
+ list.apply_filter
86
+ list.start_filtering if filter_state == Bubbles::List::FILTERING
87
+ end
88
+ list.select(selected)
89
+ end
90
+
91
+ def resize(list, width, height)
92
+ return if list.width == width && list.height == height
93
+
94
+ list.width = width
95
+ list.height = height
96
+ list.select(list.selected_index) # keeps the selection in view
97
+ list.update(Bubbletea::Message.new) # bubbles recounts its pages in update
98
+ end
99
+
100
+ def unwrap(entry) = entry.is_a?(Entry) ? entry.item : entry
101
+ end
102
+ end
103
+
104
+ extension :list do
105
+ dsl :panel do
106
+ def list(name, items: nil, resource: nil, title: nil, filter: true, label: nil, on_select: nil)
107
+ raise ArgumentError, "list #{name} needs `items:` or `resource:`, not both" unless items.nil? ^ resource.nil?
108
+ raise ArgumentError, "list #{name}: `items:` must be callable" if items && !items.respond_to?(:call)
109
+
110
+ item(Ext::List::Item.new(name:, items:, resource: resource&.to_sym, title:, filter:, label:, on_select:))
111
+ end
112
+ end
113
+
114
+ component(Ext::List::Item, focusable: true) { |item| Ext::List.build(item) }
115
+
116
+ panel_item(Ext::List::Item) do |item|
117
+ instance = app.components.find { |c| c.item.equal?(item) && c.panel == panel }
118
+ next "" unless instance
119
+
120
+ list = instance.model
121
+ raw = Ext::List.source(self, item)
122
+ shown = (store(:list)[:shown] ||= {}.compare_by_identity)
123
+ unless shown.key?(item) && shown[item] == raw
124
+ Ext::List.replace(list, Ext::List.entries(self, item, raw))
125
+ shown[item] = raw.dup # a copy: the lambda may hand back the same array, changed in place
126
+ end
127
+ Ext::List.resize(list, width, height)
128
+ list.view
129
+ end
130
+
131
+ # Enter picks the selected item, unless the list is typing a filter (then bubbles applies it).
132
+ on Bubbletea::KeyMessage, priority: 50 do |msg|
133
+ pass unless Keys.name(msg) == :enter
134
+
135
+ instance = app.components.find do |c|
136
+ c.focused && c.panel == app.focus && c.item.is_a?(Ext::List::Item)
137
+ end
138
+ pass unless instance
139
+ pass if instance.model.filter_state == Bubbles::List::FILTERING
140
+
141
+ selected = instance.model.selected_item
142
+ call(instance.item.on_select, Ext::List.unwrap(selected)) if selected && instance.item.on_select
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ # s04-messages: your own messages and events, like a Bubbletea model's `update` cases.
4
+ #
5
+ # class Deployed < Bubbletea::Message
6
+ # attr_reader :name
7
+ # def initialize(name) = (super(); @name = name)
8
+ # end
9
+ #
10
+ # R2UI.dashboard do
11
+ # on Deployed do |msg| # any Bubbletea::Message subclass
12
+ # state[:last] = msg.name
13
+ # end
14
+ # on :poll do |payload| # an event sent with `emit`
15
+ # state[:polls] = state[:polls].to_i + 1
16
+ # -> { Deployed.new(fetch_latest) } # a Proc command runs in the background;
17
+ # end # the message it returns reaches `on Deployed`
18
+ # every 5 do emit :poll end # emit(name, payload = nil, delay: 0)
19
+ # end
20
+ #
21
+ # `on(matcher) { |msg| }` handles every message `matcher === msg` (a class, a lambda, ...); a
22
+ # Symbol handles the events `emit` sends with that name, and its block gets the payload. Handlers
23
+ # run on a Context (state, flash, quit, command, ...), in the order they were declared; the first
24
+ # that matches handles the message (call `pass` to let the next one see it), and a command it
25
+ # returns or enqueues runs. They are ordinary bindings: a focused component and higher-priority
26
+ # handlers see keys first, and messages nobody handles go on to the core.
27
+ #
28
+ # `emit(name, payload = nil, delay: 0)` (any block) enqueues `Bubbletea.send_message` with the
29
+ # event, after `delay` seconds; it returns the command. Events nobody handles are ignored, and an
30
+ # app ignores events another app emitted.
31
+ module R2UI
32
+ module Ext
33
+ module Messages
34
+ # What `on` declares on the dashboard.
35
+ Handler = Data.define(:matcher, :block) do
36
+ def match?(message)
37
+ return message.name == matcher if matcher.is_a?(Symbol) && message.is_a?(Event)
38
+
39
+ matcher === message # rubocop:disable Style/CaseEquality
40
+ end
41
+
42
+ def argument(message) = matcher.is_a?(Symbol) ? message.payload : message
43
+ end
44
+
45
+ # The message `emit` sends. `app` scopes it to the app that emitted it.
46
+ class Event < Bubbletea::Message
47
+ attr_reader :name, :payload, :app
48
+
49
+ def initialize(name:, payload:, app:)
50
+ super()
51
+ @name = name
52
+ @payload = payload
53
+ @app = app
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ extension :messages do
60
+ dsl :dashboard do
61
+ def on(matcher, &block)
62
+ raise ArgumentError, "on needs a block" unless block
63
+
64
+ declare(:on, Ext::Messages::Handler.new(matcher:, block:))
65
+ end
66
+ end
67
+
68
+ helpers do
69
+ def emit(name, payload = nil, delay: 0)
70
+ raise ArgumentError, "emit needs an event name" unless name.is_a?(Symbol) || name.is_a?(String)
71
+
72
+ event = Ext::Messages::Event.new(name: name.to_sym, payload:, app:)
73
+ command(Bubbletea.send_message(event, delay:))
74
+ end
75
+ end
76
+
77
+ on Bubbletea::Message do |message|
78
+ own_event = message.is_a?(Ext::Messages::Event) && message.app.equal?(app)
79
+ pass if message.is_a?(Ext::Messages::Event) && !own_event
80
+
81
+ handled = dashboard.declared(:on).any? do |handler|
82
+ handler.match?(message) && handle(handler.block, handler.argument(message))
83
+ end
84
+ pass unless handled || own_event
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ # s14-mouse: mouse reporting, clicks and the wheel.
4
+ #
5
+ # R2UI.dashboard do
6
+ # mouse :cell # or :all (motion without a button held too); default :cell
7
+ # on_click do |msg, panel| # a Bubbletea::MouseMessage, and the panel under it (or nil)
8
+ # flash "clicked #{panel&.name} at #{msg.x},#{msg.y}"
9
+ # end
10
+ # row { panel :process }
11
+ # end
12
+ #
13
+ # With `mouse`, the program turns on mouse reporting (Bubbletea's `mouse_cell_motion` or
14
+ # `mouse_all_motion`) and turns it off again on exit. A left click focuses the panel under the
15
+ # pointer and, on a table row, selects that row; the wheel moves the selection of the table under
16
+ # the pointer (or of the focused one). `on_click` blocks run on a Context for every button press
17
+ # (left, middle, right; not the wheel, releases or motion), after the click has focused and
18
+ # selected, so `focus` and `selected_rows` already show the clicked row; a returned command runs.
19
+ #
20
+ # Buttons are Bubbletea 0.1.4's numbers, which pass the terminal's SGR button through: a left
21
+ # click is 0, middle 1, right 2, wheel up 4 and wheel down 5.
22
+ module R2UI
23
+ module Ext
24
+ module Mouse
25
+ MODES = { cell: :mouse_cell_motion, all: :mouse_all_motion }.freeze
26
+ LEFT = 0
27
+ WHEEL = { Bubbletea::MouseMessage::BUTTON_WHEEL_UP => -1, Bubbletea::MouseMessage::BUTTON_WHEEL_DOWN => 1 }.freeze
28
+
29
+ module_function
30
+
31
+ # The panel drawn at x, y in the last frame (nil if none).
32
+ def panel_at(app, x, y)
33
+ app.panel_rects.each do |panel, rect|
34
+ return panel if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.bottom
35
+ end
36
+ nil
37
+ end
38
+
39
+ # Selects the table row drawn at screen row y in `panel`, if there is one there.
40
+ def select_row(app, panel, y)
41
+ state = app.panel_state(panel)
42
+ top = table_top(app, panel)
43
+ return unless state && top
44
+
45
+ index = state.offset + (y - top - 1) # the row under the table's header line
46
+ lines = app.panel_lines(panel)
47
+ state.move(index - state.selected, lines.size) if y > top && index < lines.size
48
+ end
49
+
50
+ # Moves the selection of a table panel by `delta` rows.
51
+ def scroll(app, panel, delta)
52
+ state = panel && app.panel_state(panel)
53
+ state&.move(delta, app.panel_lines(panel).size)
54
+ end
55
+
56
+ # The screen row of the table's header in `panel`, laid out as the renderer does: inside the
57
+ # border, below a feed error line and the items before the table.
58
+ def table_top(app, panel)
59
+ rect = app.panel_rects[panel]
60
+ return unless rect
61
+
62
+ inner = rect.width < 2 || rect.height < 2 ? rect : rect.inner
63
+ y = inner.y
64
+ y += 1 if panel.resource && app.feeds[panel.resource]&.error
65
+ panel.items.each do |item|
66
+ return y if item.is_a?(DSL::Table)
67
+
68
+ y += item_height(app, panel, item, inner.width, inner.bottom - y)
69
+ return if y >= inner.bottom
70
+ end
71
+ nil
72
+ end
73
+
74
+ def item_height(app, panel, item, width, room)
75
+ lines = case item
76
+ when DSL::Gauge then 1
77
+ when DSL::Stat then item.attrs.size
78
+ when DSL::Sparkline then item.height + 1
79
+ else extension_lines(app, panel, item, width, room)
80
+ end
81
+ lines.clamp(0, room)
82
+ end
83
+
84
+ def extension_lines(app, panel, item, width, height)
85
+ ctx = Context.new(app)
86
+ ctx.panel = panel
87
+ ctx.width = width
88
+ ctx.height = height
89
+ drawer = Extensions.hooks(:panel_item).find { |h| h.match?(item) }
90
+ text = drawer ? ctx.call(drawer.block, item) : app.components.find { |c| c.item.equal?(item) }&.model&.view
91
+ text.to_s.split("\n").size
92
+ end
93
+ end
94
+ end
95
+
96
+ extension :mouse do
97
+ dsl :dashboard do
98
+ def mouse(mode = :cell)
99
+ unless Ext::Mouse::MODES.key?(mode)
100
+ raise ArgumentError, "mouse takes #{Ext::Mouse::MODES.keys.map(&:inspect).join(" or ")}, got #{mode.inspect}"
101
+ end
102
+
103
+ declare(:mouse, mode)
104
+ end
105
+
106
+ def on_click(&block)
107
+ raise ArgumentError, "on_click needs a block" unless block
108
+
109
+ declare(:on_click, block)
110
+ end
111
+ end
112
+
113
+ program_options do
114
+ mode = dashboard.declared(:mouse).last
115
+ mode ? { Ext::Mouse::MODES.fetch(mode) => true } : {}
116
+ end
117
+
118
+ on Bubbletea::MouseMessage do |msg|
119
+ pass if dashboard.declared(:mouse).empty? || !msg.press?
120
+
121
+ panel = Ext::Mouse.panel_at(app, msg.x, msg.y)
122
+ if (delta = Ext::Mouse::WHEEL[msg.button])
123
+ Ext::Mouse.scroll(app, panel && app.panel_state(panel) ? panel : focus, delta)
124
+ else
125
+ if msg.button == Ext::Mouse::LEFT && panel
126
+ app.focus = panel
127
+ Ext::Mouse.select_row(app, panel, msg.y)
128
+ end
129
+ dashboard.declared(:on_click).each { |block| call(block, msg, panel) }
130
+ end
131
+ nil # the handler's own value would be enqueued as a command (the blocks are Procs)
132
+ end
133
+ end
134
+ end