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,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s10-suspend: ctrl+z suspend / resume (Bubbletea's SuspendCommand and ResumeMessage).
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# suspendable # ctrl+z suspends the app, like a shell job
|
|
7
|
+
# on_resume { flash "welcome back" }
|
|
8
|
+
#
|
|
9
|
+
# on_key "ctrl+s" do suspend end # or suspend from any block with the `suspend` helper
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# Suspending gives the terminal back (raw mode off, cursor shown, mouse off) and stops the process
|
|
13
|
+
# with SIGTSTP; `fg` (SIGCONT) takes the terminal again and delivers a Bubbletea::ResumeMessage,
|
|
14
|
+
# which runs every `on_resume` block in order on the update thread (a Context: state, flash, a
|
|
15
|
+
# returned command runs too). ctrl+z is an ordinary binding (priority 0), so it does nothing on a
|
|
16
|
+
# dashboard that isn't `suspendable`.
|
|
17
|
+
module R2UI
|
|
18
|
+
module Ext
|
|
19
|
+
module Suspend
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def ctrl_z?(message) = message.is_a?(Bubbletea::KeyMessage) && Keys.name(message) == :"ctrl+z"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
extension :suspend do
|
|
27
|
+
dsl :dashboard do
|
|
28
|
+
def suspendable = declare(:suspendable, true)
|
|
29
|
+
|
|
30
|
+
def on_resume(&block)
|
|
31
|
+
raise ArgumentError, "on_resume needs a block" unless block
|
|
32
|
+
|
|
33
|
+
declare(:on_resume, block)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
helpers do
|
|
38
|
+
# Suspends the program (Bubbletea.suspend); a ResumeMessage arrives when it's resumed.
|
|
39
|
+
def suspend = command(Bubbletea.suspend)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
on(->(msg) { Ext::Suspend.ctrl_z?(msg) }) do
|
|
43
|
+
pass if dashboard.declared(:suspendable).empty?
|
|
44
|
+
|
|
45
|
+
suspend
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
on Bubbletea::ResumeMessage do
|
|
49
|
+
blocks = dashboard.declared(:on_resume)
|
|
50
|
+
pass if blocks.empty?
|
|
51
|
+
|
|
52
|
+
blocks.each { |block| call(block) }
|
|
53
|
+
nil # not `blocks`: a handler's returned Procs would run again as background commands
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s21-text-area: a multi-line text box (bubbles' TextArea) in a panel.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# row height: 8 do
|
|
7
|
+
# panel :notes, resource: nil do
|
|
8
|
+
# text_area :notes, placeholder: "write here", height: 4 # height: optional, else the panel's
|
|
9
|
+
# end
|
|
10
|
+
# end
|
|
11
|
+
# on_key "ctrl+s" do File.write("notes.md", area_value(:notes)) end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# The area takes the keys while its panel has focus: typing, enter for a new line, arrows and
|
|
15
|
+
# the usual editing keys (all bubbles' own behavior). esc hands keys back to the dashboard (the
|
|
16
|
+
# panel stays focused; tab away and back to type again). `area_value(:name)` reads the text from
|
|
17
|
+
# any block. The area is as wide as the panel and as tall as `height:` (at most the panel's
|
|
18
|
+
# space); without `height:` it fills the panel.
|
|
19
|
+
module R2UI
|
|
20
|
+
module Ext
|
|
21
|
+
module TextArea
|
|
22
|
+
Item = Data.define(:name, :placeholder, :height)
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# The focused panel's text area item, if its area has key focus (else nil).
|
|
27
|
+
def focused(app)
|
|
28
|
+
app.focus&.items&.each do |item|
|
|
29
|
+
next unless item.is_a?(Item)
|
|
30
|
+
|
|
31
|
+
model = app.component(item.name)
|
|
32
|
+
return item if model&.focused?
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
extension :text_area do
|
|
40
|
+
dsl :panel do
|
|
41
|
+
def text_area(name, placeholder: "", height: nil)
|
|
42
|
+
raise ArgumentError, "text_area needs a positive height, got #{height}" if height && height.to_i < 1
|
|
43
|
+
|
|
44
|
+
Component.require_bubbles!
|
|
45
|
+
item(Ext::TextArea::Item.new(name:, placeholder: placeholder.to_s, height: height&.to_i))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
helpers do
|
|
50
|
+
def area_value(name) = component(name)&.value
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
component(Ext::TextArea::Item, focusable: true) do |item|
|
|
54
|
+
Bubbles::TextArea.new(height: item.height || Bubbles::TextArea::DEFAULT_HEIGHT).tap do |area|
|
|
55
|
+
area.placeholder = item.placeholder
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Sizes the area to the space it has, then lets bubbles draw it.
|
|
60
|
+
panel_item(Ext::TextArea::Item) do |item|
|
|
61
|
+
area = component(item.name)
|
|
62
|
+
area.width = [width, 1].max
|
|
63
|
+
area.height = [item.height || height, height].min.clamp(1..)
|
|
64
|
+
area.view
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
on(->(msg) { msg.is_a?(Bubbletea::KeyMessage) && Keys.name(msg) == :escape }, priority: 50) do
|
|
68
|
+
pass unless Ext::TextArea.focused(app)
|
|
69
|
+
|
|
70
|
+
focus_component(nil)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s20-text-input: a one-line text field (bubbles' TextInput) in a panel.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# row height: 3 do
|
|
7
|
+
# panel :search, resource: nil do
|
|
8
|
+
# text_input :query, placeholder: "filter…", prompt: "> ", width: 30, char_limit: 64,
|
|
9
|
+
# on_submit: ->(value) { state[:q] = value }
|
|
10
|
+
# end
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# While its panel has focus, typed keys go to the input (tab / shift+tab still move panel focus,
|
|
15
|
+
# ctrl+c still quits). enter calls `on_submit` with the value (on a Context, so `state`, `flash`
|
|
16
|
+
# and returned commands work); esc blurs it, handing keys back to the core and your bindings while
|
|
17
|
+
# the panel stays focused; enter on its panel focuses it again. `input_value(:query)` reads the
|
|
18
|
+
# value from any block. Options left out keep bubbles' defaults; the field is drawn by bubbles.
|
|
19
|
+
module R2UI
|
|
20
|
+
module Ext
|
|
21
|
+
module TextInput
|
|
22
|
+
Item = Data.define(:name, :options, :on_submit)
|
|
23
|
+
|
|
24
|
+
# Bubbles::TextInput setters the keyword takes.
|
|
25
|
+
OPTIONS = %i[placeholder prompt width char_limit].freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def build(item)
|
|
30
|
+
Component.require_bubbles!
|
|
31
|
+
model = Bubbles::TextInput.new
|
|
32
|
+
item.options.each { |key, value| model.public_send(:"#{key}=", value) }
|
|
33
|
+
model
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def enter?(msg) = msg.is_a?(Bubbletea::KeyMessage) && Keys.name(msg) == :enter
|
|
37
|
+
def escape?(msg) = msg.is_a?(Bubbletea::KeyMessage) && Keys.name(msg) == :escape
|
|
38
|
+
|
|
39
|
+
# The text-input instances in the focused panel.
|
|
40
|
+
def in_focus(app) = app.components.select { |c| c.panel == app.focus && c.item.is_a?(Item) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
extension :text_input do
|
|
45
|
+
dsl :panel do
|
|
46
|
+
def text_input(name, on_submit: nil, **options)
|
|
47
|
+
raise ArgumentError, "text_input needs a name" unless name
|
|
48
|
+
|
|
49
|
+
unknown = options.keys - Ext::TextInput::OPTIONS
|
|
50
|
+
raise ArgumentError, "text_input: unknown option #{unknown.join(", ")}" if unknown.any?
|
|
51
|
+
|
|
52
|
+
Component.require_bubbles!
|
|
53
|
+
item(Ext::TextInput::Item.new(name: name.to_sym, options: options.compact, on_submit:))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
helpers do
|
|
58
|
+
def input_value(name) = component(name.to_sym)&.value
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
component(Ext::TextInput::Item, focusable: true) { |item| Ext::TextInput.build(item) }
|
|
62
|
+
|
|
63
|
+
# The focused input handles enter and esc itself (before ordinary bindings).
|
|
64
|
+
on(->(m) { Ext::TextInput.enter?(m) || Ext::TextInput.escape?(m) }, priority: 50) do |msg|
|
|
65
|
+
input = Ext::TextInput.in_focus(app).find(&:focused)
|
|
66
|
+
pass unless input
|
|
67
|
+
|
|
68
|
+
if Ext::TextInput.escape?(msg)
|
|
69
|
+
focus_component(nil)
|
|
70
|
+
elsif input.item.on_submit
|
|
71
|
+
call(input.item.on_submit, input.model.value)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# enter on the panel of a blurred input focuses it again.
|
|
76
|
+
on(->(m) { Ext::TextInput.enter?(m) }) do
|
|
77
|
+
input = Ext::TextInput.in_focus(app).first
|
|
78
|
+
pass unless input
|
|
79
|
+
|
|
80
|
+
focus_component(input.name)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s18-theme: lipgloss styling for the dashboard's own drawing and for your `view` blocks.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# theme do
|
|
7
|
+
# title foreground: "#7D56F4", bold: true # panel titles
|
|
8
|
+
# focus foreground: "#04B575" # the focused panel's border
|
|
9
|
+
# border foreground: "240" # other borders (ANSI 256 index)
|
|
10
|
+
# selected reverse: true, foreground: "#FAFAFA", background: "#7D56F4"
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# row do
|
|
14
|
+
# panel :hello, resource: nil do
|
|
15
|
+
# view { style(foreground: "#FF5F87", italic: true).render("hello") }
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# `theme` restyles the canvas styles (`:title`, `:header`, `:border`, `:focus`, `:selected`, `:ok`,
|
|
21
|
+
# `:warn`, `:alert`, `:accent`, `:muted`, `:dim`, ...; a new name adds a style). Each line is a style
|
|
22
|
+
# name and lipgloss options: `foreground:`, `background:` (any lipgloss colour: "#RRGGBB", "201",
|
|
23
|
+
# "5", `Lipgloss::AdaptiveColor`, ...), and `bold:`, `italic:`, `underline:`, `reverse:`, `faint:`,
|
|
24
|
+
# `strikethrough:`, `blink:`. The colours come out exactly as lipgloss renders them for the
|
|
25
|
+
# terminal's colour profile (truecolor, 256, 16 colours, or none). `theme` may be used more than
|
|
26
|
+
# once; later lines win.
|
|
27
|
+
#
|
|
28
|
+
# `style(**options)` (any block) returns a `Lipgloss::Style` with each option applied as the
|
|
29
|
+
# lipgloss method of that name (`style(bold: true, padding: [0, 1], width: 20)`), so a `view` block
|
|
30
|
+
# can `render` with it.
|
|
31
|
+
#
|
|
32
|
+
# Lipgloss loads on first use: r2ui's pure-Ruby one (through `r2ui/drop_in`), unless the app has
|
|
33
|
+
# already loaded a `Lipgloss`.
|
|
34
|
+
module R2UI
|
|
35
|
+
module Ext
|
|
36
|
+
module Theme
|
|
37
|
+
# The options a canvas style can take: the ones lipgloss turns into SGR parameters.
|
|
38
|
+
OPTIONS = %i[foreground background bold italic underline reverse faint strikethrough blink].freeze
|
|
39
|
+
|
|
40
|
+
# The body of a `theme` block: every call is `style_name **options`. A BasicObject, so names
|
|
41
|
+
# like `warn` or `select` reach method_missing instead of Kernel.
|
|
42
|
+
class Builder < BasicObject
|
|
43
|
+
def initialize = @styles = {}
|
|
44
|
+
|
|
45
|
+
def method_missing(name, *args, **options)
|
|
46
|
+
::Kernel.raise ::ArgumentError, "theme: #{name} takes only keyword options" unless args.empty?
|
|
47
|
+
::Kernel.raise ::ArgumentError, "theme: #{name} needs lipgloss options" if options.empty?
|
|
48
|
+
|
|
49
|
+
unknown = options.keys - OPTIONS
|
|
50
|
+
unless unknown.empty?
|
|
51
|
+
::Kernel.raise ::ArgumentError, "theme: #{name} got #{unknown.join(", ")}; use #{OPTIONS.join(", ")}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@styles[name] = Theme.style(**options)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def respond_to_missing?(*) = true
|
|
58
|
+
|
|
59
|
+
def __styles = @styles
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
module_function
|
|
63
|
+
|
|
64
|
+
# Makes `Lipgloss` available: r2ui's pure-Ruby lipgloss unless one is already loaded.
|
|
65
|
+
def require_lipgloss!
|
|
66
|
+
return if defined?(::Lipgloss::Style)
|
|
67
|
+
|
|
68
|
+
require_relative "../drop_in"
|
|
69
|
+
require "lipgloss"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A Lipgloss::Style with each option applied as the lipgloss method of that name.
|
|
73
|
+
def style(**options)
|
|
74
|
+
require_lipgloss!
|
|
75
|
+
options.reduce(::Lipgloss::Style.new) do |style, (name, value)|
|
|
76
|
+
unless ::Lipgloss::Style.public_method_defined?(name) && name != :render
|
|
77
|
+
raise ArgumentError, "style: lipgloss has no #{name} option"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
style.public_send(name, *(value.is_a?(Array) ? value : [value]))
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
PROBE_SGR = /\A\e\[([0-9;:]*)m/
|
|
85
|
+
|
|
86
|
+
# The SGR parameters lipgloss uses for `style` (for the current colour profile); "0" (plain)
|
|
87
|
+
# when it renders no styling.
|
|
88
|
+
def sgr(style) = style.render("x")[PROBE_SGR, 1] || "0"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
extension :theme do
|
|
93
|
+
dsl :dashboard do
|
|
94
|
+
def theme(&block)
|
|
95
|
+
raise ArgumentError, "theme needs a block" unless block
|
|
96
|
+
|
|
97
|
+
builder = Ext::Theme::Builder.new
|
|
98
|
+
builder.instance_eval(&block)
|
|
99
|
+
declare(:theme, builder.__styles)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
helpers do
|
|
104
|
+
def style(**options) = Ext::Theme.style(**options)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
styles do
|
|
108
|
+
themes = dashboard.declared(:theme)
|
|
109
|
+
themes.reduce({}, :merge).transform_values { |style| Ext::Theme.sgr(style) } unless themes.empty?
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s02-view: a panel item drawn by a block, like a Bubbletea model's `view`.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# row height: 3 do
|
|
7
|
+
# panel :clock, resource: nil do
|
|
8
|
+
# view { "#{state[:ticks].to_i} ticks · #{width}x#{height}" }
|
|
9
|
+
# end
|
|
10
|
+
# end
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# The block runs on a Context (state, width, height, panel, helpers) on every frame and returns a
|
|
14
|
+
# String; "\n" splits lines and ANSI styling (a lipgloss render) is kept. The panel gives it as
|
|
15
|
+
# many lines as it returns, up to the space left.
|
|
16
|
+
#
|
|
17
|
+
# The reference for panel items: a component story copies this shape (a `dsl :panel` keyword that
|
|
18
|
+
# adds an `item`, plus a `panel_item` drawer or a `component`).
|
|
19
|
+
module R2UI
|
|
20
|
+
module Ext
|
|
21
|
+
module View
|
|
22
|
+
Item = Data.define(:block)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
extension :view do
|
|
27
|
+
dsl :panel do
|
|
28
|
+
def view(&block)
|
|
29
|
+
raise ArgumentError, "view needs a block" unless block
|
|
30
|
+
|
|
31
|
+
item(Ext::View::Item.new(block:))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
panel_item(Ext::View::Item) { |item| call(item.block).to_s }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s24-viewport: a scrollable text pane (bubbles' Viewport) in a panel.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# state log: []
|
|
7
|
+
# row do
|
|
8
|
+
# panel :log, resource: nil do
|
|
9
|
+
# viewport(:log, follow: true) { state[:log].join("\n") }
|
|
10
|
+
# end
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# `viewport(name) { text }` hosts a `Bubbles::Viewport` sized to the panel. The block runs on a
|
|
15
|
+
# Context every frame and returns the text ("\n" splits lines, ANSI kept); the scroll position is
|
|
16
|
+
# kept as the text changes. While the panel is focused, bubbles' viewport keys scroll it (up/down,
|
|
17
|
+
# pgup/pgdown, j/k, ctrl+u/ctrl+d, g/G, home/end); keys that don't move it go on to the rest of the
|
|
18
|
+
# app, so "q" still quits. The mouse wheel scrolls it too, by the viewport's `mouse_wheel_delta`
|
|
19
|
+
# lines, when mouse reporting is on (`mouse :cell`). With `follow: true` it sticks to the bottom
|
|
20
|
+
# as the text grows, until you scroll up; scrolling back to the bottom follows again.
|
|
21
|
+
#
|
|
22
|
+
# `component(:log)` in any block is the Bubbles::Viewport (set `style`, `horizontal_step`, ...).
|
|
23
|
+
module R2UI
|
|
24
|
+
module Ext
|
|
25
|
+
module Viewport
|
|
26
|
+
Item = Data.define(:name, :follow, :block)
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# The hosted viewport instance in the focused panel, or nil.
|
|
31
|
+
def focused(ctx) = ctx.app.components.find { |c| c.item.is_a?(Item) && c.panel.equal?(ctx.focus) }
|
|
32
|
+
|
|
33
|
+
# Yields the focused viewport's instance; false if there is none or it didn't move.
|
|
34
|
+
def scroll(ctx)
|
|
35
|
+
instance = focused(ctx)
|
|
36
|
+
return false unless instance
|
|
37
|
+
|
|
38
|
+
before = [instance.model.y_offset, instance.model.x_offset]
|
|
39
|
+
yield instance
|
|
40
|
+
before != [instance.model.y_offset, instance.model.x_offset]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
extension :viewport do
|
|
46
|
+
dsl :panel do
|
|
47
|
+
def viewport(name, follow: false, &block)
|
|
48
|
+
raise ArgumentError, "viewport needs a block" unless block
|
|
49
|
+
|
|
50
|
+
Component.require_bubbles!
|
|
51
|
+
item(Ext::Viewport::Item.new(name:, follow:, block:))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
component(Ext::Viewport::Item) { |_item| Bubbles::Viewport.new(width: 0, height: 0) }
|
|
56
|
+
|
|
57
|
+
panel_item(Ext::Viewport::Item) do |item|
|
|
58
|
+
viewport = app.components.find { |c| c.item.equal?(item) && c.panel.equal?(panel) }.model
|
|
59
|
+
at_bottom = viewport.at_bottom?
|
|
60
|
+
viewport.width = width
|
|
61
|
+
viewport.height = height
|
|
62
|
+
viewport.content = call(item.block).to_s
|
|
63
|
+
if item.follow && at_bottom
|
|
64
|
+
viewport.goto_bottom
|
|
65
|
+
else
|
|
66
|
+
viewport.y_offset = viewport.y_offset # the panel may have grown
|
|
67
|
+
end
|
|
68
|
+
viewport.view
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Keys go to the focused panel's viewport before ordinary bindings; those it ignores pass on.
|
|
72
|
+
on Bubbletea::KeyMessage, priority: 50 do |message|
|
|
73
|
+
moved = Ext::Viewport.scroll(self) do |instance|
|
|
74
|
+
instance.model, = Component.split(instance.model, instance.model.update(message))
|
|
75
|
+
end
|
|
76
|
+
pass unless moved
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# bubbles' Viewport leaves the wheel to its host (its mouse handler is empty): scroll it here.
|
|
80
|
+
on Bubbletea::MouseMessage, priority: 50 do |message|
|
|
81
|
+
up = message.button == Bubbletea::MouseMessage::BUTTON_WHEEL_UP
|
|
82
|
+
down = message.button == Bubbletea::MouseMessage::BUTTON_WHEEL_DOWN
|
|
83
|
+
moved = (up || down) && Ext::Viewport.scroll(self) do |instance|
|
|
84
|
+
viewport = instance.model
|
|
85
|
+
next unless viewport.mouse_wheel_enabled
|
|
86
|
+
|
|
87
|
+
up ? viewport.scroll_up(viewport.mouse_wheel_delta) : viewport.scroll_down(viewport.mouse_wheel_delta)
|
|
88
|
+
end
|
|
89
|
+
pass unless moved
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# s12-window-title: the terminal window title (Bubbletea's SetWindowTitle, OSC 2).
|
|
4
|
+
#
|
|
5
|
+
# R2UI.dashboard do
|
|
6
|
+
# window_title "Agents" # set once, when the app starts
|
|
7
|
+
# window_title { "#{state[:count]} deploys" } # or: set at start, and again when it changes
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# on_key "r" do set_window_title("refreshing") end # any block: set it now
|
|
11
|
+
#
|
|
12
|
+
# The block form runs on a Context after init and after every update; the title is sent only when
|
|
13
|
+
# its value differs from the last one sent, so an idle app writes nothing. A dashboard has one
|
|
14
|
+
# `window_title`. `set_window_title(text)` sends a title right away (it returns the command).
|
|
15
|
+
module R2UI
|
|
16
|
+
module Ext
|
|
17
|
+
module WindowTitle
|
|
18
|
+
# What `window_title` declares on the dashboard: fixed text or a block.
|
|
19
|
+
Title = Data.define(:text, :block) do
|
|
20
|
+
def value(context) = (block ? context.call(block) : text).to_s
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
extension :window_title do
|
|
26
|
+
dsl :dashboard do
|
|
27
|
+
def window_title(text = nil, &block)
|
|
28
|
+
raise ArgumentError, "window_title needs text or a block" if text.nil? == block.nil?
|
|
29
|
+
raise ArgumentError, "window_title is already set" if @d.declared(:window_title).any?
|
|
30
|
+
|
|
31
|
+
declare(:window_title, Ext::WindowTitle::Title.new(text:, block:))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
helpers do
|
|
36
|
+
def set_window_title(text) = command(Bubbletea.set_window_title(text.to_s)) # rubocop:disable Naming/AccessorMethodName
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
after_update do
|
|
40
|
+
title = dashboard.declared(:window_title).first
|
|
41
|
+
next unless title
|
|
42
|
+
|
|
43
|
+
sent = store(:window_title)
|
|
44
|
+
value = title.value(self)
|
|
45
|
+
next if sent.key?(:last) && sent[:last] == value
|
|
46
|
+
|
|
47
|
+
sent[:last] = value
|
|
48
|
+
set_window_title(value)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|