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,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A port of lipgloss v1.1.0 tree/ (tree.go, children.go, enumerator.go, renderer.go) and the
4
+ # enumerators of list/ (list/enumerator.go). Lipgloss::Tree and Lipgloss::List wrap a GoTree::Node,
5
+ # the way the gem's Ruby objects wrap a handle to a Go *tree.Tree / *list.List: every method mutates
6
+ # that shared node, and the returned Ruby object is a fresh wrapper around the same node.
7
+ #
8
+ # Only what the gem's Ruby API can reach is ported: no hidden nodes, no style or enumerator funcs
9
+ # that look at the children (the Ruby API only sets constant styles and the built-in enumerators).
10
+ module R2UI
11
+ module Compat
12
+ module Gloss
13
+ module GoTree
14
+ UTF_8 = Encoding::UTF_8
15
+
16
+ # tree.Leaf
17
+ class Leaf
18
+ attr_accessor :value
19
+
20
+ def initialize(value)
21
+ @value = value
22
+ end
23
+ end
24
+
25
+ # tree.Tree
26
+ class Node
27
+ attr_accessor :value, :children, :offset, :r
28
+
29
+ def initialize
30
+ @value = +""
31
+ @children = []
32
+ @offset = [0, 0]
33
+ @r = nil
34
+ end
35
+
36
+ # Tree.Root(string)
37
+ def root(value)
38
+ @value = value
39
+ self
40
+ end
41
+
42
+ # Tree.Children(): the children inside the offset window.
43
+ def visible_children
44
+ (@offset[0]...(@children.length - @offset[1])).map { |i| @children[i] }
45
+ end
46
+
47
+ # Tree.Child for one *Tree, Node or string.
48
+ def child(item)
49
+ case item
50
+ when Node
51
+ new_item, rm = ensure_parent(@children, item)
52
+ @children.delete_at(rm) if rm >= 0
53
+ @children << new_item
54
+ when Leaf
55
+ @children << item
56
+ when String
57
+ @children << Leaf.new(item)
58
+ end
59
+ self
60
+ end
61
+
62
+ # Tree.Offset
63
+ def set_offset(start, stop)
64
+ start, stop = stop, start if start > stop
65
+ start = 0 if start.negative?
66
+ stop = @children.length if stop.negative? || stop > @children.length
67
+ @offset = [start, stop]
68
+ self
69
+ end
70
+
71
+ def ensure_renderer
72
+ @r ||= Renderer.new
73
+ end
74
+
75
+ # Tree.String()
76
+ def to_s
77
+ ensure_renderer.render(self, true, "")
78
+ end
79
+
80
+ private
81
+
82
+ # tree.go ensureParent: a tree without a root takes over the previous leaf's value (or merges
83
+ # into the previous subtree).
84
+ def ensure_parent(nodes, item)
85
+ return [item, -1] if item.value != "" || nodes.empty?
86
+
87
+ j = nodes.length - 1
88
+ parent = nodes[j]
89
+ case parent
90
+ when Node
91
+ i = 0
92
+ while i < item.visible_children.length
93
+ parent.child(item.children[i])
94
+ i += 1
95
+ end
96
+ [parent, j]
97
+ else
98
+ item.value = parent.value
99
+ [item, j]
100
+ end
101
+ end
102
+ end
103
+
104
+ DEFAULT_ENUMERATOR = ->(children, index) { children.length - 1 == index ? "└──" : "├──" }
105
+ ROUNDED_ENUMERATOR = ->(children, index) { children.length - 1 == index ? "╰──" : "├──" }
106
+ DEFAULT_INDENTER = ->(children, index) { children.length - 1 == index ? " " : "│ " }
107
+
108
+ # list/enumerator.go
109
+ module ListEnumerators
110
+ ABC_LEN = 26
111
+ ROMAN = %w[M CM D CD C XC L XL X IX V IV I].freeze
112
+ ARABIC = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1].freeze
113
+
114
+ BULLET = ->(_items, _i) { "•" }
115
+ ASTERISK = ->(_items, _i) { "*" }
116
+ DASH = ->(_items, _i) { "-" }
117
+ ARABIC_ENUM = ->(_items, i) { "#{i + 1}." }
118
+
119
+ ALPHABET = lambda do |_items, i|
120
+ a = "A".ord
121
+ if i >= ABC_LEN * ABC_LEN + ABC_LEN
122
+ [a + i / ABC_LEN / ABC_LEN - 1, a + (i / ABC_LEN) % ABC_LEN - 1, a + i % ABC_LEN].pack("U*") + "."
123
+ elsif i >= ABC_LEN
124
+ [a + i / ABC_LEN - 1, a + i % ABC_LEN].pack("U*") + "."
125
+ else
126
+ [a + i % ABC_LEN].pack("U*") + "."
127
+ end
128
+ end
129
+
130
+ ROMAN_ENUM = lambda do |_items, i|
131
+ result = +""
132
+ ARABIC.each_with_index do |value, v|
133
+ while i >= value - 1
134
+ i -= value
135
+ result << ROMAN[v]
136
+ end
137
+ end
138
+ result << "."
139
+ end
140
+
141
+ INDENTER = ->(_items, _i) { " " }
142
+ end
143
+
144
+ # list.New(): a tree with the bullet enumerator and a one-space indenter.
145
+ def self.new_list
146
+ node = Node.new
147
+ r = node.ensure_renderer
148
+ r.enumerator = ListEnumerators::BULLET
149
+ r.indenter = ListEnumerators::INDENTER
150
+ node
151
+ end
152
+
153
+ # tree/renderer.go
154
+ class Renderer
155
+ attr_accessor :enumerator_style, :item_style, :root_style, :enumerator, :indenter
156
+
157
+ def initialize
158
+ @enumerator_style = ::Lipgloss::Style.new.padding_right(1)
159
+ @item_style = ::Lipgloss::Style.new
160
+ @root_style = ::Lipgloss::Style.new
161
+ @enumerator = DEFAULT_ENUMERATOR
162
+ @indenter = DEFAULT_INDENTER
163
+ end
164
+
165
+ def render(node, root, prefix)
166
+ strs = []
167
+ max_len = 0
168
+ children = node.is_a?(Node) ? node.visible_children : []
169
+
170
+ name = node.value
171
+ strs << style_render(@root_style, name) if name != "" && root
172
+
173
+ children.each_index do |i|
174
+ p = style_render(@enumerator_style, @enumerator.call(children, i))
175
+ w = layout.width(p)
176
+ max_len = w if w > max_len
177
+ end
178
+
179
+ children.each_with_index do |child, i|
180
+ indent = @indenter.call(children, i)
181
+ node_prefix = style_render(@enumerator_style, @enumerator.call(children, i))
182
+ l = max_len - layout.width(node_prefix)
183
+ node_prefix = (" " * l) + node_prefix if l.positive?
184
+
185
+ item = style_render(@item_style, child.value)
186
+ multiline_prefix = prefix
187
+
188
+ while layout.height(item) > layout.height(node_prefix)
189
+ node_prefix = layout.join_vertical(0.0, [node_prefix, style_render(@enumerator_style, indent)])
190
+ end
191
+ while layout.height(node_prefix) > layout.height(multiline_prefix)
192
+ multiline_prefix = layout.join_vertical(0.0, [multiline_prefix, prefix])
193
+ end
194
+
195
+ strs << layout.join_horizontal(0.0, [multiline_prefix, node_prefix, item])
196
+
197
+ renderer = (child.is_a?(Node) && child.r) || self
198
+ s = renderer.render(child, false, prefix + style_render(@enumerator_style, indent))
199
+ strs << s unless s.empty?
200
+ end
201
+ strs.join("\n")
202
+ end
203
+
204
+ private
205
+
206
+ def layout = Layout
207
+
208
+ def style_render(style, str)
209
+ style.send(:go_render, str)
210
+ end
211
+ end
212
+
213
+ # --- Ruby-side argument handling shared by Lipgloss::List and Lipgloss::Tree --------------
214
+
215
+ # Check_Type(value, T_STRING) + StringValueCStr, copied the way Go copies a C string.
216
+ def self.c_string_arg(value)
217
+ Layout.check_string(value)
218
+ String.new(Layout.c_string(value), encoding: UTF_8)
219
+ end
220
+
221
+ # Array#to_json unmarshalled into []string (nil when the unmarshal fails).
222
+ def self.json_string_array(array)
223
+ Layout.check_array(array)
224
+ strs = Layout.json_strings(array)
225
+ return nil if strs.equal?(Layout::JSON_FAIL)
226
+
227
+ strs.map { |s| String.new(s, encoding: UTF_8) }
228
+ end
229
+
230
+ # TypedData_Get_Struct(style, ..., &style_type)
231
+ def self.style_arg(style)
232
+ return style if style.is_a?(::Lipgloss::Style)
233
+
234
+ raise TypeError, "wrong argument type #{Layout.type_name(style)} (expected Lipgloss::Style)"
235
+ end
236
+
237
+ def self.render(node)
238
+ Layout.c_result(node.to_s)
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tree/go_tree"
4
+
5
+ module Lipgloss
6
+ # Lipgloss::Tree as the gem's C extension (tree.c) defines it over Go lipgloss tree.Tree. Every method
7
+ # mutates the underlying tree and returns a new Lipgloss::Tree wrapping that same tree.
8
+ class Tree
9
+ def self.root(root)
10
+ value = R2UI::Compat::Gloss::GoTree.c_string_arg(root)
11
+ tree = allocate
12
+ tree.instance_variable_set(:@tree, R2UI::Compat::Gloss::GoTree::Node.new.root(value))
13
+ tree
14
+ end
15
+
16
+ def initialize(*args)
17
+ node = go_tree
18
+ node.root(R2UI::Compat::Gloss::GoTree.c_string_arg(args[0])) if args.length == 1
19
+ end
20
+
21
+ def root=(root)
22
+ node = go_tree
23
+ node.root(R2UI::Compat::Gloss::GoTree.c_string_arg(root))
24
+ wrap(node)
25
+ end
26
+
27
+ def child(*children)
28
+ node = go_tree
29
+ raise ArgumentError, "wrong number of arguments (given 0, expected 1+)" if children.empty?
30
+
31
+ children.each do |child|
32
+ if child.is_a?(Lipgloss::Tree)
33
+ node.child(child.send(:go_tree))
34
+ else
35
+ node.child(R2UI::Compat::Gloss::GoTree.c_string_arg(child))
36
+ end
37
+ end
38
+ wrap(node)
39
+ end
40
+
41
+ def children(children)
42
+ node = go_tree
43
+ strs = R2UI::Compat::Gloss::GoTree.json_string_array(children)
44
+ strs&.each { |s| node.child(s) }
45
+ wrap(node)
46
+ end
47
+
48
+ def enumerator(enum_symbol)
49
+ node = go_tree
50
+ node.ensure_renderer.enumerator =
51
+ if enum_symbol.equal?(:rounded)
52
+ R2UI::Compat::Gloss::GoTree::ROUNDED_ENUMERATOR
53
+ else
54
+ R2UI::Compat::Gloss::GoTree::DEFAULT_ENUMERATOR
55
+ end
56
+ wrap(node)
57
+ end
58
+
59
+ def enumerator_style(style)
60
+ node = go_tree
61
+ node.ensure_renderer.enumerator_style = R2UI::Compat::Gloss::GoTree.style_arg(style)
62
+ wrap(node)
63
+ end
64
+
65
+ def item_style(style)
66
+ node = go_tree
67
+ node.ensure_renderer.item_style = R2UI::Compat::Gloss::GoTree.style_arg(style)
68
+ wrap(node)
69
+ end
70
+
71
+ def root_style(style)
72
+ node = go_tree
73
+ node.ensure_renderer.root_style = R2UI::Compat::Gloss::GoTree.style_arg(style)
74
+ wrap(node)
75
+ end
76
+
77
+ def offset(start, stop)
78
+ node = go_tree
79
+ layout = R2UI::Compat::Gloss::Layout
80
+ node.set_offset(layout.num2int(start), layout.num2int(stop))
81
+ wrap(node)
82
+ end
83
+
84
+ def render
85
+ R2UI::Compat::Gloss::GoTree.render(go_tree)
86
+ end
87
+
88
+ def to_s
89
+ render
90
+ end
91
+
92
+ private
93
+
94
+ # The gem allocates the Go tree in the alloc function, so even an uninitialized object has one.
95
+ def go_tree
96
+ @tree ||= R2UI::Compat::Gloss::GoTree::Node.new
97
+ end
98
+
99
+ def wrap(node)
100
+ tree = self.class.allocate
101
+ tree.instance_variable_set(:@tree, node)
102
+ tree
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lipgloss
4
+ VERSION = "0.2.2"
5
+ end
6
+
7
+ module R2UI
8
+ module Compat
9
+ # Internals of the pure-Ruby lipgloss (see lib/r2ui/compat/lipgloss.rb).
10
+ module Gloss
11
+ # The Go lipgloss version the real gem wraps; returned by Lipgloss.upstream_version.
12
+ UPSTREAM_VERSION = "v1.1.0"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Pure-Ruby Lipgloss: a drop-in for the lipgloss gem 0.2.2 (marcoroth/lipgloss-ruby, MIT), which wraps
4
+ # Go lipgloss v1.1.0 through a C extension. This port follows the Go sources (lipgloss v1.1.0,
5
+ # charmbracelet/x/ansi v0.8.0, muesli/termenv v0.16.0, lucasb-eyer/go-colorful v1.2.0, rivo/uniseg v0.4.7)
6
+ # so output is byte-identical to the real gem for the same calls and color profile.
7
+ #
8
+ # Public API lives under `Lipgloss`, exactly as the gem exposes it. Internals (text width and wrapping,
9
+ # terminal color handling, color math) live under `R2UI::Compat::Gloss` so they never leak into the
10
+ # gem's namespace.
11
+
12
+ require_relative "lipgloss/version"
13
+ require_relative "lipgloss/text"
14
+ require_relative "lipgloss/colorful"
15
+ require_relative "lipgloss/termenv"
16
+ require_relative "lipgloss/position"
17
+ require_relative "lipgloss/border"
18
+ require_relative "lipgloss/color"
19
+ require_relative "lipgloss/color_blend"
20
+ require_relative "lipgloss/style"
21
+ require_relative "lipgloss/layout"
22
+ require_relative "lipgloss/table"
23
+ require_relative "lipgloss/list"
24
+ require_relative "lipgloss/tree"
25
+
26
+ module Lipgloss
27
+ TOP = Position::TOP
28
+ BOTTOM = Position::BOTTOM
29
+ LEFT = Position::LEFT
30
+ RIGHT = Position::RIGHT
31
+ CENTER = Position::CENTER
32
+
33
+ NORMAL_BORDER = Border::NORMAL
34
+ ROUNDED_BORDER = Border::ROUNDED
35
+ THICK_BORDER = Border::THICK
36
+ DOUBLE_BORDER = Border::DOUBLE
37
+ HIDDEN_BORDER = Border::HIDDEN
38
+ BLOCK_BORDER = Border::BLOCK
39
+ ASCII_BORDER = Border::ASCII
40
+
41
+ NO_TAB_CONVERSION = -1
42
+
43
+ class << self
44
+ def join_horizontal(position, *strings)
45
+ _join_horizontal(Position.resolve(position), strings)
46
+ end
47
+
48
+ def join_vertical(position, *strings)
49
+ _join_vertical(Position.resolve(position), strings)
50
+ end
51
+
52
+ def place(width, height, horizontal, vertical, string, **opts)
53
+ _place(width, height, Position.resolve(horizontal), Position.resolve(vertical), string, **opts)
54
+ end
55
+
56
+ def place_horizontal(width, position, string)
57
+ _place_horizontal(width, Position.resolve(position), string)
58
+ end
59
+
60
+ def place_vertical(height, position, string)
61
+ _place_vertical(height, Position.resolve(position), string)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded by `require "bubbletea"` after `require "r2ui/drop_in"`. The engine lane owns the implementation.
4
+ require_relative "../bubbletea"
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded by `require "lipgloss"` after `require "r2ui/drop_in"`. The style lane owns the implementation.
4
+ require_relative "../lipgloss"
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ # Hosts Bubbletea-style models (bubbles components, or your own: `update(msg)` → [model, cmd],
5
+ # `view` → String) as panel items. An extension declares one with
6
+ #
7
+ # component(SpinnerItem) { |item| Bubbles::Spinner.new } # build block, runs on a Context
8
+ # component(InputItem, focusable: true) { |item| Bubbles::TextInput.new }
9
+ #
10
+ # and the app then, for every panel item of that class:
11
+ # - builds the model once, in App#init, and runs its `init` (if it has one);
12
+ # - sends it every non-key message (ticks, window size, mouse, your own messages) and batches the
13
+ # commands it returns;
14
+ # - if `focusable`, sends it the key messages while its panel has focus (the first focusable item
15
+ # in the panel), except tab / shift+tab (panel focus) and ctrl+c (quit); calls its `focus`
16
+ # (or `focus!`) / `blur` as its panel gains and loses focus;
17
+ # - draws `model.view` in the panel, unless the extension registers its own `panel_item` drawer.
18
+ module Component
19
+ # One hosted model. `item` is the DSL object the panel holds; `model` changes as update returns.
20
+ Instance = Struct.new(:panel, :item, :spec, :model, :focused, keyword_init: true) do
21
+ def name = item.respond_to?(:name) ? item.name : nil
22
+ def focusable? = spec.options.fetch(:focusable, false)
23
+ end
24
+
25
+ Spec = Data.define(:matcher, :options, :build)
26
+
27
+ # Keys a focused component never sees.
28
+ RESERVED = %i[tab back_tab interrupt].freeze
29
+
30
+ module_function
31
+
32
+ # Bubbles is an optional dependency: component extensions call this when first used (not at
33
+ # load time). It opts into r2ui/drop_in, so bubbles' `require "bubbletea"`/`"lipgloss"` load
34
+ # r2ui's engine instead of the real gems.
35
+ def require_bubbles!
36
+ return if defined?(::Bubbles)
37
+
38
+ begin
39
+ require_relative "drop_in"
40
+ rescue LoadError => e
41
+ raise Error, "bubbles components run on r2ui's Bubbletea, but #{e.message}"
42
+ end
43
+ begin
44
+ require "bubbles"
45
+ rescue LoadError
46
+ raise Error, "this needs the bubbles gem: add `gem \"bubbles\"` to your Gemfile"
47
+ end
48
+ end
49
+
50
+ # [model, command] from an init/update result (a pair, a bare command, or nil).
51
+ def split(model, result)
52
+ return result if result.is_a?(Array) && result.size == 2 && !Context.command?(result.first)
53
+
54
+ [model, Context.command?(result) ? result : nil]
55
+ end
56
+
57
+ def focus(instance)
58
+ model = instance.model
59
+ method = %i[focus focus!].find { |m| model.respond_to?(m) }
60
+ method && model.public_send(method)
61
+ end
62
+
63
+ def blur(instance)
64
+ instance.model.blur if instance.model.respond_to?(:blur)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ # What a hook or user block runs on (instance_exec). Holds the running app and the commands the
5
+ # block enqueues; the app batches them into the Bubbletea command its init/update returns.
6
+ #
7
+ # Core helpers are below; extensions add more with `helpers do ... end`. Command helpers enqueue
8
+ # with `command(...)` and return the command, so a block can also just return one.
9
+ class Context
10
+ attr_reader :app, :message
11
+ # Set while drawing a panel item.
12
+ attr_accessor :panel, :width, :height
13
+
14
+ def initialize(app, message = nil)
15
+ @app = app
16
+ @message = message
17
+ @commands = []
18
+ end
19
+
20
+ def self.command?(value) = value.is_a?(Bubbletea::Command) || value.is_a?(Proc)
21
+
22
+ # nil, one command, or a batch of several.
23
+ def self.combine(commands)
24
+ commands = commands.compact
25
+ commands.size <= 1 ? commands.first : Bubbletea.batch(*commands)
26
+ end
27
+
28
+ # The app's user state: a Hash any block may read and write.
29
+ def state = app.state
30
+
31
+ # Private runtime state for one extension (timers it scheduled, component models, ...).
32
+ def store(name) = app.store(name)
33
+
34
+ # The app's animation state (R2UI::Motion), for panel items that tween, pulse or age values.
35
+ def motion = app.motion
36
+
37
+ def dashboard = app.dashboard
38
+
39
+ # The focused panel (a DSL::Panel).
40
+ def focus = app.focus
41
+
42
+ # Rows under the selection in the focused table panel ([] if none).
43
+ def selected_rows = app.selected_rows
44
+
45
+ # The hosted model of the component item named `name` (see R2UI::Component), or nil.
46
+ def component(name) = app.component(name)
47
+
48
+ # Moves key focus to the named component, or away from all with nil (App#focus_component).
49
+ def focus_component(name)
50
+ app.focus_component(name, self)
51
+ nil
52
+ end
53
+
54
+ # Shows text on the status bar for a few seconds.
55
+ def flash(text) = app.flash(text)
56
+
57
+ def quit = command(Bubbletea.quit)
58
+
59
+ # Enqueues a Bubbletea command (or a Proc, run in the background). Returns it.
60
+ def command(cmd)
61
+ @commands << cmd if cmd && @commands.none? { |c| c.equal?(cmd) }
62
+ cmd
63
+ end
64
+
65
+ # Inside an `on` handler: decline the message, so lower-priority handlers and the core see it.
66
+ def pass = throw(:r2ui_pass)
67
+
68
+ # Runs `block` here; a returned command is enqueued too. Returns the block's value.
69
+ def call(block, *args)
70
+ result = instance_exec(*args, &block)
71
+ (result.is_a?(Array) ? result : [result]).each { |r| command(r) if Context.command?(r) }
72
+ result
73
+ end
74
+
75
+ # Runs an `on` handler: true if it consumed the message (didn't `pass`).
76
+ def handle(block, message)
77
+ catch(:r2ui_pass) do
78
+ call(block, message)
79
+ return true
80
+ end
81
+ false
82
+ end
83
+
84
+ # The enqueued commands as one Bubbletea command (nil if none).
85
+ def commands = Context.combine(@commands)
86
+ end
87
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Makes `require "bubbletea"` and `require "lipgloss"` load r2ui's pure-Ruby versions, so code
4
+ # written for the Charm Ruby gems (and pure-Ruby gems on top of them, like bubbles) runs unchanged:
5
+ #
6
+ # require "r2ui/drop_in"
7
+ # require "bubbles" # now runs on r2ui
8
+ #
9
+ # Load it before anything requires the real gems.
10
+
11
+ loaded = $LOADED_FEATURES.grep(%r{/gems/(bubbletea|lipgloss)-[^/]+/})
12
+ unless loaded.empty?
13
+ raise LoadError, "r2ui/drop_in must be required before the real bubbletea/lipgloss gems (already loaded: #{loaded.first})"
14
+ end
15
+
16
+ $LOAD_PATH.unshift(File.expand_path("compat/load_path", __dir__))
@@ -3,11 +3,19 @@
3
3
  module R2UI
4
4
  module DSL
5
5
  # One attribute of a resource: how to read it, show it and roll it up.
6
- Column = Data.define(:key, :label, :format, :sparkline, :aggregate, :width, :align, :sort, :reader) do
6
+ #
7
+ # Drawing options for the index table: `style` is a callable (value, line) -> style or nil (a
8
+ # palette Symbol or an SGR String; nil keeps the default colouring). `sparkline` is true (block
9
+ # bars) or :braille; `spark_width` its width in cells, `spark_max` its scale (else the window
10
+ # max), `spark_style` a style or a callable (values, line) -> style. When columns don't fit, the
11
+ # lowest `priority` is dropped first (ties: the last declared).
12
+ Column = Data.define(:key, :label, :format, :sparkline, :aggregate, :width, :align, :sort, :reader,
13
+ :style, :spark_width, :spark_max, :spark_style, :priority) do
7
14
  def self.build(key, label: nil, format: :text, sparkline: false, aggregate: nil, width: nil, align: nil,
8
- sort: nil, &reader)
15
+ sort: nil, style: nil, spark_width: nil, spark_max: nil, spark_style: nil, priority: 0, &reader)
9
16
  new(
10
- key:, format:, sparkline:, width:, sort:, reader:,
17
+ key:, format:, sparkline:, width:, sort:, reader:, style:, spark_width:, spark_max:, spark_style:,
18
+ priority:,
11
19
  label: label || key.to_s.tr("_", " ").capitalize,
12
20
  aggregate: aggregate || Format.default_aggregate(format),
13
21
  align: align || Format.default_align(format)