r2ui 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +79 -0
- data/lib/r2ui/app.rb +311 -34
- data/lib/r2ui/canvas.rb +135 -14
- data/lib/r2ui/cli/builder.rb +72 -0
- data/lib/r2ui/cli/context.rb +45 -0
- data/lib/r2ui/cli/definition.rb +174 -0
- data/lib/r2ui/cli/ext/ask.rb +133 -0
- data/lib/r2ui/cli/ext/box.rb +112 -0
- data/lib/r2ui/cli/ext/choose.rb +162 -0
- data/lib/r2ui/cli/ext/choose_many.rb +241 -0
- data/lib/r2ui/cli/ext/color_option.rb +64 -0
- data/lib/r2ui/cli/ext/completion.rb +520 -0
- data/lib/r2ui/cli/ext/config.rb +179 -0
- data/lib/r2ui/cli/ext/confirm.rb +110 -0
- data/lib/r2ui/cli/ext/dashboard.rb +105 -0
- data/lib/r2ui/cli/ext/diff.rb +195 -0
- data/lib/r2ui/cli/ext/editor.rb +94 -0
- data/lib/r2ui/cli/ext/env.rb +108 -0
- data/lib/r2ui/cli/ext/examples.rb +62 -0
- data/lib/r2ui/cli/ext/filter.rb +269 -0
- data/lib/r2ui/cli/ext/format.rb +104 -0
- data/lib/r2ui/cli/ext/list.rb +117 -0
- data/lib/r2ui/cli/ext/log.rb +89 -0
- data/lib/r2ui/cli/ext/pager.rb +97 -0
- data/lib/r2ui/cli/ext/pairs.rb +62 -0
- data/lib/r2ui/cli/ext/parallel.rb +274 -0
- data/lib/r2ui/cli/ext/password.rb +158 -0
- data/lib/r2ui/cli/ext/progress.rb +330 -0
- data/lib/r2ui/cli/ext/reference.rb +129 -0
- data/lib/r2ui/cli/ext/spinner.rb +165 -0
- data/lib/r2ui/cli/ext/summary.rb +63 -0
- data/lib/r2ui/cli/ext/table.rb +141 -0
- data/lib/r2ui/cli/ext/tasks.rb +216 -0
- data/lib/r2ui/cli/ext/trace.rb +64 -0
- data/lib/r2ui/cli/ext/tree.rb +114 -0
- data/lib/r2ui/cli/ext/version.rb +67 -0
- data/lib/r2ui/cli/extension.rb +129 -0
- data/lib/r2ui/cli/help.rb +115 -0
- data/lib/r2ui/cli/helpers.rb +20 -0
- data/lib/r2ui/cli/live.rb +150 -0
- data/lib/r2ui/cli/parser.rb +165 -0
- data/lib/r2ui/cli/prompt.rb +133 -0
- data/lib/r2ui/cli/runner.rb +86 -0
- data/lib/r2ui/cli/shell.rb +185 -0
- data/lib/r2ui/cli/testing.rb +49 -0
- data/lib/r2ui/cli.rb +129 -0
- data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
- data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
- data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
- data/lib/r2ui/compat/bubbletea/input.rb +269 -0
- data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
- data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
- data/lib/r2ui/compat/bubbletea/model.rb +65 -0
- data/lib/r2ui/compat/bubbletea/native.rb +254 -0
- data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
- data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
- data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
- data/lib/r2ui/compat/bubbletea/version.rb +8 -0
- data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
- data/lib/r2ui/compat/bubbletea.rb +16 -0
- data/lib/r2ui/compat/lipgloss/border.rb +17 -0
- data/lib/r2ui/compat/lipgloss/color.rb +78 -0
- data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
- data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
- data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
- data/lib/r2ui/compat/lipgloss/list.rb +85 -0
- data/lib/r2ui/compat/lipgloss/position.rb +30 -0
- data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
- data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
- data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
- data/lib/r2ui/compat/lipgloss/table.rb +185 -0
- data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
- data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
- data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
- data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
- data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
- data/lib/r2ui/compat/lipgloss/version.rb +15 -0
- data/lib/r2ui/compat/lipgloss.rb +64 -0
- data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
- data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
- data/lib/r2ui/component.rb +67 -0
- data/lib/r2ui/context.rb +87 -0
- data/lib/r2ui/drop_in.rb +16 -0
- data/lib/r2ui/dsl/column.rb +11 -3
- data/lib/r2ui/dsl/dashboard.rb +45 -9
- data/lib/r2ui/dsl/resource.rb +12 -0
- data/lib/r2ui/ext/after.rb +54 -0
- data/lib/r2ui/ext/async.rb +81 -0
- data/lib/r2ui/ext/batch_sequence.rb +54 -0
- data/lib/r2ui/ext/countdown.rb +52 -0
- data/lib/r2ui/ext/data_table.rb +71 -0
- data/lib/r2ui/ext/every.rb +66 -0
- data/lib/r2ui/ext/exec.rb +116 -0
- data/lib/r2ui/ext/file_picker.rb +78 -0
- data/lib/r2ui/ext/focus_report.rb +69 -0
- data/lib/r2ui/ext/help.rb +62 -0
- data/lib/r2ui/ext/keys.rb +64 -0
- data/lib/r2ui/ext/list.rb +145 -0
- data/lib/r2ui/ext/messages.rb +87 -0
- data/lib/r2ui/ext/mouse.rb +134 -0
- data/lib/r2ui/ext/paginator.rb +86 -0
- data/lib/r2ui/ext/paste.rb +86 -0
- data/lib/r2ui/ext/println.rb +21 -0
- data/lib/r2ui/ext/progress.rb +85 -0
- data/lib/r2ui/ext/refresh.rb +56 -0
- data/lib/r2ui/ext/resize.rb +62 -0
- data/lib/r2ui/ext/screen.rb +47 -0
- data/lib/r2ui/ext/screen_mode.rb +63 -0
- data/lib/r2ui/ext/spinner.rb +59 -0
- data/lib/r2ui/ext/state.rb +45 -0
- data/lib/r2ui/ext/stopwatch.rb +67 -0
- data/lib/r2ui/ext/suspend.rb +56 -0
- data/lib/r2ui/ext/text_area.rb +73 -0
- data/lib/r2ui/ext/text_input.rb +83 -0
- data/lib/r2ui/ext/theme.rb +112 -0
- data/lib/r2ui/ext/view.rb +37 -0
- data/lib/r2ui/ext/viewport.rb +92 -0
- data/lib/r2ui/ext/window_title.rb +51 -0
- data/lib/r2ui/extension.rb +199 -0
- data/lib/r2ui/history.rb +4 -1
- data/lib/r2ui/keys.rb +35 -8
- data/lib/r2ui/motion.rb +169 -0
- data/lib/r2ui/panel_state.rb +46 -2
- data/lib/r2ui/renderer.rb +87 -22
- data/lib/r2ui/version.rb +1 -1
- data/lib/r2ui/widgets/box.rb +6 -3
- data/lib/r2ui/widgets/glyphs.rb +149 -0
- data/lib/r2ui/widgets/table.rb +109 -25
- data/lib/r2ui.rb +12 -1
- metadata +119 -4
- data/lib/r2ui/terminal.rb +0 -53
|
@@ -0,0 +1,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
|