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,374 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyRich
|
|
4
|
+
class AppShell
|
|
5
|
+
attr_reader :layout, :transcript, :viewport, :sidebar, :composer, :focus_manager, :theme, :live, :token_usage, :progress_manager
|
|
6
|
+
|
|
7
|
+
DEFAULT_COMMANDS = [
|
|
8
|
+
{ label: "/help", value: "/help", description: "Show commands" },
|
|
9
|
+
{ label: "/plan", value: "/plan", description: "Append a plan note" },
|
|
10
|
+
{ label: "/thinking", value: "/thinking", description: "Add a thinking block" },
|
|
11
|
+
{ label: "/tool", value: "/tool", description: "Add a tool call" },
|
|
12
|
+
{ label: "/quit", value: "/quit", description: "Exit demo" }
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(title: "Agent", subtitle: nil, model: "deepseek-v4-pro", theme: Theme.agent_dark, commands: DEFAULT_COMMANDS, on_submit: nil)
|
|
16
|
+
@title = title
|
|
17
|
+
@subtitle = subtitle || "DeepSeek-TUI · #{model}"
|
|
18
|
+
@model = model
|
|
19
|
+
@theme = theme
|
|
20
|
+
@on_submit = on_submit
|
|
21
|
+
@status = "agent · #{model}"
|
|
22
|
+
@token_usage = nil
|
|
23
|
+
@progress_text = nil
|
|
24
|
+
|
|
25
|
+
@transcript = Transcript.new
|
|
26
|
+
@progress_manager = ProgressManager.new(on_change: ->(text) { @progress_text = text })
|
|
27
|
+
@viewport = Viewport.new(@transcript, scrollbar: true, auto_scroll: true)
|
|
28
|
+
@sidebar = Sidebar.new
|
|
29
|
+
@composer = Composer.new(
|
|
30
|
+
placeholder: "编写任务或使用 /。",
|
|
31
|
+
commands: commands,
|
|
32
|
+
on_submit: method(:handle_submit),
|
|
33
|
+
on_select: method(:handle_select)
|
|
34
|
+
)
|
|
35
|
+
@focus_manager = FocusManager.new
|
|
36
|
+
@layout = build_layout
|
|
37
|
+
attach_components
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def add_user(text)
|
|
41
|
+
@transcript.add_user(text)
|
|
42
|
+
@viewport.scroll_to_bottom
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def add_assistant(text)
|
|
47
|
+
@transcript.add_assistant(text)
|
|
48
|
+
@viewport.scroll_to_bottom
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def add_thinking(text, status: "idle", collapsed: true)
|
|
53
|
+
@transcript.add_thinking(text, status: status, collapsed: collapsed)
|
|
54
|
+
@viewport.scroll_to_bottom
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def add_tool(name, status: :running, result: nil, collapsed: false)
|
|
59
|
+
@transcript.add_tool(name, status: status, result: result, collapsed: collapsed)
|
|
60
|
+
@viewport.scroll_to_bottom
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def add_separator(label = nil)
|
|
65
|
+
@transcript.add_separator(label)
|
|
66
|
+
@viewport.scroll_to_bottom
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def add_markdown(text)
|
|
71
|
+
@transcript.add_markdown(text)
|
|
72
|
+
@viewport.scroll_to_bottom
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def add_diff(title: nil, content:, language: "diff")
|
|
77
|
+
label = title ? "#{title}\n#{content}" : content
|
|
78
|
+
@transcript.add_block(:diff, label, language: language)
|
|
79
|
+
@viewport.scroll_to_bottom
|
|
80
|
+
self
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def update_plan(text)
|
|
84
|
+
@sidebar.update_plan(text)
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def set_tasks(tasks)
|
|
89
|
+
@sidebar.set_tasks(tasks)
|
|
90
|
+
self
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def status=(text)
|
|
94
|
+
@status = text.to_s
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def update_status(text)
|
|
98
|
+
self.status = text
|
|
99
|
+
self
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def show_token_usage(input: nil, output: nil, total: nil, **extra)
|
|
103
|
+
@token_usage = { input: input, output: output, total: total }.merge(extra).compact
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def start_progress(message = nil, owner: Thread.current.object_id, style: :primary, quiet_on_fast_finish: false)
|
|
108
|
+
_ = style
|
|
109
|
+
_ = quiet_on_fast_finish
|
|
110
|
+
@progress_manager.start(message, owner: owner)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def with_progress(message = nil, style: :primary, quiet_on_fast_finish: false, &block)
|
|
114
|
+
_ = style
|
|
115
|
+
_ = quiet_on_fast_finish
|
|
116
|
+
@progress_manager.with_progress(message, &block)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def confirm(title:, message:, choices:, default: nil, &callback)
|
|
120
|
+
result = default || choices.first&.fetch(:key)
|
|
121
|
+
callback.call(result) if callback
|
|
122
|
+
result
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def form(title:, fields:, &callback)
|
|
126
|
+
values = {}
|
|
127
|
+
fields.each do |field|
|
|
128
|
+
name = field.fetch(:name).to_sym
|
|
129
|
+
values[name] = field.key?(:default) ? field[:default] : default_field_value(field)
|
|
130
|
+
end
|
|
131
|
+
callback.call(values) if callback
|
|
132
|
+
values
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def open_pager(text, command: ENV.fetch("PAGER", "less -R"))
|
|
136
|
+
Terminal.with_cooked(mouse: true) do
|
|
137
|
+
IO.popen(command, "w") { |io| io.write(text.to_s) }
|
|
138
|
+
end
|
|
139
|
+
true
|
|
140
|
+
rescue
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def start(refresh_rate: 24, mouse: true, alt_screen: false)
|
|
145
|
+
Live.start(@layout, refresh_rate: refresh_rate, mouse: mouse, alt_screen: alt_screen, autowrap: false) do |live|
|
|
146
|
+
@live = live
|
|
147
|
+
live.listening = true
|
|
148
|
+
end
|
|
149
|
+
ensure
|
|
150
|
+
@live = nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def stop
|
|
154
|
+
return false unless @live
|
|
155
|
+
|
|
156
|
+
@live.post { |live| live.stop } || false
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def build_layout
|
|
162
|
+
root = Layout.new(name: :root)
|
|
163
|
+
root.split_column(
|
|
164
|
+
Layout.new(name: :header, size: 1),
|
|
165
|
+
Layout.new(name: :body, ratio: 1),
|
|
166
|
+
Layout.new(name: :composer, size: 6),
|
|
167
|
+
Layout.new(name: :status, size: 1)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
root[:body].split_row(
|
|
171
|
+
Layout.new(name: :transcript, ratio: 1),
|
|
172
|
+
Layout.new(name: :sidebar, size: 36)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
root[:header].content = HeaderView.new(self)
|
|
176
|
+
root[:transcript].content = @viewport
|
|
177
|
+
root[:sidebar].content = @sidebar
|
|
178
|
+
root[:composer].content = FramedView.new(@composer, title: "Composer", theme: @theme) { @composer.focused? }
|
|
179
|
+
root[:status].content = StatusView.new(self)
|
|
180
|
+
root
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def attach_components
|
|
184
|
+
@viewport.attach(@layout[:transcript])
|
|
185
|
+
@transcript.attach(@layout[:transcript])
|
|
186
|
+
@composer.focus.attach(@layout[:composer])
|
|
187
|
+
|
|
188
|
+
@focus_manager
|
|
189
|
+
.register(:transcript, @layout[:transcript], FocusTarget.new(@transcript, @viewport))
|
|
190
|
+
.register(:composer, @layout[:composer], @composer)
|
|
191
|
+
.attach(@layout)
|
|
192
|
+
@focus_manager.focus(:composer)
|
|
193
|
+
|
|
194
|
+
@layout.key(:ctrl_c, 1_000) do |_event, live|
|
|
195
|
+
live.stop if @stop_on_ctrl_c != false
|
|
196
|
+
false
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def handle_select(command, _live)
|
|
201
|
+
case command[:value]
|
|
202
|
+
when "/plan"
|
|
203
|
+
@status = "plan command selected"
|
|
204
|
+
when "/thinking"
|
|
205
|
+
@status = "thinking command selected"
|
|
206
|
+
when "/tool"
|
|
207
|
+
@status = "tool command selected"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def handle_submit(value, live, attachments = [])
|
|
212
|
+
case value.strip
|
|
213
|
+
when "/quit"
|
|
214
|
+
live&.stop
|
|
215
|
+
when "/help"
|
|
216
|
+
add_assistant("Commands: /help, /plan, /thinking, /tool, /quit")
|
|
217
|
+
when "/plan"
|
|
218
|
+
@sidebar.add_task("Plan updated #{Time.now.strftime('%H:%M:%S')}", status: :in_progress)
|
|
219
|
+
when "/thinking"
|
|
220
|
+
add_thinking("Let me inspect the current state and keep the details collapsible.", status: "idle", collapsed: false)
|
|
221
|
+
when "/tool"
|
|
222
|
+
add_tool("read_file", status: :done, result: "name: read_file\nresult: <demo output>", collapsed: false)
|
|
223
|
+
else
|
|
224
|
+
add_user(value)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
@on_submit&.call(value, live, self, attachments)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
class FocusTarget
|
|
231
|
+
def initialize(*targets)
|
|
232
|
+
@targets = targets
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def focus
|
|
236
|
+
@targets.each { |target| target.focus if target.respond_to?(:focus) }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def blur
|
|
240
|
+
@targets.each { |target| target.blur if target.respond_to?(:blur) }
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
class HeaderView
|
|
245
|
+
attr_accessor :width, :height
|
|
246
|
+
|
|
247
|
+
def initialize(shell)
|
|
248
|
+
@shell = shell
|
|
249
|
+
@width = 0
|
|
250
|
+
@height = 1
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def render
|
|
254
|
+
theme = @shell.theme
|
|
255
|
+
left = "#{theme.style(@shell.instance_variable_get(:@title), :accent)} #{theme.style(@shell.instance_variable_get(:@subtitle), :muted)}"
|
|
256
|
+
usage = @shell.token_usage
|
|
257
|
+
usage_text = if usage && !usage.empty?
|
|
258
|
+
total = usage[:total] || usage[:tokens]
|
|
259
|
+
total ? "#{total} tok" : usage.map { |key, value| "#{key}=#{value}" }.join(" ")
|
|
260
|
+
else
|
|
261
|
+
"tokens --"
|
|
262
|
+
end
|
|
263
|
+
right = "#{theme.style(@shell.instance_variable_get(:@model), :status)} #{theme.style('● Live', :body)} #{theme.style(usage_text, :status)}"
|
|
264
|
+
[join_edges(left, right, @width)]
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
private
|
|
268
|
+
|
|
269
|
+
def join_edges(left, right, width)
|
|
270
|
+
space = [width - visible_width(left) - visible_width(right), 1].max
|
|
271
|
+
truncate_display(left + (" " * space) + right, width)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def visible_width(text)
|
|
275
|
+
text.gsub(/\e\[[0-9;:]*m/, "").display_width
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def truncate_display(text, width)
|
|
279
|
+
return text if visible_width(text) <= width
|
|
280
|
+
|
|
281
|
+
result = +""
|
|
282
|
+
used = 0
|
|
283
|
+
in_escape = false
|
|
284
|
+
text.each_char do |char|
|
|
285
|
+
if in_escape
|
|
286
|
+
result << char
|
|
287
|
+
in_escape = false if char == "m"
|
|
288
|
+
next
|
|
289
|
+
elsif char.ord == 27
|
|
290
|
+
result << char
|
|
291
|
+
in_escape = true
|
|
292
|
+
next
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
char_width = Unicode::DisplayWidth.of(char)
|
|
296
|
+
break if used + char_width > width
|
|
297
|
+
|
|
298
|
+
result << char
|
|
299
|
+
used += char_width
|
|
300
|
+
end
|
|
301
|
+
result
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
class StatusView
|
|
306
|
+
attr_accessor :width, :height
|
|
307
|
+
|
|
308
|
+
def initialize(shell)
|
|
309
|
+
@shell = shell
|
|
310
|
+
@width = 0
|
|
311
|
+
@height = 1
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def render
|
|
315
|
+
theme = @shell.theme
|
|
316
|
+
focus = @shell.focus_manager.focused_name || :none
|
|
317
|
+
progress = @shell.instance_variable_get(:@progress_text)
|
|
318
|
+
status = progress || @shell.instance_variable_get(:@status)
|
|
319
|
+
line = "#{theme.style(status, :accent)} #{theme.style('focus: ' + focus.to_s, :muted)} #{theme.style('Tab focus · Ctrl+C quit · /quit', :dim)}"
|
|
320
|
+
[line]
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
class FramedView
|
|
325
|
+
attr_accessor :width, :height
|
|
326
|
+
|
|
327
|
+
def initialize(component, title:, theme:, &focused)
|
|
328
|
+
@component = component
|
|
329
|
+
@title = title
|
|
330
|
+
@theme = theme
|
|
331
|
+
@focused = focused
|
|
332
|
+
@width = 0
|
|
333
|
+
@height = 0
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def render
|
|
337
|
+
sync_component_dimensions
|
|
338
|
+
panel = Panel.new(rendered_content, title: @title, border_style: @theme.panel_border(focused: @focused.call), title_align: :left)
|
|
339
|
+
panel.width = @width
|
|
340
|
+
panel.height = @height
|
|
341
|
+
panel.render
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def desired_height
|
|
345
|
+
return @height unless @component.respond_to?(:desired_height)
|
|
346
|
+
|
|
347
|
+
@component.desired_height + 2
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
private
|
|
351
|
+
|
|
352
|
+
def sync_component_dimensions
|
|
353
|
+
inner_width = [@width - 2, 1].max
|
|
354
|
+
inner_height = [@height - 2, 1].max
|
|
355
|
+
@component.width = inner_width if @component.respond_to?(:width=)
|
|
356
|
+
@component.height = inner_height if @component.respond_to?(:height=)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def rendered_content
|
|
360
|
+
rendered = @component.render
|
|
361
|
+
rendered.is_a?(Array) ? rendered.join("\n") : rendered.to_s
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def default_field_value(field)
|
|
366
|
+
case field[:type]
|
|
367
|
+
when :boolean then false
|
|
368
|
+
when :multi_select then []
|
|
369
|
+
when :number then nil
|
|
370
|
+
else ""
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyRich
|
|
4
|
+
class Attachment
|
|
5
|
+
attr_accessor :type, :path, :mime_type, :display_name, :metadata
|
|
6
|
+
|
|
7
|
+
def initialize(type:, path:, mime_type: nil, display_name: nil, metadata: {})
|
|
8
|
+
@type = type.to_sym
|
|
9
|
+
@path = path.to_s
|
|
10
|
+
@mime_type = mime_type
|
|
11
|
+
@display_name = display_name || File.basename(@path)
|
|
12
|
+
@metadata = metadata.dup
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
{
|
|
17
|
+
type: @type,
|
|
18
|
+
path: @path,
|
|
19
|
+
mime_type: @mime_type,
|
|
20
|
+
display_name: @display_name,
|
|
21
|
+
metadata: @metadata
|
|
22
|
+
}.compact
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
module RubyRich
|
|
2
|
+
class Columns
|
|
3
|
+
class Column
|
|
4
|
+
attr_accessor :content, :width, :align, :padding, :title
|
|
5
|
+
|
|
6
|
+
def initialize(width: nil, align: :left, padding: 1, title: nil)
|
|
7
|
+
@content = []
|
|
8
|
+
@width = width
|
|
9
|
+
@align = align
|
|
10
|
+
@padding = padding
|
|
11
|
+
@title = title
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def add(text)
|
|
15
|
+
@content << text.to_s
|
|
16
|
+
self
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def <<(text)
|
|
20
|
+
add(text)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def clear
|
|
24
|
+
@content.clear
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def lines
|
|
29
|
+
@content
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def height
|
|
33
|
+
@content.length
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_reader :columns, :total_width, :gutter_width
|
|
38
|
+
|
|
39
|
+
def initialize(total_width: 80, gutter_width: 2)
|
|
40
|
+
@columns = []
|
|
41
|
+
@total_width = total_width
|
|
42
|
+
@gutter_width = gutter_width
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# 添加列
|
|
46
|
+
def add_column(width: nil, align: :left, padding: 1, title: nil)
|
|
47
|
+
column = Column.new(width: width, align: align, padding: padding, title: title)
|
|
48
|
+
@columns << column
|
|
49
|
+
|
|
50
|
+
# 如果没有指定宽度,自动计算平均宽度
|
|
51
|
+
calculate_column_widths if width.nil?
|
|
52
|
+
|
|
53
|
+
column
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# 删除列
|
|
57
|
+
def remove_column(index)
|
|
58
|
+
@columns.delete_at(index) if index >= 0 && index < @columns.length
|
|
59
|
+
calculate_column_widths
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# 清空所有列的内容
|
|
63
|
+
def clear_all
|
|
64
|
+
@columns.each(&:clear)
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# 渲染列布局
|
|
69
|
+
def render(show_headers: true, show_borders: false, equal_height: true)
|
|
70
|
+
return "" if @columns.empty?
|
|
71
|
+
|
|
72
|
+
calculate_column_widths
|
|
73
|
+
lines = []
|
|
74
|
+
|
|
75
|
+
# 渲染标题行
|
|
76
|
+
if show_headers && @columns.any? { |col| col.title }
|
|
77
|
+
header_line = render_header_line(show_borders)
|
|
78
|
+
lines << header_line unless header_line.empty?
|
|
79
|
+
|
|
80
|
+
if show_borders
|
|
81
|
+
separator_line = render_separator_line
|
|
82
|
+
lines << separator_line
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# 准备内容行
|
|
87
|
+
max_height = equal_height ? @columns.map(&:height).max : 0
|
|
88
|
+
|
|
89
|
+
# 填充较短的列以达到相同高度
|
|
90
|
+
if equal_height && max_height > 0
|
|
91
|
+
@columns.each do |column|
|
|
92
|
+
while column.height < max_height
|
|
93
|
+
column.add("")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# 渲染内容行
|
|
99
|
+
content_height = @columns.map(&:height).max || 0
|
|
100
|
+
content_height.times do |row_index|
|
|
101
|
+
line = render_content_line(row_index, show_borders)
|
|
102
|
+
lines << line
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# 渲染底部边框
|
|
106
|
+
if show_borders
|
|
107
|
+
bottom_line = render_separator_line
|
|
108
|
+
lines << bottom_line
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
lines.join("\n")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# 按比例设置列宽
|
|
115
|
+
def set_ratios(*ratios)
|
|
116
|
+
return if ratios.length != @columns.length
|
|
117
|
+
|
|
118
|
+
total_ratio = ratios.sum.to_f
|
|
119
|
+
available_width = @total_width - (@gutter_width * (@columns.length - 1))
|
|
120
|
+
|
|
121
|
+
@columns.each_with_index do |column, index|
|
|
122
|
+
column.width = (available_width * ratios[index] / total_ratio).to_i
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
self
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# 设置等宽列
|
|
129
|
+
def equal_widths
|
|
130
|
+
calculate_column_widths
|
|
131
|
+
self
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def calculate_column_widths
|
|
137
|
+
return if @columns.empty?
|
|
138
|
+
|
|
139
|
+
# 计算可用宽度(总宽度减去间隔)
|
|
140
|
+
available_width = @total_width - (@gutter_width * (@columns.length - 1))
|
|
141
|
+
|
|
142
|
+
# 为每列分配相等的宽度
|
|
143
|
+
base_width = available_width / @columns.length
|
|
144
|
+
remainder = available_width % @columns.length
|
|
145
|
+
|
|
146
|
+
@columns.each_with_index do |column, index|
|
|
147
|
+
column.width = base_width + (index < remainder ? 1 : 0)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def render_header_line(show_borders)
|
|
152
|
+
line_parts = []
|
|
153
|
+
|
|
154
|
+
@columns.each_with_index do |column, index|
|
|
155
|
+
header_text = column.title || ""
|
|
156
|
+
|
|
157
|
+
# 根据列的对齐方式格式化标题
|
|
158
|
+
formatted_header = format_text(header_text, column.width, column.align)
|
|
159
|
+
|
|
160
|
+
if show_borders
|
|
161
|
+
formatted_header = "│ #{formatted_header} │"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
line_parts << formatted_header
|
|
165
|
+
|
|
166
|
+
# 添加间隔(除了最后一列)
|
|
167
|
+
if index < @columns.length - 1 && !show_borders
|
|
168
|
+
line_parts << " " * @gutter_width
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
line_parts.join("")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def render_content_line(row_index, show_borders)
|
|
176
|
+
line_parts = []
|
|
177
|
+
|
|
178
|
+
@columns.each_with_index do |column, index|
|
|
179
|
+
content_text = column.lines[row_index] || ""
|
|
180
|
+
|
|
181
|
+
# 根据列的对齐方式格式化内容
|
|
182
|
+
formatted_content = format_text(content_text, column.width, column.align)
|
|
183
|
+
|
|
184
|
+
if show_borders
|
|
185
|
+
formatted_content = "│ #{formatted_content} │"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
line_parts << formatted_content
|
|
189
|
+
|
|
190
|
+
# 添加间隔(除了最后一列)
|
|
191
|
+
if index < @columns.length - 1 && !show_borders
|
|
192
|
+
line_parts << " " * @gutter_width
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
line_parts.join("")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def render_separator_line
|
|
200
|
+
line_parts = []
|
|
201
|
+
|
|
202
|
+
@columns.each_with_index do |column, index|
|
|
203
|
+
separator = "─" * (column.width + 2) # +2 for padding
|
|
204
|
+
line_parts << "├#{separator}┤"
|
|
205
|
+
|
|
206
|
+
if index < @columns.length - 1
|
|
207
|
+
line_parts << "┬"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
line_parts.join("")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def format_text(text, width, align)
|
|
215
|
+
# 移除 ANSI 转义序列计算实际显示宽度
|
|
216
|
+
display_text = text.gsub(/\e\[[0-9;]*m/, '')
|
|
217
|
+
|
|
218
|
+
if display_text.length > width
|
|
219
|
+
# 截断过长的文本
|
|
220
|
+
truncated = display_text[0, width - 3] + "..."
|
|
221
|
+
# 保持原有的 ANSI 样式
|
|
222
|
+
if text.include?("\e[")
|
|
223
|
+
style_start = text.match(/\e\[[0-9;]*m/)&.to_s || ""
|
|
224
|
+
truncated = style_start + truncated + "\e[0m"
|
|
225
|
+
end
|
|
226
|
+
truncated
|
|
227
|
+
else
|
|
228
|
+
# 根据对齐方式填充空格
|
|
229
|
+
padding_needed = width - display_text.length
|
|
230
|
+
|
|
231
|
+
case align
|
|
232
|
+
when :center
|
|
233
|
+
left_padding = padding_needed / 2
|
|
234
|
+
right_padding = padding_needed - left_padding
|
|
235
|
+
" " * left_padding + text + " " * right_padding
|
|
236
|
+
when :right
|
|
237
|
+
" " * padding_needed + text
|
|
238
|
+
else # :left
|
|
239
|
+
text + " " * padding_needed
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|