ruby_rich 0.4.0 → 0.4.2

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.
@@ -0,0 +1,512 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRich
4
+ class Composer
5
+ attr_accessor :width, :height, :min_height, :max_height
6
+ attr_reader :editor, :focused, :attachments, :history
7
+
8
+ DEFAULT_MENU_LIMIT = 8
9
+
10
+ def initialize(placeholder: "Type a message or /", commands: [], menu_limit: DEFAULT_MENU_LIMIT,
11
+ multiline: true, history_path: nil, max_history: 200,
12
+ on_submit: nil, on_select: nil, on_interrupt: nil, on_eof: nil, on_paste: nil)
13
+ @placeholder = placeholder
14
+ @commands = normalize_commands(commands)
15
+ @menu_limit = menu_limit
16
+ @on_submit = on_submit
17
+ @on_select = on_select
18
+ @on_interrupt = on_interrupt
19
+ @on_eof = on_eof
20
+ @on_paste = on_paste
21
+ @width = 0
22
+ @height = 0
23
+ @min_height = 3
24
+ @max_height = 10
25
+ @editor = LineEditor.new(multiline: multiline, history_path: history_path, max_history: max_history)
26
+ @attachments = []
27
+ @focused = false
28
+ @menu_open = false
29
+ @selected_index = 0
30
+ @ignore_next_tab = false
31
+ end
32
+
33
+ def value
34
+ @editor.value
35
+ end
36
+
37
+ def history
38
+ @editor.history
39
+ end
40
+
41
+ def attach(layout, priority: 200)
42
+ handled_events.each do |event_name|
43
+ layout.key(event_name, priority) do |event_data, live|
44
+ handle_event(event_data, live)
45
+ false
46
+ end
47
+ end
48
+
49
+ layout.key(:mouse_down, priority) do |_event_data, _live|
50
+ focus
51
+ false
52
+ end
53
+
54
+ self
55
+ end
56
+
57
+ def focus
58
+ @focused = true
59
+ self
60
+ end
61
+
62
+ def blur
63
+ @focused = false
64
+ close_menu
65
+ self
66
+ end
67
+
68
+ def focused?
69
+ @focused
70
+ end
71
+
72
+ def menu_open?
73
+ @menu_open
74
+ end
75
+
76
+ def wants_tab?
77
+ focused? && (menu_open? || @editor.value.include?("/"))
78
+ end
79
+
80
+ def ignore_next_tab
81
+ @ignore_next_tab = true
82
+ self
83
+ end
84
+
85
+ def add_attachment(attachment)
86
+ @attachments << normalize_attachment(attachment)
87
+ self
88
+ end
89
+
90
+ def register_command(name:, description: "", aliases: [], handler: nil, hidden: false, group: nil)
91
+ command = {
92
+ label: name.to_s,
93
+ value: name.to_s,
94
+ description: description.to_s,
95
+ aliases: aliases.map(&:to_s),
96
+ handler: handler,
97
+ hidden: hidden,
98
+ group: group
99
+ }
100
+ @commands.reject! { |item| item[:value] == command[:value] }
101
+ @commands << command
102
+ self
103
+ end
104
+
105
+ def refresh_commands_async(&block)
106
+ Thread.new do
107
+ next_commands = block.call
108
+ @commands = normalize_commands(next_commands) if next_commands
109
+ rescue => e
110
+ RubyRich.logger.error("Command refresh failed: #{e.class}: #{e.message}") if RubyRich.respond_to?(:logger)
111
+ end
112
+ end
113
+
114
+ def remove_attachment(index)
115
+ @attachments.delete_at(index.to_i)
116
+ end
117
+
118
+ def clear_attachments
119
+ @attachments.clear
120
+ self
121
+ end
122
+
123
+ def desired_height
124
+ input_rows = @editor.render_lines(width: inner_width, placeholder: nil, focused: false).length
125
+ attachment_rows = @attachments.empty? ? 0 : [@attachments.length, 3].min
126
+ menu_rows = menu_open? ? [[filtered_commands.length, @menu_limit].min, 1].max : 0
127
+ [[1 + input_rows + attachment_rows + menu_rows, @min_height].max, @max_height].min
128
+ end
129
+
130
+ def handle_event(event_data, live = nil)
131
+ return false unless focused?
132
+
133
+ case event_data[:name]
134
+ when :string
135
+ insert_text(event_data[:value].to_s)
136
+ when :paste
137
+ handle_paste(event_data[:value].to_s)
138
+ when :left
139
+ @editor.move_left
140
+ when :right
141
+ @editor.move_right
142
+ when :home, :ctrl_a
143
+ @editor.home
144
+ when :end, :ctrl_e
145
+ @editor.end
146
+ when :up
147
+ menu_open? ? move_selection(-1) : @editor.move_up
148
+ when :down
149
+ menu_open? ? move_selection(1) : @editor.move_down
150
+ when :backspace
151
+ @editor.backspace
152
+ sync_menu
153
+ when :delete
154
+ @editor.delete
155
+ when :ctrl_k
156
+ @editor.kill_to_end
157
+ when :ctrl_u
158
+ @editor.kill_to_start
159
+ when :ctrl_w
160
+ @editor.kill_word_back
161
+ when :enter
162
+ enter(live)
163
+ when :shift_enter, :alt_enter, :ctrl_enter
164
+ @editor.newline
165
+ when :escape
166
+ escape
167
+ when :ctrl_c
168
+ invoke_callback(@on_interrupt, live, self)
169
+ when :ctrl_d
170
+ ctrl_d(live)
171
+ when :ctrl_v
172
+ invoke_callback(@on_paste, live, self)
173
+ when :tab
174
+ if @ignore_next_tab
175
+ @ignore_next_tab = false
176
+ else
177
+ menu_open? ? move_selection(1) : open_menu_if_available
178
+ end
179
+ when :shift_tab
180
+ menu_open? ? move_selection(-1) : open_menu_if_available
181
+ end
182
+ end
183
+
184
+ def render
185
+ lines = []
186
+ lines.concat(render_attachments)
187
+ lines.concat(render_input_lines)
188
+ lines.concat(render_menu_lines) if menu_open?
189
+ fit_height(lines)
190
+ end
191
+
192
+ private
193
+
194
+ def handled_events
195
+ [
196
+ :string, :paste,
197
+ :backspace, :delete,
198
+ :left, :right, :up, :down, :home, :end,
199
+ :enter, :shift_enter, :alt_enter, :ctrl_enter, :escape, :tab, :shift_tab,
200
+ :ctrl_a, :ctrl_e, :ctrl_k, :ctrl_u, :ctrl_w, :ctrl_c, :ctrl_d, :ctrl_v
201
+ ]
202
+ end
203
+
204
+ def insert_text(text)
205
+ @editor.insert(text)
206
+ @menu_open = true if text.include?("/") || @menu_open
207
+ sync_menu
208
+ end
209
+
210
+ def handle_paste(text)
211
+ @editor.insert(text)
212
+ detect_pasted_paths(text).each { |attachment| add_attachment(attachment) }
213
+ invoke_callback(@on_paste, text, self)
214
+ sync_menu
215
+ end
216
+
217
+ def enter(live)
218
+ if menu_open? && command_query_is_bare?
219
+ submit_current_selection(live)
220
+ return
221
+ end
222
+
223
+ submit_current_value(live)
224
+ end
225
+
226
+ def escape
227
+ if menu_open?
228
+ close_menu
229
+ elsif !@editor.empty?
230
+ @editor.clear
231
+ else
232
+ blur
233
+ end
234
+ end
235
+
236
+ def ctrl_d(live)
237
+ if @editor.empty?
238
+ if @attachments.empty?
239
+ invoke_callback(@on_eof, live, self)
240
+ else
241
+ remove_attachment(@attachments.length - 1)
242
+ end
243
+ else
244
+ @editor.delete
245
+ end
246
+ end
247
+
248
+ def select_current(live)
249
+ command = filtered_commands[@selected_index]
250
+ return unless command
251
+
252
+ replace_query_with(command[:value])
253
+ close_menu
254
+ invoke_callback(@on_select, command, live)
255
+ end
256
+
257
+ def submit_current_selection(live)
258
+ command = filtered_commands[@selected_index]
259
+ return unless command
260
+
261
+ replace_query_with(command[:value])
262
+ close_menu
263
+ invoke_callback(@on_select, command, live)
264
+ submit_current_value(live)
265
+ end
266
+
267
+ def submit_current_value(live)
268
+ submitted = @editor.value
269
+ return if submitted.strip.empty? && @attachments.empty?
270
+
271
+ attachments = @attachments.dup
272
+ execute_registered_command(submitted, live)
273
+ @editor.submit_value
274
+ clear_attachments
275
+ close_menu
276
+ invoke_callback(@on_submit, submitted, live, attachments)
277
+ end
278
+
279
+ def move_selection(delta)
280
+ count = [filtered_commands.size, @menu_limit].min
281
+ return if count.zero?
282
+
283
+ @selected_index = (@selected_index + delta) % count
284
+ end
285
+
286
+ def close_menu
287
+ @menu_open = false
288
+ @selected_index = 0
289
+ end
290
+
291
+ def open_menu_if_available
292
+ @menu_open = true unless filtered_commands.empty?
293
+ clamp_selection
294
+ end
295
+
296
+ def sync_menu
297
+ @menu_open = false unless @editor.value.include?("/")
298
+ clamp_selection
299
+ end
300
+
301
+ def filtered_commands
302
+ query = current_query.downcase
303
+ commands = @commands.reject { |command| command[:hidden] }
304
+ return commands if query.empty?
305
+
306
+ commands.select do |command|
307
+ names = [command[:label], command[:value], *Array(command[:aliases])].map(&:downcase)
308
+ names.any? { |name| name.start_with?("/#{query}") || name.start_with?(query) }
309
+ end
310
+ end
311
+
312
+ def visible_commands
313
+ count = [filtered_commands.size, @menu_limit].min
314
+ return [] if count.zero?
315
+
316
+ rows = visible_menu_rows
317
+ start = [[@selected_index - rows + 1, 0].max, [count - rows, 0].max].min
318
+ filtered_commands.first(@menu_limit).each_with_index.to_a[start, rows] || []
319
+ end
320
+
321
+ def visible_menu_rows
322
+ [[@height - render_attachments.length - 1, 1].max, @menu_limit].min
323
+ end
324
+
325
+ def current_query
326
+ before_cursor = @editor.value.chars[0...@editor.cursor].join
327
+ slash_index = before_cursor.rindex("/")
328
+ return "" unless slash_index
329
+
330
+ before_cursor[(slash_index + 1)..].to_s
331
+ end
332
+
333
+ def command_query_is_bare?
334
+ !current_query.match?(/\s/)
335
+ end
336
+
337
+ def replace_query_with(replacement)
338
+ value = @editor.value
339
+ cursor = @editor.cursor
340
+ before = value.chars[0...cursor].join
341
+ after = value.chars[cursor..].join
342
+ slash_index = before.rindex("/")
343
+ return unless slash_index
344
+
345
+ @editor.value = before[0...slash_index].to_s + replacement + after
346
+ end
347
+
348
+ def clamp_selection
349
+ count = [filtered_commands.size, @menu_limit].min
350
+ @selected_index = 0 if count.zero? || @selected_index >= count
351
+ end
352
+
353
+ def render_attachments
354
+ return [] if @attachments.empty?
355
+
356
+ @attachments.first(3).each_with_index.map do |attachment, index|
357
+ suffix = attachment.mime_type ? " #{AnsiCode.color(:black, true)}#{attachment.mime_type}#{AnsiCode.reset}" : ""
358
+ "#{AnsiCode.color(:cyan, true)}[#{index + 1}]#{AnsiCode.reset} #{attachment.display_name}#{suffix}"
359
+ end
360
+ end
361
+
362
+ def render_input_lines
363
+ focus_color = focused? ? AnsiCode.color(:blue, true) : AnsiCode.color(:black, true)
364
+ prompt = focused? ? ">" : " "
365
+ placeholder = "#{AnsiCode.color(:black, true)}#{@placeholder}#{AnsiCode.reset}"
366
+ input_width = [inner_width - 2, 1].max
367
+ rendered = @editor.render_lines(width: input_width, placeholder: placeholder, focused: focused?)
368
+ rendered.each_with_index.map do |line, index|
369
+ prefix = index.zero? ? "#{focus_color}#{prompt}#{AnsiCode.reset} " : " "
370
+ visible_line = @editor.empty? ? line : colorize_input(line)
371
+ prefix + visible_line
372
+ end
373
+ end
374
+
375
+ def render_menu_lines
376
+ matches = visible_commands
377
+ return ["#{AnsiCode.color(:yellow)} No matches#{AnsiCode.reset}"] if matches.empty?
378
+
379
+ matches.map { |command, index| render_command(command, index == @selected_index) }
380
+ end
381
+
382
+ def render_command(command, selected)
383
+ marker = selected ? ">" : " "
384
+ color = selected ? AnsiCode.inverse : AnsiCode.color(:white)
385
+ description = command[:description].to_s
386
+ suffix = description.empty? ? "" : " #{AnsiCode.color(:black, true)}#{description}#{AnsiCode.reset}"
387
+ " #{color}#{marker} #{command[:label]}#{AnsiCode.reset}#{suffix}"
388
+ end
389
+
390
+ def fit_height(lines)
391
+ fitted = lines.last([@height, 1].max)
392
+ return fitted unless @width.positive?
393
+
394
+ fitted.map { |line| truncate_display(line, @width) }
395
+ end
396
+
397
+ def truncate_display(line, max_width)
398
+ plain_width = line.gsub(/\e\[[0-9;:]*m/, "").display_width
399
+ return line if plain_width <= max_width
400
+
401
+ result = +""
402
+ width = 0
403
+ in_escape = false
404
+ escape = +""
405
+ line.each_char do |char|
406
+ if in_escape
407
+ escape << char
408
+ if char == "m"
409
+ result << escape
410
+ escape = +""
411
+ in_escape = false
412
+ end
413
+ next
414
+ elsif char.ord == 27
415
+ escape << char
416
+ in_escape = true
417
+ next
418
+ end
419
+
420
+ char_width = Unicode::DisplayWidth.of(char)
421
+ break if width + char_width > max_width
422
+
423
+ result << char
424
+ width += char_width
425
+ end
426
+ result
427
+ end
428
+
429
+ def inner_width
430
+ [@width, 1].max
431
+ end
432
+
433
+ def colorize_input(line)
434
+ color = AnsiCode.color(:white, true)
435
+ reset = AnsiCode.reset
436
+ "#{color}#{line.to_s.gsub(reset, "#{reset}#{color}")}#{reset}"
437
+ end
438
+
439
+ def normalize_commands(commands)
440
+ commands.map do |command|
441
+ case command
442
+ when Hash
443
+ {
444
+ label: command.fetch(:label, command.fetch("label", command[:value] || command["value"])).to_s,
445
+ value: command.fetch(:value, command.fetch("value", command[:label] || command["label"])).to_s,
446
+ description: command.fetch(:description, command.fetch("description", "")).to_s,
447
+ aliases: Array(command.fetch(:aliases, command.fetch("aliases", []))).map(&:to_s),
448
+ handler: command[:handler] || command["handler"],
449
+ hidden: command.fetch(:hidden, command.fetch("hidden", false)),
450
+ group: command.fetch(:group, command.fetch("group", nil))
451
+ }
452
+ else
453
+ { label: command.to_s, value: command.to_s, description: "", aliases: [], handler: nil, hidden: false, group: nil }
454
+ end
455
+ end
456
+ end
457
+
458
+ def execute_registered_command(text, live)
459
+ stripped = text.strip
460
+ return false unless stripped.start_with?("/")
461
+
462
+ name, args = stripped.split(/\s+/, 2)
463
+ command = @commands.find do |item|
464
+ ([item[:value], item[:label]] + Array(item[:aliases])).include?(name)
465
+ end
466
+ return false unless command && command[:handler]
467
+
468
+ invoke_callback(command[:handler], args.to_s, live, self)
469
+ true
470
+ end
471
+
472
+ def normalize_attachment(attachment)
473
+ return attachment if attachment.is_a?(Attachment)
474
+
475
+ Attachment.new(**attachment)
476
+ end
477
+
478
+ def invoke_callback(callback, *args)
479
+ return unless callback
480
+
481
+ arity = callback.arity
482
+ callback.call(*(arity.negative? ? args : args.first(arity)))
483
+ end
484
+
485
+ def detect_pasted_paths(text)
486
+ text.scan(/(?:"([^"]+)"|'([^']+)'|(\S+\.(?:png|jpg|jpeg|gif|webp|txt|md|pdf|json|rb|py|js|ts)))/i).filter_map do |quoted, single, bare|
487
+ path = quoted || single || bare
488
+ next unless path && File.exist?(path)
489
+
490
+ Attachment.new(type: attachment_type(path), path: path, mime_type: mime_type(path))
491
+ end
492
+ end
493
+
494
+ def attachment_type(path)
495
+ path.match?(/\.(png|jpg|jpeg|gif|webp)\z/i) ? :image : :file
496
+ end
497
+
498
+ def mime_type(path)
499
+ case File.extname(path).downcase
500
+ when ".png" then "image/png"
501
+ when ".jpg", ".jpeg" then "image/jpeg"
502
+ when ".gif" then "image/gif"
503
+ when ".webp" then "image/webp"
504
+ when ".md" then "text/markdown"
505
+ when ".txt" then "text/plain"
506
+ when ".json" then "application/json"
507
+ when ".pdf" then "application/pdf"
508
+ else "application/octet-stream"
509
+ end
510
+ end
511
+ end
512
+ end