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