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.
- checksums.yaml +4 -4
- data/lib/ruby_rich/agent_shell.rb +254 -0
- data/lib/ruby_rich/ansi_code.rb +46 -0
- data/lib/ruby_rich/app_shell.rb +374 -0
- data/lib/ruby_rich/attachment.rb +25 -0
- data/lib/ruby_rich/columns.rb +244 -0
- data/lib/ruby_rich/composer.rb +512 -0
- data/lib/ruby_rich/console.rb +191 -29
- data/lib/ruby_rich/dialog.rb +2 -1
- data/lib/ruby_rich/event.rb +29 -0
- data/lib/ruby_rich/focus_manager.rb +77 -0
- data/lib/ruby_rich/layout.rb +117 -29
- data/lib/ruby_rich/line_editor.rb +325 -0
- data/lib/ruby_rich/live.rb +100 -19
- data/lib/ruby_rich/markdown.rb +213 -0
- data/lib/ruby_rich/panel.rb +1 -1
- data/lib/ruby_rich/print.rb +6 -6
- data/lib/ruby_rich/progress_bar.rb +229 -11
- data/lib/ruby_rich/progress_manager.rb +150 -0
- data/lib/ruby_rich/sidebar.rb +85 -0
- data/lib/ruby_rich/slash_input.rb +197 -0
- data/lib/ruby_rich/status.rb +246 -0
- data/lib/ruby_rich/syntax.rb +171 -0
- data/lib/ruby_rich/table.rb +167 -21
- data/lib/ruby_rich/terminal.rb +510 -0
- data/lib/ruby_rich/text.rb +112 -2
- data/lib/ruby_rich/theme.rb +96 -0
- data/lib/ruby_rich/tool_block.rb +92 -0
- data/lib/ruby_rich/transcript.rb +553 -0
- data/lib/ruby_rich/tree.rb +200 -0
- data/lib/ruby_rich/version.rb +1 -1
- data/lib/ruby_rich/viewport.rb +468 -0
- data/lib/ruby_rich.rb +65 -10
- metadata +93 -3
|
@@ -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
|
data/lib/ruby_rich/version.rb
CHANGED
|
@@ -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
|