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,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ module Compat
5
+ module Tea
6
+ # Pure-Ruby port of bubbletea 0.1.4's Go input parser (go/keys.go:
7
+ # ParseInput, parseMouseSGR, parseInts, keyNames, tea_get_key_name).
8
+ # Each event is the Hash that JSON.parse returns for Go's json.Marshal
9
+ # of the event struct, so Bubbletea.parse_event consumes it unchanged.
10
+ module Input
11
+ KEY_RUNES = -1
12
+ KEY_ESC = 27
13
+ KEY_SPACE = -25
14
+
15
+ KEY_NAMES = {
16
+ 0 => "ctrl+@", 1 => "ctrl+a", 2 => "ctrl+b", 3 => "ctrl+c", 4 => "ctrl+d",
17
+ 5 => "ctrl+e", 6 => "ctrl+f", 7 => "ctrl+g", 8 => "ctrl+h", 9 => "tab",
18
+ 10 => "ctrl+j", 11 => "ctrl+k", 12 => "ctrl+l", 13 => "enter", 14 => "ctrl+n",
19
+ 15 => "ctrl+o", 16 => "ctrl+p", 17 => "ctrl+q", 18 => "ctrl+r", 19 => "ctrl+s",
20
+ 20 => "ctrl+t", 21 => "ctrl+u", 22 => "ctrl+v", 23 => "ctrl+w", 24 => "ctrl+x",
21
+ 25 => "ctrl+y", 26 => "ctrl+z", 27 => "esc", 127 => "backspace",
22
+ -1 => "runes", -2 => "up", -3 => "down", -4 => "right", -5 => "left",
23
+ -6 => "home", -7 => "end", -8 => "pgup", -9 => "pgdown", -10 => "delete",
24
+ -11 => "insert", -12 => "f1", -13 => "f2", -14 => "f3", -15 => "f4",
25
+ -16 => "f5", -17 => "f6", -18 => "f7", -19 => "f8", -20 => "f9",
26
+ -21 => "f10", -22 => "f11", -23 => "f12", -24 => "shift+tab", -25 => "space"
27
+ }.freeze
28
+
29
+ # No sequence is a prefix of another, so Go's random map iteration
30
+ # order never changes the result.
31
+ ESCAPE_SEQUENCES = {
32
+ "\e[A" => -2, "\e[B" => -3, "\e[C" => -4, "\e[D" => -5,
33
+ "\e[H" => -6, "\e[F" => -7, "\e[1~" => -6, "\e[4~" => -7,
34
+ "\e[5~" => -8, "\e[6~" => -9, "\e[2~" => -11, "\e[3~" => -10,
35
+ "\eOP" => -12, "\eOQ" => -13, "\eOR" => -14, "\eOS" => -15,
36
+ "\e[15~" => -16, "\e[17~" => -17, "\e[18~" => -18, "\e[19~" => -19,
37
+ "\e[20~" => -20, "\e[21~" => -21, "\e[23~" => -22, "\e[24~" => -23,
38
+ "\e[Z" => -24,
39
+ # Upstream placeholders for shift/ctrl arrows; they have no name.
40
+ "\e[1;2A" => -100, "\e[1;2B" => -101, "\e[1;2C" => -102, "\e[1;2D" => -103,
41
+ "\e[1;5A" => -104, "\e[1;5B" => -105, "\e[1;5C" => -106, "\e[1;5D" => -107
42
+ }.each_with_object({}) { |(k, v), h| h[k.b.freeze] = v }.freeze
43
+
44
+ PASTE_START = "\e[200~".b.freeze
45
+ PASTE_END = "\e[201~".b.freeze
46
+
47
+ module_function
48
+
49
+ # Go tea_get_key_name: "" for unknown types.
50
+ def key_name(key_type)
51
+ KEY_NAMES.fetch(key_type, "")
52
+ end
53
+
54
+ # Go ParseInput: returns [consumed, event_hash_or_nil].
55
+ def parse(bytes)
56
+ data = bytes.b
57
+ return [0, nil] if data.empty?
58
+
59
+ b0 = data.getbyte(0)
60
+ b1 = data.getbyte(1)
61
+
62
+ if data.bytesize >= 3 && b0 == 0x1b && b1 == 0x5b
63
+ b2 = data.getbyte(2)
64
+ return [3, { "type" => "focus", "focus" => true }] if b2 == 0x49 # I
65
+ return [3, { "type" => "blur", "focus" => false }] if b2 == 0x4f # O
66
+ end
67
+
68
+ if data.bytesize >= 6 && b0 == 0x1b && b1 == 0x5b && data.getbyte(2) == 0x3c
69
+ consumed, mouse = parse_mouse_sgr(data)
70
+ return [consumed, mouse] if consumed.positive?
71
+ end
72
+
73
+ if b0 == 0x1b && data.bytesize > 1
74
+ ESCAPE_SEQUENCES.each do |seq, key_type|
75
+ next unless data.start_with?(seq)
76
+
77
+ name = KEY_NAMES.fetch(key_type, "")
78
+ name = "unknown" if name.empty?
79
+ return [seq.bytesize, key_event(key_type, nil, false, name)]
80
+ end
81
+
82
+ if b1 >= 32 && b1 < 127
83
+ char = b1.chr
84
+ return [2, key_event(KEY_RUNES, [b1], true, "alt+#{char}")]
85
+ end
86
+
87
+ return [1, key_event(KEY_ESC, nil, false, "esc")]
88
+ end
89
+
90
+ if b0 < 32 || b0 == 127
91
+ name = KEY_NAMES.fetch(b0, "")
92
+ name = "ctrl+?" if name.empty?
93
+ return [1, key_event(b0, nil, false, name)]
94
+ end
95
+
96
+ return [1, key_event(KEY_SPACE, [32], false, "space")] if b0 == 0x20
97
+
98
+ size = utf8_size(data)
99
+ return [1, nil] unless size
100
+
101
+ char = data.byteslice(0, size).force_encoding(Encoding::UTF_8)
102
+ [size, key_event(KEY_RUNES, [char.ord], false, char)]
103
+ end
104
+
105
+ # Decodes every event in one complete input, in order, plus bracketed
106
+ # paste. A paste with no end marker takes the rest of the input.
107
+ def parse_all(bytes)
108
+ decoder = Decoder.new
109
+ decoder.feed(bytes) + decoder.flush
110
+ end
111
+
112
+ # Decodes a stream of read chunks. An open bracketed paste, or a
113
+ # paste start marker cut off at the end of a chunk, is held until the
114
+ # following chunks complete it, so a paste of any length arrives as
115
+ # one paste event.
116
+ class Decoder
117
+ # Shortest chunk tail held as a possible split start marker. A lone
118
+ # "\e" or "\e[" is a real key press (esc, alt+[) and is never held.
119
+ MIN_HELD_PREFIX = 3
120
+
121
+ def initialize
122
+ @pending = "".b
123
+ end
124
+
125
+ # Events completed by this chunk; held bytes wait for the next one.
126
+ def feed(bytes)
127
+ data = @pending + bytes.b
128
+ @pending = "".b
129
+ events = []
130
+ pos = 0
131
+ while pos < data.bytesize
132
+ rest = data.byteslice(pos, data.bytesize - pos)
133
+ if rest.start_with?(PASTE_START)
134
+ body = pos + PASTE_START.bytesize
135
+ stop = data.index(PASTE_END, body)
136
+ unless stop
137
+ @pending = rest
138
+ break
139
+ end
140
+
141
+ events << Input.paste_event(data.byteslice(body, stop - body))
142
+ pos = stop + PASTE_END.bytesize
143
+ next
144
+ end
145
+
146
+ if rest.bytesize >= MIN_HELD_PREFIX && rest.bytesize < PASTE_START.bytesize &&
147
+ PASTE_START.start_with?(rest)
148
+ @pending = rest
149
+ break
150
+ end
151
+
152
+ consumed, event = Input.parse(rest)
153
+ break if consumed <= 0
154
+
155
+ events << event if event
156
+ pos += consumed
157
+ end
158
+ events
159
+ end
160
+
161
+ # Events for whatever is held, as if the input ended here: an open
162
+ # paste becomes a paste of what arrived, a partial marker plain keys.
163
+ def flush
164
+ data = @pending
165
+ @pending = "".b
166
+ return [] if data.empty?
167
+ return [Input.paste_event(data.byteslice(PASTE_START.bytesize, data.bytesize))] if data.start_with?(PASTE_START)
168
+
169
+ events = []
170
+ pos = 0
171
+ while pos < data.bytesize
172
+ consumed, event = Input.parse(data.byteslice(pos, data.bytesize - pos))
173
+ events << event if event
174
+ pos += consumed
175
+ end
176
+ events
177
+ end
178
+ end
179
+
180
+ def key_event(key_type, runes, alt, name)
181
+ { "type" => "key", "key_type" => key_type, "runes" => runes, "alt" => alt, "name" => name }
182
+ end
183
+
184
+ def paste_event(bytes)
185
+ text = bytes.dup.force_encoding(Encoding::UTF_8).scrub
186
+ { "type" => "key", "key_type" => KEY_RUNES, "runes" => text.codepoints,
187
+ "alt" => false, "name" => "[#{text}]", "paste" => true }
188
+ end
189
+
190
+ # Go parseMouseSGR: ESC [ < Cb ; Cx ; Cy (M|m). Returns [0, nil] on failure.
191
+ def parse_mouse_sgr(data)
192
+ end_index = nil
193
+ i = 3
194
+ while i < data.bytesize && i < 32
195
+ byte = data.getbyte(i)
196
+ if byte == 0x4d || byte == 0x6d # M / m
197
+ end_index = i
198
+ break
199
+ end
200
+ i += 1
201
+ end
202
+ return [0, nil] unless end_index
203
+
204
+ count, (button, x, y) = parse_ints(data.byteslice(3, end_index - 3), 3)
205
+ return [0, nil] unless count == 3
206
+
207
+ button_num = button & 3
208
+ action = if data.getbyte(end_index) == 0x6d then 1
209
+ elsif button.anybits?(32) then 2
210
+ else 0
211
+ end
212
+ if button.anybits?(64)
213
+ if button_num.zero? then button_num = 4
214
+ elsif button_num == 1 then button_num = 5
215
+ end
216
+ end
217
+
218
+ [end_index + 1, {
219
+ "type" => "mouse", "x" => x - 1, "y" => y - 1, "button" => button_num, "action" => action,
220
+ "shift" => button.anybits?(4), "alt" => button.anybits?(8), "ctrl" => button.anybits?(16)
221
+ }]
222
+ end
223
+
224
+ # Go parseInts: empty fields are skipped (slot left 0, not counted);
225
+ # a non-digit stops parsing and returns the count so far.
226
+ def parse_ints(str, slots)
227
+ vals = Array.new(slots, 0)
228
+ count = 0
229
+ start = 0
230
+ index = 0
231
+ i = 0
232
+ while i <= str.bytesize && index < slots
233
+ if i == str.bytesize || str.getbyte(i) == 0x3b
234
+ if i > start
235
+ num = 0
236
+ (start...i).each do |j|
237
+ d = str.getbyte(j)
238
+ return [count, vals] if d < 0x30 || d > 0x39
239
+
240
+ num = (num * 10) + (d - 0x30)
241
+ end
242
+ vals[index] = num
243
+ count += 1
244
+ end
245
+ index += 1
246
+ start = i + 1
247
+ end
248
+ i += 1
249
+ end
250
+ [count, vals]
251
+ end
252
+
253
+ # Byte length of the valid UTF-8 rune at the start of data, or nil
254
+ # where Go's utf8.DecodeRune returns (RuneError, 1).
255
+ def utf8_size(data)
256
+ b0 = data.getbyte(0)
257
+ len = if b0 < 0x80 then 1
258
+ elsif b0 >= 0xc2 && b0 <= 0xdf then 2
259
+ elsif b0 >= 0xe0 && b0 <= 0xef then 3
260
+ elsif b0 >= 0xf0 && b0 <= 0xf4 then 4
261
+ end
262
+ return nil unless len && data.bytesize >= len
263
+
264
+ data.byteslice(0, len).force_encoding(Encoding::UTF_8).valid_encoding? ? len : nil
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ module Compat
5
+ module Tea
6
+ # Upstream's InputReader (go/input.go): a background reader that pushes raw chunks of up to 256
7
+ # bytes onto a queue. Go reads through cancelreader so that a stopped reader no longer consumes
8
+ # stdin; here the thread waits with IO.select on a short timeout and checks a stop flag, and
9
+ # only reads once the input is readable. EOF or a read error ends the thread.
10
+ class InputReader
11
+ CHUNK = 256
12
+ WAIT = 0.02
13
+
14
+ attr_reader :queue
15
+
16
+ def initialize(io)
17
+ @io = io
18
+ @queue = Thread::Queue.new
19
+ @stop = false
20
+ @thread = nil
21
+ end
22
+
23
+ def start
24
+ @thread ||= Thread.new do
25
+ Thread.current.report_on_exception = false
26
+ Thread.current.name = "bubbletea input reader"
27
+ read_loop
28
+ end
29
+ self
30
+ end
31
+
32
+ def running? = !@thread.nil? && !@stop
33
+
34
+ # Stop consuming input. With wait, joins the thread (it notices the flag within WAIT seconds);
35
+ # a thread stuck in a read after all is killed rather than left to steal a child's input.
36
+ def stop(wait: true)
37
+ @stop = true
38
+ thread = @thread
39
+ return unless thread && wait && thread != Thread.current
40
+
41
+ thread.kill unless thread.join(WAIT * 10)
42
+ nil
43
+ end
44
+
45
+ # One chunk, or nil when none arrives within timeout_ms.
46
+ def pop(timeout_ms)
47
+ timeout = timeout_ms.to_i <= 0 ? 0 : timeout_ms / 1000.0
48
+ @queue.pop(timeout: timeout)
49
+ end
50
+
51
+ private
52
+
53
+ def read_loop
54
+ until @stop
55
+ ready = IO.select([@io], nil, nil, WAIT)
56
+ next unless ready && !@stop
57
+
58
+ @queue << @io.readpartial(CHUNK)
59
+ end
60
+ rescue EOFError, IOError, SystemCallError
61
+ nil
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copied unchanged from bubbletea 0.1.4 (https://github.com/marcoroth/bubbletea-ruby).
4
+ # Copyright (c) 2025 Marco Roth. MIT License; see LICENSE-bubbletea.txt in this directory.
5
+
6
+ module Bubbletea
7
+ class Message
8
+ end
9
+
10
+ class KeyMessage < Message
11
+ KEY_NULL = 0
12
+ KEY_CTRL_A = 1
13
+ KEY_CTRL_B = 2
14
+ KEY_CTRL_C = 3
15
+ KEY_CTRL_D = 4
16
+ KEY_CTRL_E = 5
17
+ KEY_CTRL_F = 6
18
+ KEY_CTRL_G = 7
19
+ KEY_CTRL_H = 8
20
+ KEY_TAB = 9
21
+ KEY_CTRL_J = 10
22
+ KEY_CTRL_K = 11
23
+ KEY_CTRL_L = 12
24
+ KEY_ENTER = 13
25
+ KEY_CTRL_N = 14
26
+ KEY_CTRL_O = 15
27
+ KEY_CTRL_P = 16
28
+ KEY_CTRL_Q = 17
29
+ KEY_CTRL_R = 18
30
+ KEY_CTRL_S = 19
31
+ KEY_CTRL_T = 20
32
+ KEY_CTRL_U = 21
33
+ KEY_CTRL_V = 22
34
+ KEY_CTRL_W = 23
35
+ KEY_CTRL_X = 24
36
+ KEY_CTRL_Y = 25
37
+ KEY_CTRL_Z = 26
38
+ KEY_ESC = 27
39
+ KEY_BACKSPACE = 127
40
+
41
+ KEY_RUNES = -1
42
+ KEY_UP = -2
43
+ KEY_DOWN = -3
44
+ KEY_RIGHT = -4
45
+ KEY_LEFT = -5
46
+ KEY_HOME = -6
47
+ KEY_END = -7
48
+ KEY_PGUP = -8
49
+ KEY_PGDOWN = -9
50
+ KEY_DELETE = -10
51
+ KEY_INSERT = -11
52
+ KEY_F1 = -12
53
+ KEY_F2 = -13
54
+ KEY_F3 = -14
55
+ KEY_F4 = -15
56
+ KEY_F5 = -16
57
+ KEY_F6 = -17
58
+ KEY_F7 = -18
59
+ KEY_F8 = -19
60
+ KEY_F9 = -20
61
+ KEY_F10 = -21
62
+ KEY_F11 = -22
63
+ KEY_F12 = -23
64
+ KEY_SHIFT_TAB = -24
65
+ KEY_SPACE = -25
66
+
67
+ attr_reader :key_type, :runes, :alt, :name
68
+
69
+ def initialize(key_type:, runes: [], alt: false, name: nil)
70
+ super()
71
+
72
+ @key_type = key_type
73
+ @runes = runes.is_a?(Array) ? runes : []
74
+ @alt = alt
75
+ @name = name || lookup_key_name
76
+ end
77
+
78
+ private
79
+
80
+ def lookup_key_name
81
+ base_name = if @key_type == KEY_RUNES && char
82
+ char
83
+ else
84
+ go_name = Bubbletea.get_key_name(@key_type)
85
+ go_name.empty? ? "unknown" : go_name
86
+ end
87
+
88
+ @alt ? "alt+#{base_name}" : base_name
89
+ end
90
+
91
+ public
92
+
93
+ def to_s
94
+ @name
95
+ end
96
+
97
+ def char
98
+ @runes.pack("U*") if @runes.any?
99
+ end
100
+
101
+ def ctrl?
102
+ @key_type.between?(0, 31)
103
+ end
104
+
105
+ def runes?
106
+ @key_type == KEY_RUNES
107
+ end
108
+
109
+ def space?
110
+ @key_type == KEY_SPACE
111
+ end
112
+
113
+ def enter?
114
+ @key_type == KEY_ENTER
115
+ end
116
+
117
+ def backspace?
118
+ @key_type == KEY_BACKSPACE
119
+ end
120
+
121
+ def tab?
122
+ @key_type == KEY_TAB
123
+ end
124
+
125
+ def esc?
126
+ @key_type == KEY_ESC
127
+ end
128
+
129
+ def up?
130
+ @key_type == KEY_UP
131
+ end
132
+
133
+ def down?
134
+ @key_type == KEY_DOWN
135
+ end
136
+
137
+ def left?
138
+ @key_type == KEY_LEFT
139
+ end
140
+
141
+ def right?
142
+ @key_type == KEY_RIGHT
143
+ end
144
+ end
145
+
146
+ class MouseMessage < Message
147
+ BUTTON_NONE = 0
148
+ BUTTON_LEFT = 1
149
+ BUTTON_MIDDLE = 2
150
+ BUTTON_RIGHT = 3
151
+ BUTTON_WHEEL_UP = 4
152
+ BUTTON_WHEEL_DOWN = 5
153
+
154
+ ACTION_PRESS = 0
155
+ ACTION_RELEASE = 1
156
+ ACTION_MOTION = 2
157
+
158
+ attr_reader :x, :y, :button, :action, :shift, :alt, :ctrl
159
+
160
+ def initialize(x:, y:, button:, action:, shift: false, alt: false, ctrl: false)
161
+ super()
162
+
163
+ @x = x
164
+ @y = y
165
+ @button = button
166
+ @action = action
167
+ @shift = shift
168
+ @alt = alt
169
+ @ctrl = ctrl
170
+ end
171
+
172
+ def press?
173
+ @action == ACTION_PRESS
174
+ end
175
+
176
+ def release?
177
+ @action == ACTION_RELEASE
178
+ end
179
+
180
+ def motion?
181
+ @action == ACTION_MOTION
182
+ end
183
+
184
+ def wheel?
185
+ @button >= BUTTON_WHEEL_UP
186
+ end
187
+
188
+ def left?
189
+ @button == BUTTON_LEFT
190
+ end
191
+
192
+ def right?
193
+ @button == BUTTON_RIGHT
194
+ end
195
+
196
+ def middle?
197
+ @button == BUTTON_MIDDLE
198
+ end
199
+ end
200
+
201
+ class WindowSizeMessage < Message
202
+ attr_reader :width, :height
203
+
204
+ def initialize(width:, height:)
205
+ super()
206
+
207
+ @width = width
208
+ @height = height
209
+ end
210
+ end
211
+
212
+ class FocusMessage < Message
213
+ end
214
+
215
+ class BlurMessage < Message
216
+ end
217
+
218
+ class QuitMessage < Message
219
+ end
220
+
221
+ class ResumeMessage < Message
222
+ end
223
+
224
+ def self.parse_event(hash)
225
+ return nil if hash.nil?
226
+
227
+ case hash["type"]
228
+ when "key"
229
+ KeyMessage.new(
230
+ key_type: hash["key_type"],
231
+ runes: hash["runes"] || [],
232
+ alt: hash["alt"] || false,
233
+ name: hash["name"]
234
+ )
235
+ when "mouse"
236
+ MouseMessage.new(
237
+ x: hash["x"],
238
+ y: hash["y"],
239
+ button: hash["button"],
240
+ action: hash["action"],
241
+ shift: hash["shift"] || false,
242
+ alt: hash["alt"] || false,
243
+ ctrl: hash["ctrl"] || false
244
+ )
245
+ when "focus"
246
+ FocusMessage.new
247
+ when "blur"
248
+ BlurMessage.new
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copied unchanged from bubbletea 0.1.4 (https://github.com/marcoroth/bubbletea-ruby).
4
+ # Copyright (c) 2025 Marco Roth. MIT License; see LICENSE-bubbletea.txt in this directory.
5
+
6
+ module Bubbletea
7
+ # Model module that provides the Elm Architecture interface.
8
+ # Include this module in your model class and implement:
9
+ # - init: Returns [model, command] - initial state and optional command
10
+ # - update(message): Returns [model, command] - new state and optional command
11
+ # - view: Returns String - the current view to render
12
+ #
13
+ # Example:
14
+ # class Counter
15
+ # include Bubbletea::Model
16
+ #
17
+ # attr_reader :count
18
+ #
19
+ # def initialize
20
+ # @count = 0
21
+ # end
22
+ #
23
+ # def init
24
+ # [self, nil]
25
+ # end
26
+ #
27
+ # def update(message)
28
+ # case message
29
+ # when Bubbletea::KeyMessage
30
+ # case message.to_s
31
+ # when "q", "ctrl+c"
32
+ # [self, Bubbletea.quit]
33
+ # when "up", "k"
34
+ # @count += 1
35
+ # [self, nil]
36
+ # when "down", "j"
37
+ # @count -= 1
38
+ # [self, nil]
39
+ # else
40
+ # [self, nil]
41
+ # end
42
+ # else
43
+ # [self, nil]
44
+ # end
45
+ # end
46
+ #
47
+ # def view
48
+ # "Count: #{@count}\n\nPress up/down to change, q to quit"
49
+ # end
50
+ # end
51
+ #
52
+ module Model
53
+ def init
54
+ [self, nil]
55
+ end
56
+
57
+ def update(_message)
58
+ [self, nil]
59
+ end
60
+
61
+ def view
62
+ ""
63
+ end
64
+ end
65
+ end