ruby_rich 0.3.1 → 0.4.1

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,200 @@
1
+ module RubyRich
2
+ class Tree
3
+ # 树形结构显示的字符集
4
+ TREE_CHARS = {
5
+ default: {
6
+ vertical: '│',
7
+ horizontal: '─',
8
+ branch: '├',
9
+ last: '└',
10
+ space: ' '
11
+ },
12
+ ascii: {
13
+ vertical: '|',
14
+ horizontal: '-',
15
+ branch: '+',
16
+ last: '+',
17
+ space: ' '
18
+ },
19
+ rounded: {
20
+ vertical: '│',
21
+ horizontal: '─',
22
+ branch: '├',
23
+ last: '└',
24
+ space: ' '
25
+ },
26
+ double: {
27
+ vertical: '║',
28
+ horizontal: '═',
29
+ branch: '╠',
30
+ last: '╚',
31
+ space: ' '
32
+ }
33
+ }.freeze
34
+
35
+ # 树节点类
36
+ class Node
37
+ attr_accessor :name, :children, :data, :expanded
38
+
39
+ def initialize(name, data: nil)
40
+ @name = name
41
+ @children = []
42
+ @data = data
43
+ @expanded = true
44
+ end
45
+
46
+ def add_child(child_node)
47
+ @children << child_node
48
+ child_node
49
+ end
50
+
51
+ def add(name, data: nil)
52
+ child = Node.new(name, data: data)
53
+ add_child(child)
54
+ child
55
+ end
56
+
57
+ def leaf?
58
+ @children.empty?
59
+ end
60
+
61
+ def has_children?
62
+ !@children.empty?
63
+ end
64
+ end
65
+
66
+ attr_reader :root, :style
67
+
68
+ def initialize(root_name = 'Root', style: :default)
69
+ @root = Node.new(root_name)
70
+ @style = style
71
+ @chars = TREE_CHARS[@style] || TREE_CHARS[:default]
72
+ end
73
+
74
+ # 从哈希构建树
75
+ def self.from_hash(hash, root_name = 'Root', style: :default)
76
+ tree = new(root_name, style: style)
77
+ build_from_hash(tree.root, hash)
78
+ tree
79
+ end
80
+
81
+ # 从文件路径构建树
82
+ def self.from_paths(paths, root_name = 'Root', style: :default)
83
+ tree = new(root_name, style: style)
84
+
85
+ paths.each do |path|
86
+ parts = path.split('/')
87
+ current_node = tree.root
88
+
89
+ parts.each do |part|
90
+ next if part.empty?
91
+
92
+ # 查找是否已存在该子节点
93
+ existing_child = current_node.children.find { |child| child.name == part }
94
+
95
+ if existing_child
96
+ current_node = existing_child
97
+ else
98
+ current_node = current_node.add(part)
99
+ end
100
+ end
101
+ end
102
+
103
+ tree
104
+ end
105
+
106
+ # 添加节点到根节点
107
+ def add(name, data: nil)
108
+ @root.add(name, data: data)
109
+ end
110
+
111
+ # 渲染树形结构
112
+ def render(show_guides: true, colors: true)
113
+ lines = []
114
+ render_node(@root, '', true, lines, show_guides, colors)
115
+ lines.join("\n")
116
+ end
117
+
118
+ # 渲染为字符串(别名)
119
+ def to_s
120
+ render
121
+ end
122
+
123
+ private
124
+
125
+ def self.build_from_hash(node, hash)
126
+ hash.each do |key, value|
127
+ child = node.add(key.to_s)
128
+ if value.is_a?(Hash)
129
+ build_from_hash(child, value)
130
+ elsif value.is_a?(Array)
131
+ value.each_with_index do |item, index|
132
+ if item.is_a?(Hash)
133
+ item_child = child.add("[#{index}]")
134
+ build_from_hash(item_child, item)
135
+ else
136
+ child.add(item.to_s)
137
+ end
138
+ end
139
+ else
140
+ child.add(value.to_s) unless value.nil?
141
+ end
142
+ end
143
+ end
144
+
145
+ def render_node(node, prefix, is_last, lines, show_guides, colors)
146
+ # 根节点特殊处理
147
+ if node == @root
148
+ if colors
149
+ lines << "\e[1m\e[96m#{node.name}\e[0m"
150
+ else
151
+ lines << node.name
152
+ end
153
+
154
+ node.children.each_with_index do |child, index|
155
+ is_child_last = (index == node.children.length - 1)
156
+ render_node(child, '', is_child_last, lines, show_guides, colors)
157
+ end
158
+ return
159
+ end
160
+
161
+ # 构建当前行的前缀和连接符
162
+ if show_guides
163
+ connector = is_last ? @chars[:last] : @chars[:branch]
164
+ current_prefix = prefix + connector + @chars[:horizontal] + @chars[:space]
165
+ else
166
+ current_prefix = prefix + @chars[:space] * 4
167
+ end
168
+
169
+ # 渲染当前节点
170
+ node_text = if colors
171
+ case
172
+ when node.leaf?
173
+ "\e[92m#{node.name}\e[0m" # 绿色叶子节点
174
+ when node.has_children?
175
+ "\e[94m#{node.name}/\e[0m" # 蓝色目录节点
176
+ else
177
+ node.name
178
+ end
179
+ else
180
+ node.leaf? ? node.name : "#{node.name}/"
181
+ end
182
+
183
+ lines << current_prefix + node_text
184
+
185
+ # 递归渲染子节点
186
+ if node.expanded && node.has_children?
187
+ next_prefix = if show_guides
188
+ prefix + (is_last ? @chars[:space] : @chars[:vertical]) + @chars[:space] * 3
189
+ else
190
+ prefix + @chars[:space] * 4
191
+ end
192
+
193
+ node.children.each_with_index do |child, index|
194
+ is_child_last = (index == node.children.length - 1)
195
+ render_node(child, next_prefix, is_child_last, lines, show_guides, colors)
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -1,3 +1,3 @@
1
1
  module RubyRich
2
- VERSION = "0.3.1"
2
+ VERSION = "0.4.1"
3
3
  end
@@ -0,0 +1,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyRich
4
+ class Viewport
5
+ attr_accessor :width, :height, :scroll_top
6
+ attr_reader :content, :selected_text
7
+
8
+ def initialize(content = "", scrollbar: true, auto_scroll: false, scrollbar_style: :blue)
9
+ @content = content
10
+ @scrollbar = scrollbar
11
+ @auto_scroll = auto_scroll
12
+ @scrollbar_style = scrollbar_style
13
+ @width = 0
14
+ @height = 0
15
+ @scroll_top = 0
16
+ @dragging_scrollbar = false
17
+ @drag_start_y = nil
18
+ @drag_start_scroll_top = nil
19
+ @selecting = false
20
+ @selection_start = nil
21
+ @selection_end = nil
22
+ @selected_text = ""
23
+ @focused = true
24
+ end
25
+
26
+ def focus
27
+ @focused = true
28
+ self
29
+ end
30
+
31
+ def blur
32
+ @focused = false
33
+ self
34
+ end
35
+
36
+ def content=(new_content)
37
+ was_at_bottom = at_bottom?
38
+ @content = new_content
39
+ scroll_to_bottom if @auto_scroll && was_at_bottom
40
+ clamp_scroll
41
+ end
42
+
43
+ def attach(layout, priority: 100)
44
+ [:page_up, :page_down, :home, :end, :up, :down].each do |event_name|
45
+ layout.key(event_name, priority) do |event_data, _live|
46
+ handle_event(event_data, layout)
47
+ false
48
+ end
49
+ end
50
+
51
+ [:mouse_wheel, :mouse_down, :mouse_drag, :mouse_up].each do |event_name|
52
+ layout.key(event_name, priority) do |event_data, _live|
53
+ handle_event(event_data, layout)
54
+ end
55
+ end
56
+
57
+ self
58
+ end
59
+
60
+ def handle_event(event_data, layout = nil)
61
+ return false if keyboard_event?(event_data) && !@focused
62
+ return false if mouse_event?(event_data) && !@focused
63
+
64
+ case event_data[:name]
65
+ when :page_up
66
+ scroll_by(-page_size)
67
+ false
68
+ when :page_down
69
+ scroll_by(page_size)
70
+ false
71
+ when :home
72
+ scroll_to(0)
73
+ false
74
+ when :end
75
+ scroll_to_bottom
76
+ false
77
+ when :up
78
+ scroll_by(-1)
79
+ false
80
+ when :down
81
+ scroll_by(1)
82
+ false
83
+ when :mouse_wheel
84
+ scroll_by(event_data[:direction] == :down ? 3 : -3)
85
+ true
86
+ when :mouse_down
87
+ return copy_selection if event_data[:button] == :right
88
+
89
+ start_scrollbar_drag(event_data, layout) || start_selection(event_data, layout)
90
+ when :mouse_drag
91
+ drag_scrollbar(event_data, layout) || drag_selection(event_data, layout)
92
+ when :mouse_up
93
+ stop_scrollbar_drag || stop_selection
94
+ else
95
+ false
96
+ end
97
+ end
98
+
99
+ def render
100
+ clamp_scroll
101
+ return [] if @height.to_i <= 0 || @width.to_i <= 0
102
+
103
+ visible_width = content_width
104
+ visible = rendered_lines[@scroll_top, @height] || []
105
+ lines = Array.new(@height) { "" }
106
+
107
+ @height.times do |index|
108
+ absolute_line = @scroll_top + index
109
+ lines[index] = apply_selection(fit_line(visible[index].to_s, visible_width), absolute_line)
110
+ end
111
+
112
+ return lines unless show_scrollbar?
113
+
114
+ scrollbar = render_scrollbar
115
+ lines.each_with_index.map { |line, index| line + scrollbar[index] }
116
+ end
117
+
118
+ def scroll_by(delta)
119
+ scroll_to(@scroll_top + delta)
120
+ end
121
+
122
+ def scroll_to(line)
123
+ @scroll_top = line.to_i
124
+ clamp_scroll
125
+ end
126
+
127
+ def scroll_to_bottom
128
+ @scroll_top = max_scroll_top
129
+ clamp_scroll
130
+ end
131
+
132
+ def at_bottom?
133
+ @scroll_top >= max_scroll_top
134
+ end
135
+
136
+ def max_scroll_top
137
+ [rendered_lines.length - @height, 0].max
138
+ end
139
+
140
+ private
141
+
142
+ def rendered_lines
143
+ @rendered_lines = normalize_lines(@content)
144
+ end
145
+
146
+ def keyboard_event?(event_data)
147
+ event_data[:type] == :key
148
+ end
149
+
150
+ def mouse_event?(event_data)
151
+ event_data[:type] == :mouse
152
+ end
153
+
154
+ def normalize_lines(value)
155
+ value.width = content_width if value.respond_to?(:width=)
156
+
157
+ rendered = if value.respond_to?(:render)
158
+ value.render
159
+ else
160
+ value
161
+ end
162
+
163
+ case rendered
164
+ when String
165
+ rendered.split("\n")
166
+ when Array
167
+ rendered
168
+ else
169
+ rendered.to_s.split("\n")
170
+ end
171
+ end
172
+
173
+ def page_size
174
+ [@height - 1, 1].max
175
+ end
176
+
177
+ def content_width
178
+ @scrollbar ? [@width - 1, 1].max : @width
179
+ end
180
+
181
+ def show_scrollbar?
182
+ @scrollbar && @width.positive?
183
+ end
184
+
185
+ def render_scrollbar
186
+ lines = Array.new(@height) { track_char }
187
+ return lines if @height <= 0
188
+
189
+ thumb_size.times do |offset|
190
+ index = thumb_top + offset
191
+ lines[index] = thumb_char if index.between?(0, @height - 1)
192
+ end
193
+
194
+ lines
195
+ end
196
+
197
+ def thumb_size
198
+ total = rendered_lines.length
199
+ return @height if total <= @height
200
+
201
+ [(@height.to_f * @height / total).ceil, 1].max
202
+ end
203
+
204
+ def thumb_top
205
+ total_scroll = max_scroll_top
206
+ return 0 if total_scroll.zero?
207
+
208
+ travel = [@height - thumb_size, 0].max
209
+ (@scroll_top.to_f / total_scroll * travel).round
210
+ end
211
+
212
+ def track_char
213
+ "#{AnsiCode.color(:black, true)}│#{AnsiCode.reset}"
214
+ end
215
+
216
+ def thumb_char
217
+ "#{AnsiCode.color(@scrollbar_style, true)}│#{AnsiCode.reset}"
218
+ end
219
+
220
+ def fit_line(line, target_width)
221
+ plain_width = display_width(line)
222
+ return line + (" " * (target_width - plain_width)) if plain_width <= target_width
223
+
224
+ truncate_display(line, target_width)
225
+ end
226
+
227
+ def truncate_display(line, target_width)
228
+ result = +""
229
+ width = 0
230
+ in_escape = false
231
+ escape = +""
232
+
233
+ line.each_char do |char|
234
+ if in_escape
235
+ escape << char
236
+ if char == "m"
237
+ result << escape
238
+ escape = +""
239
+ in_escape = false
240
+ end
241
+ next
242
+ elsif char.ord == 27
243
+ escape << char
244
+ in_escape = true
245
+ next
246
+ end
247
+
248
+ char_width = Unicode::DisplayWidth.of(char)
249
+ break if width + char_width > target_width
250
+
251
+ result << char
252
+ width += char_width
253
+ end
254
+
255
+ result
256
+ end
257
+
258
+ def display_width(line)
259
+ line.gsub(/\e\[[0-9;:]*m/, "").chars.sum { |char| Unicode::DisplayWidth.of(char) }
260
+ end
261
+
262
+ def clamp_scroll
263
+ @scroll_top = [[@scroll_top, 0].max, max_scroll_top].min
264
+ end
265
+
266
+ def start_scrollbar_drag(event_data, layout)
267
+ return false unless layout
268
+ return false unless event_data[:x] == layout.x_offset + @width - 1
269
+
270
+ @dragging_scrollbar = true
271
+ @drag_start_y = event_data[:y]
272
+ @drag_start_scroll_top = @scroll_top
273
+ scroll_to(scroll_top_for_y(event_data[:y], layout))
274
+ true
275
+ end
276
+
277
+ def start_selection(event_data, layout)
278
+ return false unless layout
279
+ return false if event_data[:x] >= layout.x_offset + content_width
280
+ return false unless event_data[:y].between?(layout.y_offset, layout.y_offset + @height - 1)
281
+
282
+ @selecting = true
283
+ @selection_start = mouse_position(event_data, layout)
284
+ @selection_end = @selection_start
285
+ @selected_text = ""
286
+ true
287
+ end
288
+
289
+ def drag_scrollbar(event_data, layout)
290
+ return false unless @dragging_scrollbar && layout
291
+
292
+ if @drag_start_y
293
+ travel = [@height - thumb_size, 1].max
294
+ scroll_travel = max_scroll_top
295
+ delta_y = event_data[:y] - @drag_start_y
296
+ scroll_to(@drag_start_scroll_top + (delta_y.to_f / travel * scroll_travel).round)
297
+ else
298
+ scroll_to(scroll_top_for_y(event_data[:y], layout))
299
+ end
300
+
301
+ true
302
+ end
303
+
304
+ def drag_selection(event_data, layout)
305
+ return false unless @selecting && layout
306
+
307
+ @selection_end = mouse_position(event_data, layout)
308
+ true
309
+ end
310
+
311
+ def stop_scrollbar_drag
312
+ was_dragging = @dragging_scrollbar
313
+ @dragging_scrollbar = false
314
+ @drag_start_y = nil
315
+ @drag_start_scroll_top = nil
316
+ was_dragging
317
+ end
318
+
319
+ def stop_selection
320
+ return false unless @selecting
321
+
322
+ @selecting = false
323
+ @selected_text = extract_selected_text
324
+ copy_selection
325
+ true
326
+ end
327
+
328
+ def copy_selection
329
+ return false if @selected_text.to_s.empty?
330
+
331
+ copy_to_clipboard(@selected_text)
332
+ true
333
+ end
334
+
335
+ def scroll_top_for_y(y, layout)
336
+ relative_y = [[y - layout.y_offset, 0].max, @height - 1].min
337
+ travel = [@height - thumb_size, 1].max
338
+ (relative_y.to_f / travel * max_scroll_top).round
339
+ end
340
+
341
+ def mouse_position(event_data, layout)
342
+ {
343
+ line: @scroll_top + [[event_data[:y] - layout.y_offset, 0].max, @height - 1].min,
344
+ col: [[event_data[:x] - layout.x_offset, 0].max, content_width].min
345
+ }
346
+ end
347
+
348
+ def normalized_selection
349
+ return nil unless @selection_start && @selection_end
350
+
351
+ a = @selection_start
352
+ b = @selection_end
353
+ if a[:line] < b[:line] || (a[:line] == b[:line] && a[:col] <= b[:col])
354
+ [a, b]
355
+ else
356
+ [b, a]
357
+ end
358
+ end
359
+
360
+ def apply_selection(line, absolute_line)
361
+ range = normalized_selection
362
+ return line unless range
363
+
364
+ start_pos, end_pos = range
365
+ return line if absolute_line < start_pos[:line] || absolute_line > end_pos[:line]
366
+
367
+ start_col = absolute_line == start_pos[:line] ? start_pos[:col] : 0
368
+ end_col = absolute_line == end_pos[:line] ? end_pos[:col] : content_width
369
+ highlight_display_range(line, start_col, end_col)
370
+ end
371
+
372
+ def highlight_display_range(line, start_col, end_col)
373
+ return line if end_col <= start_col
374
+
375
+ result = +""
376
+ width = 0
377
+ active = false
378
+ in_escape = false
379
+ escape = +""
380
+
381
+ line.each_char do |char|
382
+ if in_escape
383
+ escape << char
384
+ if char == "m"
385
+ result << escape
386
+ escape = +""
387
+ in_escape = false
388
+ end
389
+ next
390
+ elsif char.ord == 27
391
+ escape << char
392
+ in_escape = true
393
+ next
394
+ end
395
+
396
+ char_width = Unicode::DisplayWidth.of(char)
397
+ should_highlight = width < end_col && width + char_width > start_col
398
+ if should_highlight && !active
399
+ result << AnsiCode.inverse
400
+ active = true
401
+ elsif !should_highlight && active
402
+ result << AnsiCode.reset
403
+ active = false
404
+ end
405
+ result << char
406
+ width += char_width
407
+ end
408
+ result << AnsiCode.reset if active
409
+ result
410
+ end
411
+
412
+ def extract_selected_text
413
+ range = normalized_selection
414
+ return "" unless range
415
+
416
+ start_pos, end_pos = range
417
+ (start_pos[:line]..end_pos[:line]).map do |line_index|
418
+ line = strip_ansi(rendered_lines[line_index].to_s)
419
+ start_col = line_index == start_pos[:line] ? start_pos[:col] : 0
420
+ end_col = line_index == end_pos[:line] ? end_pos[:col] : display_width(line)
421
+ slice_display_range(line, start_col, end_col).rstrip
422
+ end.join("\n")
423
+ end
424
+
425
+ def slice_display_range(line, start_col, end_col)
426
+ result = +""
427
+ width = 0
428
+ line.each_char do |char|
429
+ char_width = Unicode::DisplayWidth.of(char)
430
+ result << char if width < end_col && width + char_width > start_col
431
+ width += char_width
432
+ end
433
+ result
434
+ end
435
+
436
+ def strip_ansi(text)
437
+ text.gsub(/\e\[[0-9;:]*m/, "")
438
+ end
439
+
440
+ def copy_to_clipboard(text)
441
+ if RubyRich::Terminal.windows?
442
+ copy_to_windows_clipboard(text)
443
+ elsif ENV["WAYLAND_DISPLAY"]
444
+ IO.popen("wl-copy", "w") { |io| io.write(text) }
445
+ elsif ENV["DISPLAY"]
446
+ IO.popen("xclip -selection clipboard", "w") { |io| io.write(text) }
447
+ elsif RUBY_PLATFORM.match?(/darwin/)
448
+ IO.popen("pbcopy", "w") { |io| io.write(text) }
449
+ end
450
+ rescue IOError, SystemCallError
451
+ nil
452
+ end
453
+
454
+ def copy_to_windows_clipboard(text)
455
+ script = "[Console]::InputEncoding=[System.Text.UTF8Encoding]::new($false); Set-Clipboard -Value ([Console]::In.ReadToEnd())"
456
+ IO.popen(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], "w") do |io|
457
+ io.set_encoding(Encoding::UTF_8)
458
+ io.write(text.to_s.encode(Encoding::UTF_8))
459
+ end
460
+ rescue IOError, SystemCallError
461
+ IO.popen("clip", "w") do |io|
462
+ io.binmode
463
+ io.write("\uFEFF".encode("UTF-16LE"))
464
+ io.write(text.to_s.encode("UTF-16LE"))
465
+ end
466
+ end
467
+ end
468
+ end