ruby_rich 0.4.8 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 568e2001bde24749d1410dadda7e77090963eedc48e9ad9e6d77f036e245a51f
4
- data.tar.gz: 99a40412ae33eb985e7b0e1d4dc0516b8539ca32eb79ae7ec881b673d350ecc5
3
+ metadata.gz: 6db3483399e193c2a8feb97472df06341391d165f07aafde1af299a0892bb83c
4
+ data.tar.gz: 71ef2db35e2b369953c7201e13bc751001f02815dd2717ea3358964ff12b30b3
5
5
  SHA512:
6
- metadata.gz: 187311b6561fcfea7c51af5ab44bd8b8dcbead8fabd5108d9356b07eacf82f0994ef5c6c64e00db1764a7cd8a546feb6df17c2ab119f21e11898a924a485499e
7
- data.tar.gz: ff1f361748e077f0ad6f59c30b22a8502e83a2196e7ab23a2365ee67362283036f0fb1e3da4eea102cdacfcea1c8c1ab1c08b15e8841c0da1b270182e0497e4b
6
+ metadata.gz: d4d3b29b2e73d8ebe31e883ba0001cb207f65b2683897d861601918e097823bce393302dd51b276fe15c71608959d28d61c6413e82c26fc0226245cf9b2c358b
7
+ data.tar.gz: 41274198774c77707e3d052a9b81b1d1e18d2ae293a63b0dadcdbf508b1d74c3892203433d2a4cd92759275ce44b31906f83bd4ee3e10a128fefe3e8b890bcd5
@@ -13,6 +13,46 @@ module RubyRich
13
13
  # - 数学公式 (需 math engine)
14
14
  # - 缩写 (abbreviations)
15
15
  # - 活跃维护
16
+
17
+ # === Markdown rendering colour theme. ===
18
+ # Each key maps to `[color, bright]` accepted by AnsiCode.
19
+ MarkdownTheme = {
20
+ heading_1: [:cyan, true],
21
+ heading_2: [:blue, true],
22
+ heading_3: [:yellow, true],
23
+ heading_4_6: [:black, true],
24
+ heading_underline: [:cyan, true], # H1
25
+ heading_underline2:[:blue, true], # H2
26
+ text: [:white, true],
27
+ strong_text: [:white, true],
28
+ code_border: [:black, true],
29
+ code_bg: [:black, true],
30
+ code_fg: [:white, true],
31
+ inline_code_fg: [:black, false],
32
+ inline_code_bg: [:white, false],
33
+ blockquote_marker: [:black, true],
34
+ blockquote_text: [:white, true],
35
+ blockquote_italic: true,
36
+ list_level_1: [:cyan, true],
37
+ list_level_2: [:magenta, true],
38
+ list_level_3: [:yellow, true],
39
+ ordered_list: [:cyan, true],
40
+ task_checked: [:green, true],
41
+ task_unchecked: [:black, true],
42
+ link_text: [:blue, true],
43
+ link_url: [:black, true],
44
+ image_label: [:magenta, true],
45
+ rule: [:black, true],
46
+ footnote: [:magenta, true],
47
+ abbreviation: [:black, true],
48
+ math: [:magenta, true],
49
+ mark_fg: [:black, false],
50
+ mark_bg: [:yellow, false],
51
+ kbd_fg: [:black, false],
52
+ kbd_bg: [:white, false],
53
+ table_border: :simple
54
+ }.freeze
55
+
16
56
  class TerminalConverter < Kramdown::Converter::Base
17
57
 
18
58
  def initialize(root, options)
@@ -20,8 +60,26 @@ module RubyRich
20
60
  @width = options[:width] || 80
21
61
  @indent_str = options[:indent] || ' '
22
62
  @table_border_style = options[:table_border_style] || :simple
63
+ @theme = (options[:theme] || MarkdownTheme).freeze
23
64
  # 用于有序列表编号
24
65
  @list_counters = []
66
+ @list_types = []
67
+ end
68
+
69
+ # Shortcut: AnsiCode.color with theme lookup.
70
+ def tc(key)
71
+ color, bright = @theme[key]
72
+ AnsiCode.color(color, bright)
73
+ end
74
+
75
+ def tbg(key)
76
+ color, bright = @theme[key]
77
+ AnsiCode.background(color, bright)
78
+ end
79
+
80
+ def tfont(key, **overrides)
81
+ color, bright = @theme[key]
82
+ AnsiCode.font(color, font_bright: bright, **overrides)
25
83
  end
26
84
 
27
85
  # 主分发方法 — 根据 AST 元素类型路由到对应处理方法
@@ -88,7 +146,7 @@ module RubyRich
88
146
  end
89
147
 
90
148
  def visible_width(text)
91
- text.to_s.gsub(/\e\[[0-9;]*m/, '').length
149
+ text.to_s.gsub(/\e\[[0-9;]*m/, '').display_width
92
150
  end
93
151
 
94
152
  # ---- 块级元素 ----
@@ -103,39 +161,112 @@ module RubyRich
103
161
  vw = visible_width(text)
104
162
  case level
105
163
  when 1
106
- "#{AnsiCode.font(:cyan, font_bright: true, bold: true)}#{text}#{AnsiCode.reset}\n" \
107
- "#{AnsiCode.color(:cyan, true)}#{'=' * [vw, 1].max}#{AnsiCode.reset}\n\n"
164
+ "#{tfont(:heading_1, bold: true)}#{text}#{AnsiCode.reset}\n" \
165
+ "#{tc(:heading_underline)}#{'=' * [vw, 1].max}#{AnsiCode.reset}\n\n"
108
166
  when 2
109
- "#{AnsiCode.font(:blue, font_bright: true, bold: true)}#{text}#{AnsiCode.reset}\n" \
110
- "#{AnsiCode.color(:blue, true)}#{'-' * [vw, 1].max}#{AnsiCode.reset}\n\n"
167
+ "#{tfont(:heading_2, bold: true)}#{text}#{AnsiCode.reset}\n" \
168
+ "#{tc(:heading_underline2)}#{'-' * [vw, 1].max}#{AnsiCode.reset}\n\n"
111
169
  when 3
112
- "#{AnsiCode.font(:yellow, font_bright: true, bold: true)}### #{text}#{AnsiCode.reset}\n\n"
170
+ "#{tfont(:heading_3, bold: true)}### #{text}#{AnsiCode.reset}\n\n"
113
171
  else
114
172
  prefix = '#' * level
115
- "#{AnsiCode.font(:black, font_bright: true, bold: true)}#{prefix} #{text}#{AnsiCode.reset}\n\n"
173
+ "#{tfont(:heading_4_6, bold: true)}#{prefix} #{text}#{AnsiCode.reset}\n\n"
116
174
  end
117
175
  end
118
176
 
119
177
  def convert_codeblock(el)
120
178
  lang = el.options[:lang]&.strip
121
179
  lang = nil if lang && lang.empty?
122
- code = el.value.strip
123
- highlighted = Syntax.highlight(code, lang)
124
- bg = AnsiCode.background(:black, true)
125
- fg = AnsiCode.color(:white, true)
126
- "#{bg}#{fg}#{indent_lines(highlighted)}#{AnsiCode.reset}\n\n"
180
+ code = el.value
181
+ # Preserve trailing newlines for correct line count; strip leading/trailing
182
+ # blank lines that would produce empty first/last code rows.
183
+ code_lines = code.split("\n", -1)
184
+ code_lines.shift while code_lines.first&.strip&.empty?
185
+ code_lines.pop while code_lines.last&.strip&.empty?
186
+ return "#{tc(:code_border)}┌─ #{lang || "text"} ─┐#{AnsiCode.reset}\n#{tc(:code_border)}└──┘#{AnsiCode.reset}\n\n" if code_lines.empty?
187
+
188
+ # Mermaid diagram support
189
+ if lang == "mermaid" || lang == "mmd"
190
+ rendered = MermaidRenderer.render(code, @width.to_i)
191
+ return "#{rendered}\n\n"
192
+ end
193
+
194
+ total_lines = code_lines.length
195
+ digit_width = [total_lines.to_s.length, 1].max
196
+ label = lang && !lang.empty? ? lang : "text"
197
+ # Left gutter: "│ " (2) + digits (digit_width) + " │ " (3) = 5 + digit_width
198
+ # Right: " │" (2). Total overhead = 7 + digit_width.
199
+ gutter_width = 5 + digit_width
200
+ code_width = [@width.to_i - gutter_width - 1, 20].max
201
+
202
+ border = tc(:code_border)
203
+ rows = []
204
+ code_lines.each_with_index do |raw_line, idx|
205
+ num = format("%#{digit_width}d", idx + 1)
206
+ highlighted = Syntax.highlight(raw_line, lang)
207
+ fit = fit_code_line(highlighted, code_width)
208
+ rows << "#{border}│#{AnsiCode.reset} #{num} #{border}│#{AnsiCode.reset} #{fit}#{border}│#{AnsiCode.reset}"
209
+ end
210
+
211
+ top_border = "#{border}┌─ #{label} #{'─' * [@width.to_i - label.display_width - 5, 2].max}┐#{AnsiCode.reset}"
212
+ bottom_border = "#{border}└#{'─' * (@width.to_i - 2)}┘#{AnsiCode.reset}"
213
+
214
+ [top_border, rows.join("\n"), bottom_border, "", ""].join("\n")
215
+ end
216
+
217
+ # Fit a syntax-highlighted line to a given display width, preserving
218
+ # ANSI escape sequences and truncating with "…" when necessary.
219
+ def fit_code_line(line, max_width)
220
+ return "" if max_width <= 0
221
+
222
+ result = +""
223
+ width = 0
224
+ in_escape = false
225
+ escape = +""
226
+ truncated = false
227
+
228
+ line.each_char do |char|
229
+ if in_escape
230
+ escape << char
231
+ if char == "m"
232
+ result << escape
233
+ escape = +""
234
+ in_escape = false
235
+ end
236
+ next
237
+ elsif char.ord == 27
238
+ escape << char
239
+ in_escape = true
240
+ next
241
+ end
242
+
243
+ char_width = Unicode::DisplayWidth.of(char)
244
+ if width + char_width + 1 > max_width
245
+ truncated = true
246
+ break
247
+ end
248
+ result << char
249
+ width += char_width
250
+ end
251
+
252
+ result << "…" if truncated
253
+ result << " " * (max_width - width) unless truncated
254
+ result << AnsiCode.reset
255
+ result
127
256
  end
128
257
 
129
258
  def convert_codespan(el)
130
259
  code = el.value
131
- "#{AnsiCode.background(:white)}#{AnsiCode.color(:black)} #{code} #{AnsiCode.reset}"
260
+ "#{tbg(:inline_code_bg)}#{tc(:inline_code_fg)} #{code} #{AnsiCode.reset}"
132
261
  end
133
262
 
134
263
  def convert_blockquote(el)
135
264
  content = inline_content(el)
136
265
  lines = content.strip.split("\n")
266
+ marker = tc(:blockquote_marker)
267
+ text_c = tc(:blockquote_text)
137
268
  quoted = lines.map do |line|
138
- "#{AnsiCode.color(:black, true)}│ #{AnsiCode.color(:white, true)}#{strip_ansi_reset(line)}"
269
+ "#{marker}│ #{text_c}#{strip_ansi_reset(line)}"
139
270
  end
140
271
  "#{quoted.join("\n")}#{AnsiCode.reset}\n\n"
141
272
  end
@@ -145,20 +276,54 @@ module RubyRich
145
276
  def convert_list(el)
146
277
  # 保存/恢复计数器,支持嵌套列表
147
278
  @list_counters.push(0)
279
+ @list_types.push(el.type)
148
280
  result = convert_children(el)
281
+ @list_types.pop
149
282
  @list_counters.pop
150
283
  "#{result}\n"
151
284
  end
152
285
 
286
+ UNORDERED_MARKERS = { 1 => '•', 2 => '◦', 3 => '▸' }.freeze
287
+
153
288
  def convert_li(el)
154
- marker = if el.options[:parent]&.type == :ol
289
+ depth = [@list_counters.length, 1].max
290
+ list_type = @list_types.last
291
+ marker = if list_type == :ol
155
292
  @list_counters[-1] += 1
156
293
  "#{@list_counters[-1]}."
157
294
  else
158
- ''
295
+ UNORDERED_MARKERS[depth.clamp(1, 3)] || ''
159
296
  end
297
+ task = detect_task_marker(el)
160
298
  text = inline_content(el)
161
- "#{AnsiCode.color(:cyan, true)}#{marker}#{AnsiCode.reset} #{text.strip}\n"
299
+ if task
300
+ "#{tc(task ? :task_checked : :task_unchecked)}#{task}#{AnsiCode.reset} #{text.strip}\n"
301
+ else
302
+ "#{tc(list_type == :ol ? :ordered_list : :"list_level_#{depth.clamp(1, 3)}")}#{marker}#{AnsiCode.reset} #{text.strip}\n"
303
+ end
304
+ end
305
+
306
+ # Detect GitHub Flavored Markdown task list markers.
307
+ # kramdown-parser-gfm represents them as a child HTML input element
308
+ # inside the li's paragraph (p); we extract the checked state for
309
+ # terminal rendering.
310
+ TASK_CHECKED = '☑'.freeze
311
+ TASK_UNCHECKED = '☐'.freeze
312
+
313
+ def detect_task_marker(el)
314
+ return nil unless el.children
315
+
316
+ para = el.children.find { |c| c.type == :p }
317
+ return nil unless para&.children
318
+
319
+ input_idx = para.children.index { |c| c.type == :html_element && c.value.to_s.downcase == 'input' }
320
+ return nil unless input_idx
321
+
322
+ raw = para.children[input_idx].attr['checked']
323
+ checked = raw&.to_s&.downcase == 'checked'
324
+ # Remove the hidden input element so text is clean.
325
+ para.children.delete_at(input_idx)
326
+ checked ? TASK_CHECKED : TASK_UNCHECKED
162
327
  end
163
328
 
164
329
  # ---- 内联样式 ----
@@ -182,8 +347,8 @@ module RubyRich
182
347
  title = el.attr['title']
183
348
  text = inline_content(el)
184
349
  title_part = title && !title.empty? ? " - #{title}" : ""
185
- "#{AnsiCode.color(:blue, true)}#{AnsiCode.underline}#{text}#{AnsiCode.reset} " \
186
- "#{AnsiCode.color(:black, true)}(#{url}#{title_part})#{AnsiCode.reset}"
350
+ "#{tc(:link_text)}#{AnsiCode.underline}#{text}#{AnsiCode.reset} " \
351
+ "#{tc(:link_url)}(#{url}#{title_part})#{AnsiCode.reset}"
187
352
  end
188
353
 
189
354
  def convert_image(el)
@@ -191,15 +356,15 @@ module RubyRich
191
356
  title = el.attr['title']
192
357
  alt = el.attr['alt'] || ''
193
358
  title_part = title && !title.empty? ? " - #{title}" : ""
194
- "#{AnsiCode.color(:magenta, true)}[Image: #{alt}]#{AnsiCode.reset} " \
195
- "#{AnsiCode.color(:black, true)}(#{url}#{title_part})#{AnsiCode.reset}"
359
+ "#{tc(:image_label)}[Image: #{alt}]#{AnsiCode.reset} " \
360
+ "#{tc(:link_url)}(#{url}#{title_part})#{AnsiCode.reset}"
196
361
  end
197
362
 
198
363
  # ---- 水平线 ----
199
364
 
200
365
  def convert_hr(_el)
201
366
  line_char = '─'
202
- "#{AnsiCode.color(:black, true)}#{line_char * @width}#{AnsiCode.reset}\n\n"
367
+ "#{tc(:rule)}#{line_char * @width}#{AnsiCode.reset}\n\n"
203
368
  end
204
369
 
205
370
  # ---- 表格(kramdown 原生 AST) ----
@@ -221,27 +386,28 @@ module RubyRich
221
386
 
222
387
  return "" if header_rows.empty? || body_rows.empty?
223
388
 
224
- headers = header_rows.last
389
+ headers, fitted_body_rows = fit_table_rows(header_rows.last, body_rows)
225
390
  begin
226
391
  tbl = RubyRich::Table.new(
227
392
  headers: headers,
228
- border_style: @table_border_style
393
+ border_style: @table_border_style || :simple
229
394
  )
230
- body_rows.each do |row|
395
+ fitted_body_rows.each do |row|
231
396
  padded = row + Array.new([0, headers.length - row.length].max, "")
232
397
  tbl.add_row(padded[0...headers.length])
233
398
  end
234
399
  "#{tbl.render}\n\n"
235
400
  rescue
236
- # fallback: 纯文本表格
401
+ # fallback: plain text table
237
402
  result = "\n"
238
403
  result += header_rows.last.join(" | ")
239
404
  result += "\n#{"-" * [result.strip.length, 20].min}\n"
240
405
  body_rows.each { |row| result += row.join(" | ") + "\n" }
241
- "#{result}\n"
406
+ return "#{result}\n"
242
407
  end
243
408
  end
244
409
 
410
+ # Extract cell text from a table row element.
245
411
  def collect_row_cells(tr)
246
412
  tr.children.select { |c| [:th, :td].include?(c.type) }
247
413
  .map { |c| inline_content(c) }
@@ -255,6 +421,116 @@ module RubyRich
255
421
  inline_content(el)
256
422
  end
257
423
 
424
+ # ---- 表格宽度自适应 ----
425
+
426
+ # Fit table cell content to terminal width by normalising column counts,
427
+ # calculating natural widths, constraining to available space, and wrapping
428
+ # cell text.
429
+ def fit_table_rows(header_row, body_rows)
430
+ column_count = [header_row.length, *body_rows.map(&:length)].max.to_i
431
+ normalized_header = header_row + Array.new([0, column_count - header_row.length].max, "")
432
+ normalized_body = body_rows.map { |row| row + Array.new([0, column_count - row.length].max, "") }
433
+ natural_widths = table_natural_widths(normalized_header, normalized_body)
434
+ column_widths = constrain_table_widths(natural_widths)
435
+
436
+ headers = normalized_header.each_with_index.map { |cell, index| wrap_table_cell(table_cell_text(cell), column_widths[index]) }
437
+ rows = normalized_body.map do |row|
438
+ row.each_with_index.map { |cell, index| wrap_table_cell(table_cell_text(cell), column_widths[index]) }
439
+ end
440
+
441
+ [headers, rows]
442
+ end
443
+
444
+ # Maximum display width per column.
445
+ def table_natural_widths(header_row, body_rows)
446
+ rows = [header_row] + body_rows
447
+ return [] if rows.empty?
448
+
449
+ rows.transpose.map do |cells|
450
+ cells.map { |cell| cell_display_width(table_cell_text(cell)) }.max.to_i
451
+ end
452
+ end
453
+
454
+ # Strip ANSI escape sequences from a cell value.
455
+ def table_cell_text(cell)
456
+ cell.to_s.gsub(/\e\[[0-9;:]*m/, "")
457
+ end
458
+
459
+ # Shrink column widths proportionally to fit the terminal width.
460
+ def constrain_table_widths(natural_widths)
461
+ return natural_widths if natural_widths.empty?
462
+
463
+ border_overhead = (natural_widths.length * 3) + 1
464
+ max_table_width = [[(@width || 80).to_i - 1, 20].max, border_overhead + natural_widths.length].max
465
+ available_content_width = [max_table_width - border_overhead, natural_widths.length].max
466
+ widths = natural_widths.map { |width| [width, 1].max }
467
+ return widths if widths.sum <= available_content_width
468
+
469
+ min_width = available_content_width < natural_widths.length * 3 ? 1 : 3
470
+ while widths.sum > available_content_width
471
+ index = widths.each_with_index.select { |width, _| width > min_width }.max_by(&:first)&.last
472
+ break unless index
473
+
474
+ widths[index] -= 1
475
+ end
476
+ widths
477
+ end
478
+
479
+ # Wrap cell text to fit a given display width, splitting across newlines
480
+ # and wrapping long lines.
481
+ def wrap_table_cell(text, width)
482
+ width = [width.to_i, 1].max
483
+ text.to_s.split("\n", -1).flat_map do |line|
484
+ wrap_table_line(line, width)
485
+ end.join("\n")
486
+ end
487
+
488
+ # Wrap a single line of text to the given display width, preserving any
489
+ # ANSI escape sequences (re-emitted on each wrapped segment).
490
+ def wrap_table_line(line, width)
491
+ return [""] if line.empty?
492
+
493
+ lines = []
494
+ current = +""
495
+ current_width = 0
496
+ in_escape = false
497
+ escape = +""
498
+
499
+ line.each_char do |char|
500
+ if in_escape
501
+ escape << char
502
+ if char == "m"
503
+ current << escape
504
+ escape = +""
505
+ in_escape = false
506
+ end
507
+ next
508
+ elsif char.ord == 27
509
+ escape << char
510
+ in_escape = true
511
+ next
512
+ end
513
+
514
+ char_width = Unicode::DisplayWidth.of(char)
515
+ if current_width.positive? && current_width + char_width > width
516
+ lines << current
517
+ current = +""
518
+ current_width = 0
519
+ end
520
+ current << char
521
+ current_width += char_width
522
+ end
523
+
524
+ lines << current unless current.empty?
525
+ lines.empty? ? [""] : lines
526
+ end
527
+
528
+ # Display width of text after stripping ANSI escape sequences, taking the
529
+ # maximum across lines (for multi-line cells).
530
+ def cell_display_width(text)
531
+ text.to_s.gsub(/\e\[[0-9;:]*m/, "").split("\n").map(&:display_width).max.to_i
532
+ end
533
+
258
534
  # ---- HTML 元素处理 ----
259
535
 
260
536
  def convert_html_element(el)
@@ -271,9 +547,9 @@ module RubyRich
271
547
  when 'sup'
272
548
  content # 终端无上标,保留文本
273
549
  when 'kbd'
274
- "#{AnsiCode.background(:white)}#{AnsiCode.color(:black)} #{content} #{AnsiCode.reset}"
550
+ "#{tbg(:kbd_bg)}#{tc(:kbd_fg)} #{content} #{AnsiCode.reset}"
275
551
  when 'mark'
276
- "#{AnsiCode.background(:yellow)}#{AnsiCode.color(:black)}#{content}#{AnsiCode.reset}"
552
+ "#{tbg(:mark_bg)}#{tc(:mark_fg)}#{content}#{AnsiCode.reset}"
277
553
  when 'details', 'summary'
278
554
  content
279
555
  when 'br'
@@ -315,7 +591,7 @@ module RubyRich
315
591
  # 脚注内容在文档末尾自动收集
316
592
  content = inline_content(el)
317
593
  name = el.options[:name]
318
- "#{AnsiCode.color(:magenta, true)}[^#{name}]#{AnsiCode.reset}"
594
+ "#{tc(:footnote)}[^#{name}]#{AnsiCode.reset}"
319
595
  end
320
596
 
321
597
  # ---- 定义列表(kramdown 独有) ----
@@ -338,7 +614,7 @@ module RubyRich
338
614
  title = el.attr['title']
339
615
  text = inline_content(el)
340
616
  if title && !title.empty?
341
- "#{AnsiCode.underline}#{text}#{AnsiCode.reset}#{AnsiCode.color(:black, true)}(#{title})#{AnsiCode.reset}"
617
+ "#{AnsiCode.underline}#{text}#{AnsiCode.reset}#{tc(:abbreviation)}(#{title})#{AnsiCode.reset}"
342
618
  else
343
619
  text
344
620
  end
@@ -347,13 +623,308 @@ module RubyRich
347
623
  # ---- 数学公式(kramdown 独有,需 math engine) ----
348
624
 
349
625
  def convert_math(el)
350
- # 在终端中显示原始 LaTeX 公式
351
626
  mode = el.options[:category] == :block ? 'block' : 'inline'
352
627
  formula = el.value.to_s.strip
628
+ rendered = LatexConverter.convert(formula)
629
+ color = tc(:math)
353
630
  if mode == 'block'
354
- "#{AnsiCode.color(:magenta, true)}[Math]\n#{formula}\n[/Math]#{AnsiCode.reset}\n\n"
631
+ "#{color}#{rendered}#{AnsiCode.reset}\n\n"
355
632
  else
356
- "#{AnsiCode.color(:magenta, true)}[Math: #{formula}]#{AnsiCode.reset}"
633
+ "#{color}#{rendered}#{AnsiCode.reset}"
634
+ end
635
+ end
636
+
637
+ # ---- LaTeX to Unicode converter ----
638
+ # Translates common LaTeX math commands to Unicode characters
639
+ # for terminal display. Handles Greek letters, big operators,
640
+ # frac, sqrt, super/subscript, cases, and ~150 common symbols.
641
+ module LatexConverter
642
+ # --- big lookup table ------------------------------------
643
+ # Format: "\\command" => "unicode_char"
644
+ SYMBOLS = {
645
+ # Greek lowercase
646
+ 'alpha' => 'α', 'beta' => 'β', 'gamma' => 'γ',
647
+ 'delta' => 'δ', 'epsilon' => 'ε', 'varepsilon' => 'ɛ',
648
+ 'zeta' => 'ζ', 'eta' => 'η', 'theta' => 'θ',
649
+ 'vartheta' => 'ϑ', 'iota' => 'ι', 'kappa' => 'κ',
650
+ 'lambda' => 'λ', 'mu' => 'μ', 'nu' => 'ν',
651
+ 'xi' => 'ξ', 'pi' => 'π', 'varpi' => 'ϖ',
652
+ 'rho' => 'ρ', 'varrho' => 'ϱ', 'sigma' => 'σ',
653
+ 'varsigma' => 'ς', 'tau' => 'τ', 'upsilon' => 'υ',
654
+ 'phi' => 'φ', 'varphi' => 'ϕ', 'chi' => 'χ',
655
+ 'psi' => 'ψ', 'omega' => 'ω',
656
+ # Greek uppercase
657
+ 'Gamma' => 'Γ', 'Delta' => 'Δ', 'Theta' => 'Θ',
658
+ 'Lambda' => 'Λ', 'Xi' => 'Ξ', 'Pi' => 'Π',
659
+ 'Sigma' => 'Σ', 'Upsilon' => 'Υ', 'Phi' => 'Φ',
660
+ 'Psi' => 'Ψ', 'Omega' => 'Ω',
661
+ # Relations
662
+ 'leq' => '≤', 'geq' => '≥', 'neq' => '≠',
663
+ 'equiv' => '≡', 'approx' => '≈', 'sim' => '∼',
664
+ 'simeq' => '≃', 'propto' => '∝', 'll' => '≪',
665
+ 'gg' => '≫', 'doteq' => '≐', 'prec' => '≺',
666
+ 'succ' => '≻', 'preceq' => '≼', 'succeq' => '≽',
667
+ 'subset' => '⊂', 'supset' => '⊃', 'subseteq' => '⊆',
668
+ 'supseteq' => '⊇', 'in' => '∈', 'ni' => '∋',
669
+ 'notin' => '∉', 'perp' => '⊥', 'parallel' => '∥',
670
+ # Binary operators
671
+ 'times' => '×', 'div' => '÷', 'cdot' => '·',
672
+ 'pm' => '±', 'mp' => '∓', 'oplus' => '⊕',
673
+ 'ominus' => '⊖', 'otimes' => '⊗', 'oslash' => '⊘',
674
+ 'odot' => '⊙', 'circ' => '∘', 'bullet' => '∙',
675
+ 'cap' => '∩', 'cup' => '∪', 'setminus' => '∖',
676
+ 'land' => '∧', 'lor' => '∨', 'wedge' => '∧',
677
+ 'vee' => '∨', 'star' => '⋆',
678
+ # Arrows
679
+ 'to' => '→', 'rightarrow' => '→', 'Rightarrow' => '⇒',
680
+ 'leftarrow' => '←', 'Leftarrow' => '⇐',
681
+ 'leftrightarrow' => '↔', 'Leftrightarrow' => '⇔',
682
+ 'mapsto' => '↦', 'longmapsto' => '⟼',
683
+ 'uparrow' => '↑', 'downarrow' => '↓',
684
+ 'longrightarrow' => '⟶', 'Longrightarrow' => '⟹',
685
+ # Big operators
686
+ 'sum' => '∑', 'prod' => '∏', 'coprod' => '∐',
687
+ 'int' => '∫', 'iint' => '∬', 'iiint' => '∭',
688
+ 'oint' => '∮', 'bigcup' => '⋃', 'bigcap' => '⋂',
689
+ 'bigvee' => '⋁', 'bigwedge' => '⋀',
690
+ # Misc symbols
691
+ 'infty' => '∞', 'partial' => '∂', 'nabla' => '∇',
692
+ 'forall' => '∀', 'exists' => '∃', 'nexists' => '∄',
693
+ 'emptyset' => '∅', 'varnothing' => '∅',
694
+ 'Re' => 'ℜ', 'Im' => 'ℑ', 'aleph' => 'ℵ',
695
+ 'ell' => 'ℓ', 'hbar' => 'ℏ', 'wp' => '℘',
696
+ 'angle' => '∠', 'triangle' => '△', 'triangledown' => '▽',
697
+ 'square' => '□', 'Box' => '□', 'diamond' => '◇',
698
+ 'clubsuit' => '♣', 'diamondsuit' => '♢',
699
+ 'heartsuit' => '♡', 'spadesuit' => '♠',
700
+ 'ldots' => '…', 'cdots' => '⋯', 'vdots' => '⋮',
701
+ 'ddots' => '⋱', 'dots' => '…',
702
+ 'cong' => '≅', 'models' => '⊨', 'mid' => '∣',
703
+ 'nmid' => '∤', 'therefore' => '∴', 'because' => '∵',
704
+ 'neg' => '¬', 'lnot' => '¬', 'top' => '⊤', 'bot' => '⊥',
705
+ 'degree' => '°', 'prime' => '′', 'dag' => '†',
706
+ 'ddag' => '‡', 'S' => '§', 'P' => '¶',
707
+ 'pound' => '£', 'euro' => '€', 'yen' => '¥',
708
+ 'copyright' => '©', 'circledR' => '®',
709
+ # Delimiters – strip LaTeX wrapper
710
+ 'left' => '', 'right' => '', 'bigl' => '', 'bigr' => '',
711
+ 'Bigl' => '', 'Bigr' => '', 'biggl' => '', 'biggr' => '',
712
+ # Arrows special
713
+ 'gets' => '←',
714
+ # Text sub/sup scripts
715
+ 'text' => '',
716
+ }.freeze
717
+
718
+ # Commands whose argument should be preserved verbatim (e.g. \text{abc})
719
+ TEXT_LIKE = %w[text textrm textsf texttt textbf textit].freeze
720
+
721
+ SUPERSCRIPTS = {
722
+ '0' => '⁰', '1' => '¹', '2' => '²', '3' => '³', '4' => '⁴',
723
+ '5' => '⁵', '6' => '⁶', '7' => '⁷', '8' => '⁸', '9' => '⁹',
724
+ '+' => '⁺', '-' => '⁻', '=' => '⁼', '(' => '⁽', ')' => '⁾',
725
+ 'i' => 'ⁱ', 'n' => 'ⁿ',
726
+ }.freeze
727
+
728
+ SUBSCRIPTS = {
729
+ '0' => '₀', '1' => '₁', '2' => '₂', '3' => '₃', '4' => '₄',
730
+ '5' => '₅', '6' => '₆', '7' => '₇', '8' => '₈', '9' => '₉',
731
+ '+' => '₊', '-' => '₋', '=' => '₌', '(' => '₍', ')' => '₎',
732
+ 'a' => 'ₐ', 'e' => 'ₑ', 'i' => 'ᵢ', 'j' => 'ⱼ',
733
+ 'n' => 'ₙ', 'x' => 'ₓ',
734
+ }.freeze
735
+
736
+ def self.convert(formula)
737
+ return formula if formula.nil? || formula.strip.empty?
738
+
739
+ result = formula.dup
740
+ result = process_frac(result)
741
+ result = process_sqrt(result)
742
+ result = process_cases(result)
743
+ result = process_scripts(result)
744
+ result = replace_symbols(result)
745
+ result = strip_delim_spacing(result)
746
+ result
747
+ end
748
+
749
+ # \frac{num}{den} → (num)/(den) or num/den when single-char
750
+ def self.process_frac(text)
751
+ text.gsub(/\\frac\s*\{([^{}]*(?:\{[^}]*\}[^{}]*)*)\}\s*\{([^{}]*(?:\{[^}]*\}[^{}]*)*)\}/) do
752
+ num = Regexp.last_match(1)
753
+ den = Regexp.last_match(2)
754
+ num_wrap = num.length > 1 ? "(#{num})" : num
755
+ den_wrap = den.length > 1 ? "(#{den})" : den
756
+ "#{num_wrap}/#{den_wrap}"
757
+ end
758
+ end
759
+
760
+ # \sqrt{x} → √(x) \sqrt[n]{x} → ⁿ√(x)
761
+ def self.process_sqrt(text)
762
+ text.gsub(/\\sqrt(?:\[([^\]]*)\])?\s*\{([^{}]*(?:\{[^}]*\}[^{}]*)*)\}/) do
763
+ degree = Regexp.last_match(1)
764
+ radicand = Regexp.last_match(2)
765
+ prefix = degree ? script_chars(degree, SUPERSCRIPTS) : ''
766
+ "#{prefix}√(#{radicand})"
767
+ end
768
+ end
769
+
770
+ # \begin{cases} ... \end{cases} → ⎧ … ⎨ … ⎩ …
771
+ def self.process_cases(text)
772
+ text.gsub(/\\begin\{cases\}(.*?)\\end\{cases\}/m) do
773
+ body = Regexp.last_match(1).strip
774
+ lines = body.split('\\\\').map(&:strip).reject(&:empty?)
775
+ return '{}' if lines.empty?
776
+ out = +""
777
+ lines.each_with_index do |line, i|
778
+ leader = case i
779
+ when 0 then '⎧'
780
+ when lines.length - 1 then '⎩'
781
+ else '⎨'
782
+ end
783
+ out << "#{leader} #{line.gsub('&', '')}\n"
784
+ end
785
+ out.strip
786
+ end
787
+ end
788
+
789
+ # ^{x} / _{x} → Unicode super/subscript
790
+ def self.process_scripts(text)
791
+ # ^{...}
792
+ text = text.gsub(/\^\{([^}]+)\}/) {
793
+ inner = Regexp.last_match(1)
794
+ inner.include?('\\') ? "^\{#{inner}\}" : script_chars(inner, SUPERSCRIPTS)
795
+ }
796
+ # _{...}
797
+ text = text.gsub(/_\{([^}]+)\}/) {
798
+ inner = Regexp.last_match(1)
799
+ inner.include?('\\') ? "_\{#{inner}\}" : script_chars(inner, SUBSCRIPTS)
800
+ }
801
+ # ^x (single non-whitespace char, not \ or {)
802
+ text = text.gsub(/\^([^\s\\{])/) { SUPERSCRIPTS[Regexp.last_match(1)] || "^#{Regexp.last_match(1)}" }
803
+ # _x (single non-whitespace char, not \ or {)
804
+ text = text.gsub(/_([^\s\\{])/) { SUBSCRIPTS[Regexp.last_match(1)] || "_#{Regexp.last_match(1)}" }
805
+ text
806
+ end
807
+
808
+ def self.script_chars(str, map)
809
+ str.each_char.map { |c| map[c] || c }.join
810
+ end
811
+
812
+ # Replace \command tokens with Unicode equivalents.
813
+ def self.replace_symbols(text)
814
+ # Handle \text{…} first – keep content, remove wrapper
815
+ text = text.gsub(/\\(text\w*)\s*\{(.*?)\}/) { Regexp.last_match(2) }
816
+ # Replace all other \commands
817
+ text.gsub(/\\([a-zA-Z]+)/) { |m|
818
+ SYMBOLS[Regexp.last_match(1)] || m
819
+ }
820
+ end
821
+
822
+ # Remove stray spaces inserted by \left / \right.
823
+ def self.strip_delim_spacing(text)
824
+ text.gsub(/\(\s+/, '(').gsub(/\s+\)/, ')')
825
+ .gsub(/\[\s+/, '[').gsub(/\s+\]/, ']')
826
+ .gsub(/\{\s+/, '{').gsub(/\s+\}/, '}')
827
+ .gsub(/\\s+/, ' ')
828
+ end
829
+ end
830
+
831
+ # ---- Mermaid diagram renderer ----
832
+ # Renders pie charts inline; other diagram types show source with a
833
+ # hint to install `mmdc` for full rendering.
834
+ module MermaidRenderer
835
+ BAR_MAX = 32
836
+
837
+ def self.render(source, width = 80)
838
+ trimmed = source.strip
839
+ return "" if trimmed.empty?
840
+
841
+ type = detect_type(trimmed)
842
+ case type
843
+ when :pie
844
+ render_pie(trimmed, width)
845
+ else
846
+ render_fallback(trimmed, type, width)
847
+ end
848
+ end
849
+
850
+ def self.detect_type(source)
851
+ first = source.lines.first&.strip&.downcase || ""
852
+ return :pie if first.start_with?("pie")
853
+ return :flowchart if first.start_with?("flowchart") || first.start_with?("graph")
854
+ return :sequence if first.start_with?("sequencediagram")
855
+ return :class if first.start_with?("classdiagram")
856
+ return :gantt if first.start_with?("gantt")
857
+ return :state if first.start_with?("statediagram")
858
+ :generic
859
+ end
860
+
861
+ # Pie chart → horizontal bar chart with percentage labels.
862
+ def self.render_pie(source, width)
863
+ title = ""
864
+ entries = []
865
+ source.each_line do |line|
866
+ line = line.strip
867
+ next if line.empty?
868
+ if line.downcase.start_with?("pie")
869
+ rest = line[3..].strip
870
+ if rest.downcase.start_with?("title")
871
+ title = rest[5..].strip
872
+ end
873
+ next
874
+ end
875
+ if line.downcase.start_with?("title")
876
+ title = line[5..].strip
877
+ next
878
+ end
879
+ # Parse "label" : value
880
+ label_part, value_part = line.split(":", 2).map(&:strip)
881
+ next unless label_part && value_part
882
+ label = label_part.delete_prefix('"').delete_suffix('"')
883
+ value = value_part.to_f
884
+ entries << [label, value] if value > 0
885
+ end
886
+
887
+ return "[Mermaid pie: no data]" if entries.empty?
888
+
889
+ total = entries.sum { |_l, v| v }
890
+ return "[Mermaid pie: total is zero]" if total <= 0
891
+
892
+ max_label = entries.map { |l, _| l.length }.max
893
+ out = +""
894
+ out << "#{tc(:heading_3)}#{title}#{AnsiCode.reset}\n" unless title.empty?
895
+
896
+ entries.each do |label, value|
897
+ pct = value / total * 100.0
898
+ filled = (pct / 100.0 * BAR_MAX).round
899
+ half = (pct / 100.0 * BAR_MAX * 2).round % 2 == 1
900
+ bar = "█" * filled + (half ? "▌" : "")
901
+ out << sprintf("%-#{BAR_MAX + 1}s %-#{max_label}s %5.1f%%\n", bar, label, pct)
902
+ end
903
+
904
+ out.strip
905
+ end
906
+
907
+ # Fallback: show diagram source with a labelled header.
908
+ def self.render_fallback(source, type, width)
909
+ label = type.to_s.capitalize
910
+ pad = (width - label.length - 2).clamp(2, 60)
911
+ lines = source.lines.map(&:chomp)
912
+ [
913
+ "#{tc(:code_border)}┌─ #{label} #{'─' * pad}┐#{AnsiCode.reset}",
914
+ *lines.map { |l| "#{tc(:code_border)}│#{AnsiCode.reset} #{l}" },
915
+ "#{tc(:code_border)}└#{'─' * (width - 2)}┘#{AnsiCode.reset}",
916
+ "#{tc(:muted || :heading_4_6)}Install mmdc (npm i -g @mermaid-js/mermaid-cli) for full diagram rendering.#{AnsiCode.reset}",
917
+ ].join("\n")
918
+ end
919
+
920
+ # Proxy theme colour access (same instance as TerminalConverter).
921
+ def self.tc(key)
922
+ color, bright = MarkdownTheme[key]
923
+ AnsiCode.color(color, bright)
924
+ end
925
+
926
+ def self.AnsiCode
927
+ ::RubyRich::AnsiCode
357
928
  end
358
929
  end
359
930
 
@@ -369,6 +940,77 @@ module RubyRich
369
940
  end
370
941
  end
371
942
 
943
+ # ---- Frontmatter extraction ----
944
+ # Extracts YAML-style frontmatter (delimited by ---) and returns
945
+ # [content_without_fm, parsed_pairs, is_vertical].
946
+ module Frontmatter
947
+ VERTICAL_THRESHOLD = 5
948
+
949
+ def self.extract(markdown_text)
950
+ return [markdown_text, nil, false] unless markdown_text.start_with?("---\n")
951
+
952
+ rest = markdown_text[4..]
953
+ offset = 4
954
+ rest.each_line do |line|
955
+ if line == "---\n" || line == "...\n" || line == "---" || line == "..."
956
+ fm_block = markdown_text[4...offset]
957
+ content = markdown_text[(offset + line.length)..] || ""
958
+ pairs = parse_pairs(fm_block)
959
+ return [markdown_text, nil, false] if pairs.empty?
960
+ vertical = pairs.length >= VERTICAL_THRESHOLD
961
+ return [content, pairs, vertical]
962
+ end
963
+ offset += line.length
964
+ end
965
+ [markdown_text, nil, false]
966
+ end
967
+
968
+ def self.parse_pairs(block)
969
+ pairs = []
970
+ lines = block.lines.map(&:chomp)
971
+ i = 0
972
+ while i < lines.length
973
+ trimmed = lines[i].strip
974
+ i += 1 and next if trimmed.empty? || trimmed.start_with?('#')
975
+
976
+ colon_pos = trimmed.index(':')
977
+ i += 1 and next unless colon_pos
978
+
979
+ key = trimmed[0...colon_pos].strip
980
+ raw_value = trimmed[(colon_pos + 1)..].strip
981
+ i += 1 and next if key.empty?
982
+
983
+ if ["", ">-", ">", "|", "|-"].include?(raw_value)
984
+ # Multiline value
985
+ parts = []
986
+ while i < lines.length && lines[i].start_with?(' ', "\t")
987
+ part = lines[i].strip
988
+ parts << part unless part.empty?
989
+ i += 1
990
+ end
991
+ pairs << [key, parts.join(" ")]
992
+ elsif raw_value.empty?
993
+ # List value (indented items)
994
+ items = []
995
+ while i < lines.length && lines[i].start_with?(' ', "\t")
996
+ item = lines[i].strip
997
+ items << (item.start_with?("- ") ? item[2..].strip : item)
998
+ i += 1
999
+ end
1000
+ pairs << [key, items.empty? ? "" : items.join(", ")]
1001
+ else
1002
+ pairs << [key, unquote(raw_value)]
1003
+ i += 1
1004
+ end
1005
+ end
1006
+ pairs
1007
+ end
1008
+
1009
+ def self.unquote(s)
1010
+ (s.length >= 2 && ((s.start_with?('"') && s.end_with?('"')) || (s.start_with?("'") && s.end_with?("'")))) ? s[1...-1] : s
1011
+ end
1012
+ end
1013
+
372
1014
  # ---- 公开 API ----
373
1015
 
374
1016
  # 渲染 Markdown 文本为 ANSI 终端输出
@@ -388,6 +1030,26 @@ module RubyRich
388
1030
  table_border_style: options[:table_border_style] || :simple
389
1031
  }
390
1032
 
1033
+ # Pre-process frontmatter
1034
+ content, fm_pairs, fm_vertical = Frontmatter.extract(markdown_text)
1035
+
1036
+ # Pre-process inline math $...$ (kramdown needs a math-engine gem for this)
1037
+ math_color = AnsiCode.color(*MarkdownTheme[:math])
1038
+ content = content.gsub(/(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)/) do
1039
+ rendered = TerminalConverter::LatexConverter.convert(Regexp.last_match(1).strip)
1040
+ "#{math_color}#{rendered}#{AnsiCode.reset}"
1041
+ end
1042
+
1043
+ fm_output = ""
1044
+ if fm_pairs && !fm_pairs.empty?
1045
+ if fm_vertical
1046
+ # Vertical frontmatter: one column per key-value pair
1047
+ fm_output = render_frontmatter_vertical(fm_pairs, converter_options)
1048
+ else
1049
+ fm_output = render_frontmatter_horizontal(fm_pairs, converter_options)
1050
+ end
1051
+ end
1052
+
391
1053
  kramdown_opts = {
392
1054
  input: 'GFM', # GitHub Flavored Markdown
393
1055
  syntax_highlighter: nil, # 自行处理语法高亮
@@ -396,9 +1058,31 @@ module RubyRich
396
1058
  line_width: converter_options[:width]
397
1059
  }.merge(options[:kramdown] || {})
398
1060
 
399
- doc = Kramdown::Document.new(markdown_text, kramdown_opts)
1061
+ doc = Kramdown::Document.new(content, kramdown_opts)
400
1062
  result, _warnings = TerminalConverter.convert(doc.root, converter_options)
401
- result
1063
+ "#{fm_output}#{result}"
1064
+ end
1065
+
1066
+ # Render frontmatter as a vertical key-value table (many pairs).
1067
+ def self.render_frontmatter_vertical(pairs, opts)
1068
+ tbl = RubyRich::Table.new(
1069
+ headers: %w[Key Value],
1070
+ border_style: opts[:table_border_style] || :simple
1071
+ )
1072
+ pairs.each { |k, v| tbl.add_row([k, v]) }
1073
+ "#{tbl.render}\n\n"
1074
+ end
1075
+
1076
+ # Render frontmatter as a horizontal 2-row table (few pairs).
1077
+ def self.render_frontmatter_horizontal(pairs, opts)
1078
+ keys = pairs.map(&:first)
1079
+ vals = pairs.map(&:last)
1080
+ tbl = RubyRich::Table.new(
1081
+ headers: keys,
1082
+ border_style: opts[:table_border_style] || :simple
1083
+ )
1084
+ tbl.add_row(vals)
1085
+ "#{tbl.render}\n\n"
402
1086
  end
403
1087
 
404
1088
  def initialize(options = {})
@@ -409,4 +1093,4 @@ module RubyRich
409
1093
  self.class.render(markdown_text, @options)
410
1094
  end
411
1095
  end
412
- end
1096
+ end
@@ -126,13 +126,13 @@ module RubyRich
126
126
  border_chars = BORDER_STYLES[@border_style]
127
127
 
128
128
  row_content = row.map.with_index do |cell, i|
129
- rendered = cell.render
129
+ rendered = cell.render.sub(/\e\[0m\z/, '')
130
130
  content = bold ? rendered : align_cell(rendered, column_widths[i])
131
- aligned_content = align_cell(content, column_widths[i])
131
+ aligned_content = align_cell(content, column_widths[i]).sub(/\e\[0m\z/, '')
132
132
  " #{aligned_content} "
133
133
  end.join(border_chars[:vertical])
134
134
 
135
- ["#{border_chars[:left]}#{row_content}#{border_chars[:right]}"]
135
+ ["#{border_chars[:left]}#{row_content}#{border_chars[:right]}\e[0m"]
136
136
  end
137
137
 
138
138
  def render_styled_multiline_row(row, column_widths)
@@ -213,10 +213,10 @@ module RubyRich
213
213
 
214
214
  def render_row(row, column_widths, bold: false)
215
215
  row.map.with_index do |cell, i|
216
- rendered = cell.render
216
+ rendered = cell.render.sub(/\e\[0m\z/, '')
217
217
  content = bold ? rendered : align_cell(rendered, column_widths[i])
218
- align_cell(content, column_widths[i])
219
- end.join(" | ").prepend("| ").concat(" |")
218
+ align_cell(content, column_widths[i]).sub(/\e\[0m\z/, '')
219
+ end.join(" | ").prepend("| ").concat(" |\e[0m")
220
220
  end
221
221
 
222
222
  def render_multiline_row(row, column_widths)
@@ -254,7 +254,7 @@ module RubyRich
254
254
 
255
255
  # Render each line of the row
256
256
  (0...max_height).map do |line_index|
257
- row_lines.map { |lines| lines[line_index] }.join(" | ").prepend("| ").concat(" |")
257
+ row_lines.map { |lines| lines[line_index].sub(/\e\[0m\z/, '') }.join(" | ").prepend("| ").concat(" |\e[0m")
258
258
  end
259
259
  end
260
260
 
@@ -263,7 +263,7 @@ module RubyRich
263
263
  end
264
264
 
265
265
  def align_cell(content, width)
266
- style_sequences = content.scan(/\e\[[0-9;]*m/)
266
+ style_sequences = content.scan(/\e\[[0-9;]*m/).reject { |s| s == "\e[0m" }
267
267
  plain_content = content.gsub(/\e\[[0-9;]*m/, '')
268
268
 
269
269
  # 计算实际显示宽度
@@ -282,7 +282,7 @@ module RubyRich
282
282
  end
283
283
 
284
284
  if style_sequences.any?
285
- style_sequences.first + padded_content + "\e[0m"
285
+ style_sequences.first + padded_content
286
286
  else
287
287
  padded_content
288
288
  end
@@ -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: " ")
@@ -1,3 +1,3 @@
1
1
  module RubyRich
2
- VERSION = "0.4.8"
2
+ VERSION = "0.5.0"
3
3
  end
@@ -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
- def initialize(content = "", scrollbar: true, auto_scroll: false, scrollbar_style: :blue)
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
- start_scrollbar_drag(event_data, layout) || start_viewport_drag(event_data, layout)
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) || drag_viewport(event_data, layout) || drag_selection(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
- if RubyRich::Terminal.windows?
536
- copy_to_windows_clipboard(text)
537
- elsif ENV["WAYLAND_DISPLAY"]
538
- IO.popen("wl-copy", "w") { |io| io.write(text) }
539
- elsif ENV["DISPLAY"]
540
- IO.popen("xclip -selection clipboard", "w") { |io| io.write(text) }
541
- elsif RUBY_PLATFORM.match?(/darwin/)
542
- IO.popen("pbcopy", "w") { |io| io.write(text) }
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
- nil
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_rich
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.8
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhuang biaowei