ruby_rich 0.4.8 → 0.4.9
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/lib/ruby_rich/markdown.rb +117 -6
- data/lib/ruby_rich/transcript.rb +2 -0
- data/lib/ruby_rich/version.rb +1 -1
- data/lib/ruby_rich/viewport.rb +75 -15
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 852764b68219de1ce528f772a55c3e454b3556c35aa9f0c0e89c29f31c2293ba
|
|
4
|
+
data.tar.gz: e2827430cb5dada9ae7d8ca1cb59d9fbca4df95790404c1bcb893e7323864b95
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 95388b58b409347a55ebd1ef3dded1d3520810c7f31292bb83e14dd73192f356ec453b5688dd9dc122ae1e626ff365d6738c2d9a4de799adf53df2b2cced8916
|
|
7
|
+
data.tar.gz: 342ed768c7a9f04fadc1da1ec1d775fbbbdf482fc446f55ed69b4eb880159118cd36f6860c11964ddcc7371e70bcc35adcca0203183fcc558a604bd43786091e
|
data/lib/ruby_rich/markdown.rb
CHANGED
|
@@ -221,27 +221,28 @@ module RubyRich
|
|
|
221
221
|
|
|
222
222
|
return "" if header_rows.empty? || body_rows.empty?
|
|
223
223
|
|
|
224
|
-
headers = header_rows.last
|
|
224
|
+
headers, fitted_body_rows = fit_table_rows(header_rows.last, body_rows)
|
|
225
225
|
begin
|
|
226
226
|
tbl = RubyRich::Table.new(
|
|
227
227
|
headers: headers,
|
|
228
|
-
border_style: @table_border_style
|
|
228
|
+
border_style: @table_border_style || :simple
|
|
229
229
|
)
|
|
230
|
-
|
|
230
|
+
fitted_body_rows.each do |row|
|
|
231
231
|
padded = row + Array.new([0, headers.length - row.length].max, "")
|
|
232
232
|
tbl.add_row(padded[0...headers.length])
|
|
233
233
|
end
|
|
234
234
|
"#{tbl.render}\n\n"
|
|
235
235
|
rescue
|
|
236
|
-
# fallback:
|
|
236
|
+
# fallback: plain text table
|
|
237
237
|
result = "\n"
|
|
238
238
|
result += header_rows.last.join(" | ")
|
|
239
239
|
result += "\n#{"-" * [result.strip.length, 20].min}\n"
|
|
240
240
|
body_rows.each { |row| result += row.join(" | ") + "\n" }
|
|
241
|
-
"#{result}\n"
|
|
241
|
+
return "#{result}\n"
|
|
242
242
|
end
|
|
243
243
|
end
|
|
244
244
|
|
|
245
|
+
# Extract cell text from a table row element.
|
|
245
246
|
def collect_row_cells(tr)
|
|
246
247
|
tr.children.select { |c| [:th, :td].include?(c.type) }
|
|
247
248
|
.map { |c| inline_content(c) }
|
|
@@ -255,6 +256,116 @@ module RubyRich
|
|
|
255
256
|
inline_content(el)
|
|
256
257
|
end
|
|
257
258
|
|
|
259
|
+
# ---- 表格宽度自适应 ----
|
|
260
|
+
|
|
261
|
+
# Fit table cell content to terminal width by normalising column counts,
|
|
262
|
+
# calculating natural widths, constraining to available space, and wrapping
|
|
263
|
+
# cell text.
|
|
264
|
+
def fit_table_rows(header_row, body_rows)
|
|
265
|
+
column_count = [header_row.length, *body_rows.map(&:length)].max.to_i
|
|
266
|
+
normalized_header = header_row + Array.new([0, column_count - header_row.length].max, "")
|
|
267
|
+
normalized_body = body_rows.map { |row| row + Array.new([0, column_count - row.length].max, "") }
|
|
268
|
+
natural_widths = table_natural_widths(normalized_header, normalized_body)
|
|
269
|
+
column_widths = constrain_table_widths(natural_widths)
|
|
270
|
+
|
|
271
|
+
headers = normalized_header.each_with_index.map { |cell, index| wrap_table_cell(table_cell_text(cell), column_widths[index]) }
|
|
272
|
+
rows = normalized_body.map do |row|
|
|
273
|
+
row.each_with_index.map { |cell, index| wrap_table_cell(table_cell_text(cell), column_widths[index]) }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
[headers, rows]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Maximum display width per column.
|
|
280
|
+
def table_natural_widths(header_row, body_rows)
|
|
281
|
+
rows = [header_row] + body_rows
|
|
282
|
+
return [] if rows.empty?
|
|
283
|
+
|
|
284
|
+
rows.transpose.map do |cells|
|
|
285
|
+
cells.map { |cell| cell_display_width(table_cell_text(cell)) }.max.to_i
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Strip ANSI escape sequences from a cell value.
|
|
290
|
+
def table_cell_text(cell)
|
|
291
|
+
cell.to_s.gsub(/\e\[[0-9;:]*m/, "")
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Shrink column widths proportionally to fit the terminal width.
|
|
295
|
+
def constrain_table_widths(natural_widths)
|
|
296
|
+
return natural_widths if natural_widths.empty?
|
|
297
|
+
|
|
298
|
+
border_overhead = (natural_widths.length * 3) + 1
|
|
299
|
+
max_table_width = [[(@width || 80).to_i - 1, 20].max, border_overhead + natural_widths.length].max
|
|
300
|
+
available_content_width = [max_table_width - border_overhead, natural_widths.length].max
|
|
301
|
+
widths = natural_widths.map { |width| [width, 1].max }
|
|
302
|
+
return widths if widths.sum <= available_content_width
|
|
303
|
+
|
|
304
|
+
min_width = available_content_width < natural_widths.length * 3 ? 1 : 3
|
|
305
|
+
while widths.sum > available_content_width
|
|
306
|
+
index = widths.each_with_index.select { |width, _| width > min_width }.max_by(&:first)&.last
|
|
307
|
+
break unless index
|
|
308
|
+
|
|
309
|
+
widths[index] -= 1
|
|
310
|
+
end
|
|
311
|
+
widths
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Wrap cell text to fit a given display width, splitting across newlines
|
|
315
|
+
# and wrapping long lines.
|
|
316
|
+
def wrap_table_cell(text, width)
|
|
317
|
+
width = [width.to_i, 1].max
|
|
318
|
+
text.to_s.split("\n", -1).flat_map do |line|
|
|
319
|
+
wrap_table_line(line, width)
|
|
320
|
+
end.join("\n")
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Wrap a single line of text to the given display width, preserving any
|
|
324
|
+
# ANSI escape sequences (re-emitted on each wrapped segment).
|
|
325
|
+
def wrap_table_line(line, width)
|
|
326
|
+
return [""] if line.empty?
|
|
327
|
+
|
|
328
|
+
lines = []
|
|
329
|
+
current = +""
|
|
330
|
+
current_width = 0
|
|
331
|
+
in_escape = false
|
|
332
|
+
escape = +""
|
|
333
|
+
|
|
334
|
+
line.each_char do |char|
|
|
335
|
+
if in_escape
|
|
336
|
+
escape << char
|
|
337
|
+
if char == "m"
|
|
338
|
+
current << escape
|
|
339
|
+
escape = +""
|
|
340
|
+
in_escape = false
|
|
341
|
+
end
|
|
342
|
+
next
|
|
343
|
+
elsif char.ord == 27
|
|
344
|
+
escape << char
|
|
345
|
+
in_escape = true
|
|
346
|
+
next
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
char_width = Unicode::DisplayWidth.of(char)
|
|
350
|
+
if current_width.positive? && current_width + char_width > width
|
|
351
|
+
lines << current
|
|
352
|
+
current = +""
|
|
353
|
+
current_width = 0
|
|
354
|
+
end
|
|
355
|
+
current << char
|
|
356
|
+
current_width += char_width
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
lines << current unless current.empty?
|
|
360
|
+
lines.empty? ? [""] : lines
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Display width of text after stripping ANSI escape sequences, taking the
|
|
364
|
+
# maximum across lines (for multi-line cells).
|
|
365
|
+
def cell_display_width(text)
|
|
366
|
+
text.to_s.gsub(/\e\[[0-9;:]*m/, "").split("\n").map(&:display_width).max.to_i
|
|
367
|
+
end
|
|
368
|
+
|
|
258
369
|
# ---- HTML 元素处理 ----
|
|
259
370
|
|
|
260
371
|
def convert_html_element(el)
|
|
@@ -409,4 +520,4 @@ module RubyRich
|
|
|
409
520
|
self.class.render(markdown_text, @options)
|
|
410
521
|
end
|
|
411
522
|
end
|
|
412
|
-
end
|
|
523
|
+
end
|
data/lib/ruby_rich/transcript.rb
CHANGED
|
@@ -412,6 +412,8 @@ module RubyRich
|
|
|
412
412
|
private
|
|
413
413
|
|
|
414
414
|
def render_entry(entry, index)
|
|
415
|
+
return entry.content.to_s.split("\n", -1) if entry.metadata[:plain]
|
|
416
|
+
|
|
415
417
|
case entry.type
|
|
416
418
|
when :user
|
|
417
419
|
render_plain_message(entry.content, first_prefix: "#{AnsiCode.color(:blue, true)}●#{AnsiCode.reset} ", rest_prefix: " ")
|
data/lib/ruby_rich/version.rb
CHANGED
data/lib/ruby_rich/viewport.rb
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
3
5
|
module RubyRich
|
|
4
6
|
class Viewport
|
|
5
7
|
attr_accessor :width, :height, :scroll_top
|
|
6
8
|
attr_reader :content, :selected_text
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
# drag_mode controls what left-click drag does in the content area:
|
|
11
|
+
# :viewport – drag scrolls the viewport (default, backward-compatible)
|
|
12
|
+
# :selection – drag selects text
|
|
13
|
+
DRAG_MODES = [:viewport, :selection].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(content = "",
|
|
16
|
+
scrollbar: true, auto_scroll: false,
|
|
17
|
+
scrollbar_style: :blue, auto_copy: true,
|
|
18
|
+
drag_mode: :viewport)
|
|
9
19
|
@content = content
|
|
10
20
|
@scrollbar = scrollbar
|
|
11
21
|
@auto_scroll = auto_scroll
|
|
@@ -22,6 +32,8 @@ module RubyRich
|
|
|
22
32
|
@selection_end = nil
|
|
23
33
|
@selected_text = ""
|
|
24
34
|
@focused = true
|
|
35
|
+
@auto_copy = auto_copy
|
|
36
|
+
@drag_mode = DRAG_MODES.include?(drag_mode) ? drag_mode : :viewport
|
|
25
37
|
@rendered_lines_cache_key = nil
|
|
26
38
|
@rendered_lines_cache = nil
|
|
27
39
|
end
|
|
@@ -88,10 +100,14 @@ module RubyRich
|
|
|
88
100
|
true
|
|
89
101
|
when :mouse_down
|
|
90
102
|
return copy_selection if event_data[:button] == :right
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
if @drag_mode == :selection
|
|
104
|
+
start_scrollbar_drag(event_data, layout) || start_selection(event_data, layout)
|
|
105
|
+
else
|
|
106
|
+
start_scrollbar_drag(event_data, layout) || start_viewport_drag(event_data, layout)
|
|
107
|
+
end
|
|
93
108
|
when :mouse_drag
|
|
94
|
-
drag_scrollbar(event_data, layout) ||
|
|
109
|
+
drag_scrollbar(event_data, layout) ||
|
|
110
|
+
(@drag_mode == :selection ? drag_selection(event_data, layout) : (drag_viewport(event_data, layout) || drag_selection(event_data, layout)))
|
|
95
111
|
when :mouse_up
|
|
96
112
|
stop_scrollbar_drag || stop_viewport_drag || stop_selection
|
|
97
113
|
else
|
|
@@ -415,7 +431,7 @@ module RubyRich
|
|
|
415
431
|
|
|
416
432
|
@selecting = false
|
|
417
433
|
@selected_text = extract_selected_text
|
|
418
|
-
copy_selection
|
|
434
|
+
copy_selection if @auto_copy
|
|
419
435
|
true
|
|
420
436
|
end
|
|
421
437
|
|
|
@@ -477,6 +493,7 @@ module RubyRich
|
|
|
477
493
|
escape << char
|
|
478
494
|
if char == "m"
|
|
479
495
|
result << escape
|
|
496
|
+
result << AnsiCode.inverse if active
|
|
480
497
|
escape = +""
|
|
481
498
|
in_escape = false
|
|
482
499
|
end
|
|
@@ -531,18 +548,61 @@ module RubyRich
|
|
|
531
548
|
text.gsub(/\e\[[0-9;:]*m/, "")
|
|
532
549
|
end
|
|
533
550
|
|
|
551
|
+
# Copy text to the system clipboard, trying every available method in
|
|
552
|
+
# sequence and falling back to the OSC 52 terminal clipboard so that
|
|
553
|
+
# remote (SSH) sessions also work.
|
|
534
554
|
def copy_to_clipboard(text)
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
555
|
+
text = text.to_s
|
|
556
|
+
return false if text.empty?
|
|
557
|
+
return true if RubyRich::Terminal.windows? && try_windows_clipboard(text)
|
|
558
|
+
|
|
559
|
+
clipboard_commands.each do |command|
|
|
560
|
+
return true if write_clipboard_command(command, text)
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
copy_to_terminal_clipboard(text)
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Ordered list of [command, *args] arrays to try for clipboard access.
|
|
567
|
+
def clipboard_commands
|
|
568
|
+
commands = []
|
|
569
|
+
commands << ["wl-copy"] if ENV["WAYLAND_DISPLAY"]
|
|
570
|
+
if ENV["DISPLAY"]
|
|
571
|
+
commands << ["xclip", "-selection", "clipboard"]
|
|
572
|
+
commands << ["xsel", "--clipboard", "--input"]
|
|
543
573
|
end
|
|
574
|
+
commands << ["pbcopy"] if RUBY_PLATFORM.match?(/darwin/)
|
|
575
|
+
commands
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Attempt to write text to a given clipboard command via a pipe.
|
|
579
|
+
# Returns true when the subprocess exits successfully.
|
|
580
|
+
def write_clipboard_command(command, text)
|
|
581
|
+
IO.popen(command, "w") { |io| io.write(text) }
|
|
582
|
+
$?&.success? == true
|
|
544
583
|
rescue IOError, SystemCallError
|
|
545
|
-
|
|
584
|
+
false
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Try the OS-specific Windows clipboard helper and return true when it
|
|
588
|
+
# succeeds (any IO/Syscall error is treated as failure).
|
|
589
|
+
def try_windows_clipboard(text)
|
|
590
|
+
copy_to_windows_clipboard(text)
|
|
591
|
+
true
|
|
592
|
+
rescue IOError, SystemCallError
|
|
593
|
+
false
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# OSC 52 terminal clipboard – the only fallback that works over SSH.
|
|
597
|
+
# Encoded payload is emitted on stdout for the hosting terminal to
|
|
598
|
+
# capture.
|
|
599
|
+
def copy_to_terminal_clipboard(text)
|
|
600
|
+
encoded = Base64.strict_encode64(text.encode(Encoding::UTF_8))
|
|
601
|
+
$stdout.print("\e]52;c;#{encoded}\a")
|
|
602
|
+
$stdout.flush
|
|
603
|
+
true
|
|
604
|
+
rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError, IOError, SystemCallError
|
|
605
|
+
false
|
|
546
606
|
end
|
|
547
607
|
|
|
548
608
|
def copy_to_windows_clipboard(text)
|
|
@@ -559,4 +619,4 @@ module RubyRich
|
|
|
559
619
|
end
|
|
560
620
|
end
|
|
561
621
|
end
|
|
562
|
-
end
|
|
622
|
+
end
|