ruby_rich 0.4.3 → 0.4.4

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: 9110486cfde27e4e77705f6daa78bd07a66fd9665dcfa3cdb7bbbb13ab1cac82
4
- data.tar.gz: 46cb92c2621846bab87f0b0cb2c45dfd6bdba6c3f3547ac64bae3f2d2c779333
3
+ metadata.gz: 50bb2ebd116fd55abbe618cbd351ad1b73dab3a040d5df2f43011aff77a608bf
4
+ data.tar.gz: be41ae4ba9c0d4754c3eb9de3070d914e6dba23dd383f5fc81ee72c848f8e4a6
5
5
  SHA512:
6
- metadata.gz: c9e7643688bb98f73aa309238413535539448bc20b5ddb05fbdc83c476b8696c09df3990cbae1beb36095f6fd18c8228876026237ca6a934af7e4be1a0ff29d3
7
- data.tar.gz: 7b9fb1f6844b45679079474aad5a54ef97e343bc56459a873ccdaad7ef4f0e1b080fc1141dcce3647d47c152b9bd2443f164d266cfdaaa66f6dd78dbf6c6e44e
6
+ metadata.gz: c71f39b536218b86f9ce58e15ab476d7927bc086be78ec46f36629e9d3007972f5b4c727a924fad3729666362ae3fb600445ff0acd265e58bac9d266097b4edf
7
+ data.tar.gz: 2424396618c67fdad4568d77917424377c70e0284572ceb8e2735a53ccf9d10d217f1273cbc337ffd9063b70c9cfe60b310deb34278b619232ef8302140d8dc4
@@ -23,11 +23,11 @@ module RubyRich
23
23
  @progress_text = nil
24
24
 
25
25
  @transcript = Transcript.new
26
- @progress_manager = ProgressManager.new(on_change: ->(text) { @progress_text = text })
26
+ @progress_manager = ProgressManager.new(on_change: ->(text) { update_progress_text(text) })
27
27
  @viewport = Viewport.new(@transcript, scrollbar: true, auto_scroll: true)
28
28
  @sidebar = Sidebar.new
29
29
  @composer = Composer.new(
30
- placeholder: "编写任务或使用 /。",
30
+ placeholder: "Create a task or enter /",
31
31
  commands: commands,
32
32
  on_submit: method(:handle_submit),
33
33
  on_select: method(:handle_select)
@@ -104,6 +104,12 @@ module RubyRich
104
104
  self
105
105
  end
106
106
 
107
+ def update_progress_text(text)
108
+ @progress_text = text
109
+ @live&.refresh
110
+ self
111
+ end
112
+
107
113
  def start_progress(message = nil, owner: Thread.current.object_id, style: :primary, quiet_on_fast_finish: false)
108
114
  _ = style
109
115
  _ = quiet_on_fast_finish
@@ -433,6 +433,8 @@ module RubyRich
433
433
  def colorize_input(line)
434
434
  color = AnsiCode.color(:white, true)
435
435
  reset = AnsiCode.reset
436
+ return line.to_s if line.to_s.include?(AnsiCode.inverse)
437
+
436
438
  "#{color}#{line.to_s.gsub(reset, "#{reset}#{color}")}#{reset}"
437
439
  end
438
440
 
@@ -15,18 +15,24 @@ module RubyRich
15
15
  @history_index = nil
16
16
  @chars = []
17
17
  @cursor = 0
18
+ @value_cache = nil
19
+ @lines_cache = nil
20
+ @line_starts_cache = nil
21
+ @cursor_line_col_cache_key = nil
22
+ @cursor_line_col_cache = nil
18
23
  load_history
19
24
  history.each { |item| add_history(item, persist: false) }
20
25
  end
21
26
 
22
27
  def value
23
- @chars.join
28
+ @value_cache ||= @chars.join
24
29
  end
25
30
 
26
31
  def value=(text)
27
32
  @chars = text.to_s.chars
28
33
  @cursor = @chars.length
29
34
  @history_index = nil
35
+ invalidate_content_cache
30
36
  self
31
37
  end
32
38
 
@@ -38,6 +44,7 @@ module RubyRich
38
44
  @chars.clear
39
45
  @cursor = 0
40
46
  @history_index = nil
47
+ invalidate_content_cache
41
48
  self
42
49
  end
43
50
 
@@ -49,6 +56,7 @@ module RubyRich
49
56
  @chars.insert(@cursor, *new_chars)
50
57
  @cursor += new_chars.length
51
58
  @history_index = nil
59
+ invalidate_content_cache
52
60
  self
53
61
  end
54
62
 
@@ -62,6 +70,7 @@ module RubyRich
62
70
 
63
71
  @chars.delete_at(@cursor - 1)
64
72
  @cursor -= 1
73
+ invalidate_content_cache
65
74
  true
66
75
  end
67
76
 
@@ -69,36 +78,43 @@ module RubyRich
69
78
  return false if @cursor >= @chars.length
70
79
 
71
80
  @chars.delete_at(@cursor)
81
+ invalidate_content_cache
72
82
  true
73
83
  end
74
84
 
75
85
  def move_left
76
86
  @cursor = [@cursor - 1, 0].max
87
+ invalidate_cursor_cache
77
88
  self
78
89
  end
79
90
 
80
91
  def move_right
81
92
  @cursor = [@cursor + 1, @chars.length].min
93
+ invalidate_cursor_cache
82
94
  self
83
95
  end
84
96
 
85
97
  def home
86
98
  @cursor = current_line_start
99
+ invalidate_cursor_cache
87
100
  self
88
101
  end
89
102
 
90
103
  def end
91
104
  @cursor = current_line_end
105
+ invalidate_cursor_cache
92
106
  self
93
107
  end
94
108
 
95
109
  def buffer_start
96
110
  @cursor = 0
111
+ invalidate_cursor_cache
97
112
  self
98
113
  end
99
114
 
100
115
  def buffer_end
101
116
  @cursor = @chars.length
117
+ invalidate_cursor_cache
102
118
  self
103
119
  end
104
120
 
@@ -118,6 +134,7 @@ module RubyRich
118
134
  return false if @cursor >= @chars.length
119
135
 
120
136
  @chars.slice!(@cursor...current_line_end)
137
+ invalidate_content_cache
121
138
  true
122
139
  end
123
140
 
@@ -127,6 +144,7 @@ module RubyRich
127
144
 
128
145
  @chars.slice!(start...@cursor)
129
146
  @cursor = start
147
+ invalidate_content_cache
130
148
  true
131
149
  end
132
150
 
@@ -136,6 +154,7 @@ module RubyRich
136
154
  start = previous_word_start
137
155
  @chars.slice!(start...@cursor)
138
156
  @cursor = start
157
+ invalidate_content_cache
139
158
  true
140
159
  end
141
160
 
@@ -158,14 +177,19 @@ module RubyRich
158
177
  end
159
178
 
160
179
  def lines
161
- text = value
162
- text.empty? ? [""] : text.split("\n", -1)
180
+ @lines_cache ||= begin
181
+ text = value
182
+ text.empty? ? [""] : text.split("\n", -1)
183
+ end
163
184
  end
164
185
 
165
186
  def cursor_line_col
166
- before = @chars[0...@cursor].join
167
- parts = before.empty? ? [""] : before.split("\n", -1)
168
- [parts.length - 1, parts.last.to_s.chars.length]
187
+ cache_key = [@cursor, @chars.length]
188
+ return @cursor_line_col_cache if @cursor_line_col_cache_key == cache_key && @cursor_line_col_cache
189
+
190
+ line_index = line_index_for_cursor
191
+ @cursor_line_col_cache_key = cache_key
192
+ @cursor_line_col_cache = [line_index, @cursor - line_starts[line_index]]
169
193
  end
170
194
 
171
195
  def render_lines(width:, placeholder: nil, focused: true)
@@ -183,6 +207,18 @@ module RubyRich
183
207
 
184
208
  private
185
209
 
210
+ def invalidate_content_cache
211
+ @value_cache = nil
212
+ @lines_cache = nil
213
+ @line_starts_cache = nil
214
+ invalidate_cursor_cache
215
+ end
216
+
217
+ def invalidate_cursor_cache
218
+ @cursor_line_col_cache_key = nil
219
+ @cursor_line_col_cache = nil
220
+ end
221
+
186
222
  def normalize_insert_text(text)
187
223
  incoming = text.to_s.gsub(/\r\n?/, "\n")
188
224
  return incoming if @multiline
@@ -191,23 +227,13 @@ module RubyRich
191
227
  end
192
228
 
193
229
  def current_line_start
194
- index = @cursor - 1
195
- while index >= 0
196
- return index + 1 if @chars[index] == "\n"
197
-
198
- index -= 1
199
- end
200
- 0
230
+ line_starts[line_index_for_cursor]
201
231
  end
202
232
 
203
233
  def current_line_end
204
- index = @cursor
205
- while index < @chars.length
206
- return index if @chars[index] == "\n"
207
-
208
- index += 1
209
- end
210
- @chars.length
234
+ line_index = line_index_for_cursor
235
+ next_start = line_starts[line_index + 1]
236
+ next_start ? next_start - 1 : @chars.length
211
237
  end
212
238
 
213
239
  def move_vertical(delta)
@@ -216,18 +242,43 @@ module RubyRich
216
242
  return self if target_line.negative? || target_line >= lines.length
217
243
 
218
244
  @cursor = index_for_line_col(target_line, current_col)
245
+ invalidate_cursor_cache
219
246
  self
220
247
  end
221
248
 
222
249
  def index_for_line_col(line_index, col)
223
- index = 0
224
- lines.each_with_index do |line, current|
225
- line_length = line.chars.length
226
- return index + [col, line_length].min if current == line_index
250
+ line_start = line_starts[line_index]
251
+ return @chars.length unless line_start
227
252
 
228
- index += line_length + 1
253
+ line_length = lines[line_index].chars.length
254
+ line_start + [col, line_length].min
255
+ end
256
+
257
+ def line_starts
258
+ @line_starts_cache ||= begin
259
+ starts = [0]
260
+ @chars.each_with_index do |char, index|
261
+ starts << index + 1 if char == "\n"
262
+ end
263
+ starts
229
264
  end
230
- @chars.length
265
+ end
266
+
267
+ def line_index_for_cursor
268
+ starts = line_starts
269
+ low = 0
270
+ high = starts.length - 1
271
+
272
+ while low <= high
273
+ mid = (low + high) / 2
274
+ if starts[mid] <= @cursor
275
+ low = mid + 1
276
+ else
277
+ high = mid - 1
278
+ end
279
+ end
280
+
281
+ [high, 0].max
231
282
  end
232
283
 
233
284
  def previous_word_start
@@ -288,34 +339,23 @@ module RubyRich
288
339
  current_width = 0
289
340
  chars = line.chars
290
341
  chars.each_with_index do |char, index|
291
- if marker_col == index
292
- cursor = cursor_marker
293
- if current_width + 1 > width
294
- segments << current
295
- current = +""
296
- current_width = 0
297
- end
298
- current << cursor
299
- current_width += 1
300
- end
301
-
302
342
  char_width = display_width(char)
303
343
  if current_width + char_width > width
304
344
  segments << current
305
345
  current = +""
306
346
  current_width = 0
307
347
  end
308
- current << char
348
+ current << (marker_col == index ? cursor_marker(char) : char)
309
349
  current_width += char_width
310
350
  end
311
351
 
312
- current << cursor_marker if marker_col == chars.length
352
+ current << cursor_marker(" ") if marker_col == chars.length
313
353
  segments << current
314
354
  segments
315
355
  end
316
356
 
317
- def cursor_marker
318
- "#{AnsiCode.color(:blue, true)}_#{AnsiCode.reset}"
357
+ def cursor_marker(char)
358
+ "#{AnsiCode.inverse}#{char}#{AnsiCode.reset}"
319
359
  end
320
360
 
321
361
  def display_width(char)
@@ -12,9 +12,9 @@ module RubyRich
12
12
  @cache = nil
13
13
  end
14
14
 
15
- def print_with_pos(x,y,char)
15
+ def print_with_pos(x,y,text)
16
16
  print "\e[#{y};#{x}H" # Move cursor to top-left
17
- print char
17
+ print text
18
18
  end
19
19
 
20
20
  def draw(buffer)
@@ -23,19 +23,37 @@ module RubyRich
23
23
  draw_full(buffer)
24
24
  @cache = buffer
25
25
  else
26
- buffer.each_with_index do |arr, y|
27
- arr.each_with_index do |char, x|
28
- if @cache[y][x] != char
29
- print_with_pos(x + 1 , y + 1 , char)
30
- @cache[y][x] = char
31
- end
32
- end
33
- end
26
+ draw_changes(buffer)
34
27
  end
35
28
  end
36
29
 
37
30
  private
38
31
 
32
+ def draw_changes(buffer)
33
+ buffer.each_with_index do |line, y|
34
+ cache_line = @cache[y] ||= []
35
+ x = 0
36
+ while x < line.length
37
+ if cache_line[x] == line[x]
38
+ x += 1
39
+ next
40
+ end
41
+
42
+ start = x
43
+ parts = []
44
+ while x < line.length && cache_line[x] != line[x]
45
+ char = line[x]
46
+ parts << char unless char.nil?
47
+ cache_line[x] = char
48
+ x += 1
49
+ end
50
+
51
+ print_with_pos(start + 1, y + 1, parts.join) unless parts.empty?
52
+ end
53
+ end
54
+ $stdout.flush
55
+ end
56
+
39
57
  def draw_full(buffer)
40
58
  buffer.each_with_index do |line, y|
41
59
  print_with_pos(1, y + 1, line.compact.join(""))
@@ -45,6 +63,8 @@ module RubyRich
45
63
  end
46
64
 
47
65
  class Live
66
+ RESIZE_POLL_INTERVAL = 0.25
67
+
48
68
  attr_accessor :params, :app, :listening, :layout
49
69
  class << self
50
70
  def start(layout, refresh_rate: 30, mouse: false, alt_screen: false, autowrap: false, &proc)
@@ -87,6 +107,10 @@ module RubyRich
87
107
  @event_queue = Queue.new
88
108
  @action_queue = Queue.new
89
109
  @event_thread = nil
110
+ @wake_mutex = Mutex.new
111
+ @wake_condition = ConditionVariable.new
112
+ @dirty = true
113
+ @last_terminal_size = nil
90
114
  @params = {}
91
115
  if (log_path = ENV["RUBY_RICH_LOG"]).to_s.strip != ""
92
116
  FileUtils.mkdir_p(File.dirname(log_path))
@@ -97,12 +121,15 @@ module RubyRich
97
121
  def run(proc = nil)
98
122
  start_event_thread if @listening
99
123
  while @running
100
- drain_action_queue
124
+ action_processed = drain_action_queue
101
125
  break unless @running
102
126
 
103
- render_frame
104
- drain_event_queue if @listening
105
- sleep 1.0 / @refresh_rate
127
+ event_processed = @listening ? drain_event_queue : false
128
+ if consume_dirty || action_processed || event_processed || terminal_size_changed?
129
+ render_frame
130
+ else
131
+ wait_for_activity
132
+ end
106
133
  end
107
134
  rescue Interrupt
108
135
  @running = false
@@ -113,11 +140,21 @@ module RubyRich
113
140
  return false unless @running
114
141
 
115
142
  @action_queue << block
143
+ wake
144
+ true
145
+ end
146
+
147
+ def refresh
148
+ return false unless @running
149
+
150
+ mark_dirty
151
+ wake
116
152
  true
117
153
  end
118
154
 
119
155
  def stop
120
156
  @running = false
157
+ wake
121
158
  shutdown
122
159
  RubyRich::Terminal.clear
123
160
  end
@@ -150,7 +187,10 @@ module RubyRich
150
187
  while @running
151
188
  begin
152
189
  event_data = @console.get_event
153
- @event_queue << event_data if event_data
190
+ if event_data
191
+ @event_queue << event_data
192
+ wake
193
+ end
154
194
  rescue IOError, SystemCallError
155
195
  break
156
196
  rescue Interrupt
@@ -164,30 +204,73 @@ module RubyRich
164
204
  end
165
205
 
166
206
  def drain_event_queue
207
+ processed = false
167
208
  until @event_queue.empty?
168
209
  event_data = @event_queue.pop(true)
169
210
  @layout.notify_listeners(event_data)
211
+ processed = true
170
212
  end
213
+ processed
171
214
  rescue ThreadError
172
- nil
215
+ processed
173
216
  end
174
217
 
175
218
  def drain_action_queue
219
+ processed = false
176
220
  until @action_queue.empty?
177
221
  action = @action_queue.pop(true)
178
222
  action.call(self)
223
+ processed = true
179
224
  end
225
+ processed
180
226
  rescue ThreadError
181
- nil
227
+ processed
182
228
  rescue => e
183
229
  RubyRich.logger.error("UI action failed: #{e.class}: #{e.message}")
230
+ true
184
231
  end
185
232
 
186
233
  def render_frame
187
- @layout.calculate_dimensions(terminal_width, terminal_height)
234
+ width = terminal_width
235
+ height = terminal_height
236
+ @last_terminal_size = [width, height]
237
+ @layout.calculate_dimensions(width, height)
188
238
  @render.draw(@layout.render_to_buffer)
189
239
  end
190
240
 
241
+ def wait_for_activity
242
+ @wake_mutex.synchronize do
243
+ return unless @running
244
+ return unless @action_queue.empty?
245
+ return if @listening && !@event_queue.empty?
246
+
247
+ @wake_condition.wait(@wake_mutex, RESIZE_POLL_INTERVAL)
248
+ end
249
+ end
250
+
251
+ def wake
252
+ @wake_mutex.synchronize { @wake_condition.signal }
253
+ end
254
+
255
+ def mark_dirty
256
+ @wake_mutex.synchronize { @dirty = true }
257
+ end
258
+
259
+ def consume_dirty
260
+ @wake_mutex.synchronize do
261
+ dirty = @dirty
262
+ @dirty = false
263
+ dirty
264
+ end
265
+ end
266
+
267
+ def terminal_size_changed?
268
+ current_size = [terminal_width, terminal_height]
269
+ changed = @last_terminal_size != current_size
270
+ @last_terminal_size = current_size
271
+ changed
272
+ end
273
+
191
274
  def terminal_width
192
275
  TTY::Screen.width
193
276
  end
@@ -1,7 +1,8 @@
1
1
  module RubyRich
2
2
  class Panel
3
- attr_accessor :width, :height, :content, :line_pos, :border_style, :title
3
+ attr_accessor :height, :content, :line_pos, :border_style, :title
4
4
  attr_accessor :title_align, :content_changed
5
+ attr_reader :width
5
6
 
6
7
  def initialize(content = "", title: nil, border_style: :white, title_align: :center)
7
8
  @content = content
@@ -11,6 +12,14 @@ module RubyRich
11
12
  @height = 0
12
13
  @line_pos = 0
13
14
  @title_align = title_align
15
+ @wrapped_content_cache_key = nil
16
+ @wrapped_content_cache = nil
17
+ end
18
+
19
+ def width=(new_width)
20
+ new_width = new_width.to_i
21
+ invalidate_wrap_cache if @width != new_width
22
+ @width = new_width
14
23
  end
15
24
 
16
25
  def inner_width
@@ -117,6 +126,7 @@ module RubyRich
117
126
 
118
127
  def content=(new_content)
119
128
  @content = new_content
129
+ invalidate_wrap_cache
120
130
  content_lines = wrap_content(@content)
121
131
  if content_lines.size > @height - 2
122
132
  @line_pos = content_lines.size - @height + 2
@@ -126,6 +136,11 @@ module RubyRich
126
136
 
127
137
  private
128
138
 
139
+ def invalidate_wrap_cache
140
+ @wrapped_content_cache_key = nil
141
+ @wrapped_content_cache = nil
142
+ end
143
+
129
144
  def split_text_by_width(text)
130
145
  result = []
131
146
  current_line = ""
@@ -165,9 +180,13 @@ module RubyRich
165
180
  end
166
181
 
167
182
  def wrap_content(text)
183
+ key = [text, @width]
184
+ return @wrapped_content_cache if @wrapped_content_cache_key == key && @wrapped_content_cache
185
+
186
+ @wrapped_content_cache_key = key
168
187
  text.split("\n").flat_map do |line|
169
188
  split_text_by_width(line)
170
- end
189
+ end.tap { |lines| @wrapped_content_cache = lines }
171
190
  end
172
191
  end
173
192
  end
@@ -126,7 +126,8 @@ module RubyRich
126
126
  border_chars = BORDER_STYLES[@border_style]
127
127
 
128
128
  row_content = row.map.with_index do |cell, i|
129
- content = bold ? cell.render : align_cell(cell.render, column_widths[i])
129
+ rendered = cell.render
130
+ content = bold ? rendered : align_cell(rendered, column_widths[i])
130
131
  aligned_content = align_cell(content, column_widths[i])
131
132
  " #{aligned_content} "
132
133
  end.join(border_chars[:vertical])
@@ -143,12 +144,13 @@ module RubyRich
143
144
 
144
145
  # Prepare each cell's lines
145
146
  row_lines = row.map.with_index do |cell, i|
147
+ rendered = cell.render
146
148
  # 获取单元格的样式序列
147
- style_sequence = cell.render.match(/\e\[[0-9;]*m/)&.to_s || ""
149
+ style_sequence = rendered.match(/\e\[[0-9;]*m/)&.to_s || ""
148
150
  reset_sequence = style_sequence.empty? ? "" : "\e[0m"
149
151
 
150
152
  # 分割成多行并保持样式
151
- cell_content = cell.render.split("\n")
153
+ cell_content = rendered.split("\n")
152
154
 
153
155
  # 为每一行添加样式
154
156
  cell_content.map! { |line|
@@ -165,8 +167,8 @@ module RubyRich
165
167
 
166
168
  # Normalize row height
167
169
  max_height = row_lines.map(&:size).max
168
- row_lines.each do |lines|
169
- width = column_widths[row_lines.index(lines)]
170
+ row_lines.each_with_index do |lines, index|
171
+ width = column_widths[index]
170
172
  style_sequence = lines.first.match(/\e\[[0-9;]*m/)&.to_s || ""
171
173
  reset_sequence = style_sequence.empty? ? "" : "\e[0m"
172
174
  lines.fill(style_sequence + " " * width + reset_sequence, lines.size...max_height)
@@ -188,7 +190,7 @@ module RubyRich
188
190
 
189
191
  # Calculate widths from headers
190
192
  @headers.each_with_index do |header, i|
191
- header_text = header.respond_to?(:render) ? header.render : header.to_s
193
+ header_text = render_cell(header)
192
194
  header_width = Unicode::DisplayWidth.of(header_text.gsub(/\e\[[0-9;]*m/, ''))
193
195
  widths[i] = [widths[i], header_width].max
194
196
  end
@@ -196,7 +198,7 @@ module RubyRich
196
198
  # Calculate widths from rows
197
199
  @rows.each do |row|
198
200
  row.each_with_index do |cell, i|
199
- cell_lines = cell.render.split("\n")
201
+ cell_lines = render_cell(cell).split("\n")
200
202
  cell_lines.each do |line|
201
203
  # Remove ANSI escape sequences before calculating width
202
204
  plain_line = line.gsub(/\e\[[0-9;]*m/, '')
@@ -211,7 +213,8 @@ module RubyRich
211
213
 
212
214
  def render_row(row, column_widths, bold: false)
213
215
  row.map.with_index do |cell, i|
214
- content = bold ? cell.render : align_cell(cell.render, column_widths[i])
216
+ rendered = cell.render
217
+ content = bold ? rendered : align_cell(rendered, column_widths[i])
215
218
  align_cell(content, column_widths[i])
216
219
  end.join(" | ").prepend("| ").concat(" |")
217
220
  end
@@ -219,12 +222,13 @@ module RubyRich
219
222
  def render_multiline_row(row, column_widths)
220
223
  # Prepare each cell's lines
221
224
  row_lines = row.map.with_index do |cell, i|
225
+ rendered = cell.render
222
226
  # Get cell style sequence
223
- style_sequence = cell.render.match(/\e\[[0-9;]*m/)&.to_s || ""
227
+ style_sequence = rendered.match(/\e\[[0-9;]*m/)&.to_s || ""
224
228
  reset_sequence = style_sequence.empty? ? "" : "\e[0m"
225
229
 
226
230
  # Split into multiple lines while preserving styles
227
- cell_content = cell.render.split("\n")
231
+ cell_content = rendered.split("\n")
228
232
 
229
233
  # Add style to each line
230
234
  cell_content.map! { |line|
@@ -241,8 +245,8 @@ module RubyRich
241
245
 
242
246
  # Normalize row height
243
247
  max_height = row_lines.map(&:size).max
244
- row_lines.each do |lines|
245
- width = column_widths[row_lines.index(lines)]
248
+ row_lines.each_with_index do |lines, index|
249
+ width = column_widths[index]
246
250
  style_sequence = lines.first.match(/\e\[[0-9;]*m/)&.to_s || ""
247
251
  reset_sequence = style_sequence.empty? ? "" : "\e[0m"
248
252
  lines.fill(style_sequence + " " * width + reset_sequence, lines.size...max_height)
@@ -253,6 +257,10 @@ module RubyRich
253
257
  row_lines.map { |lines| lines[line_index] }.join(" | ").prepend("| ").concat(" |")
254
258
  end
255
259
  end
260
+
261
+ def render_cell(cell)
262
+ cell.respond_to?(:render) ? cell.render : cell.to_s
263
+ end
256
264
 
257
265
  def align_cell(content, width)
258
266
  style_sequences = content.scan(/\e\[[0-9;]*m/)
@@ -280,4 +288,4 @@ module RubyRich
280
288
  end
281
289
  end
282
290
  end
283
- end
291
+ end
@@ -160,11 +160,12 @@ module RubyRich
160
160
  class Store
161
161
  include Enumerable
162
162
 
163
- attr_reader :entries
163
+ attr_reader :entries, :version
164
164
 
165
165
  def initialize
166
166
  @entries = []
167
167
  @sequence = 0
168
+ @version = 0
168
169
  @mutex = Mutex.new
169
170
  end
170
171
 
@@ -183,6 +184,7 @@ module RubyRich
183
184
  name: name
184
185
  )
185
186
  @entries << entry
187
+ touch
186
188
  entry
187
189
  end
188
190
  end
@@ -201,6 +203,7 @@ module RubyRich
201
203
  return false unless index
202
204
 
203
205
  @entries.delete_at(index)
206
+ touch
204
207
  true
205
208
  end
206
209
  end
@@ -241,10 +244,15 @@ module RubyRich
241
244
  return false unless entry
242
245
 
243
246
  yield entry
247
+ touch
244
248
  true
245
249
  end
246
250
  end
247
251
 
252
+ def touch
253
+ @version += 1
254
+ end
255
+
248
256
  def normalize_type(type)
249
257
  normalized = type.to_sym
250
258
  normalized = :tool if normalized == :tool_call
@@ -278,6 +286,10 @@ module RubyRich
278
286
  @store.entries
279
287
  end
280
288
 
289
+ def version
290
+ @store.version
291
+ end
292
+
281
293
  def focus
282
294
  @focused = true
283
295
  self
@@ -1,3 +1,3 @@
1
1
  module RubyRich
2
- VERSION = "0.4.3"
2
+ VERSION = "0.4.4"
3
3
  end
@@ -21,6 +21,8 @@ module RubyRich
21
21
  @selection_end = nil
22
22
  @selected_text = ""
23
23
  @focused = true
24
+ @rendered_lines_cache_key = nil
25
+ @rendered_lines_cache = nil
24
26
  end
25
27
 
26
28
  def focus
@@ -36,6 +38,7 @@ module RubyRich
36
38
  def content=(new_content)
37
39
  was_at_bottom = at_bottom?
38
40
  @content = new_content
41
+ invalidate_rendered_lines
39
42
  scroll_to_bottom if @auto_scroll && was_at_bottom
40
43
  clamp_scroll
41
44
  end
@@ -140,7 +143,24 @@ module RubyRich
140
143
  private
141
144
 
142
145
  def rendered_lines
143
- @rendered_lines = normalize_lines(@content)
146
+ key = rendered_lines_cache_key(@content)
147
+ return @rendered_lines_cache if @rendered_lines_cache_key == key && @rendered_lines_cache
148
+
149
+ @rendered_lines_cache_key = key
150
+ @rendered_lines_cache = normalize_lines(@content)
151
+ end
152
+
153
+ def invalidate_rendered_lines
154
+ @rendered_lines_cache_key = nil
155
+ @rendered_lines_cache = nil
156
+ end
157
+
158
+ def rendered_lines_cache_key(value)
159
+ [
160
+ value.object_id,
161
+ content_width,
162
+ value.respond_to?(:version) ? value.version : nil
163
+ ]
144
164
  end
145
165
 
146
166
  def keyboard_event?(event_data)
@@ -162,12 +182,58 @@ module RubyRich
162
182
 
163
183
  case rendered
164
184
  when String
165
- rendered.split("\n")
185
+ rendered.split("\n").flat_map { |line| wrap_display_line(line, content_width) }
166
186
  when Array
167
- rendered
187
+ rendered.flat_map { |line| wrap_display_line(line.to_s, content_width) }
168
188
  else
169
- rendered.to_s.split("\n")
189
+ rendered.to_s.split("\n").flat_map { |line| wrap_display_line(line, content_width) }
190
+ end
191
+ end
192
+
193
+ def wrap_display_line(line, target_width)
194
+ return [""] if line.empty?
195
+ return [line] if display_width(line) <= target_width
196
+
197
+ lines = []
198
+ current = +""
199
+ width = 0
200
+ in_escape = false
201
+ escape = +""
202
+ active_sgr = +""
203
+
204
+ line.each_char do |char|
205
+ if in_escape
206
+ escape << char
207
+ if char == "m"
208
+ current << escape
209
+ active_sgr = escape == AnsiCode.reset ? +"" : escape.dup
210
+ escape = +""
211
+ in_escape = false
212
+ end
213
+ next
214
+ elsif char.ord == 27
215
+ escape << char
216
+ in_escape = true
217
+ next
218
+ end
219
+
220
+ char_width = Unicode::DisplayWidth.of(char)
221
+ if width.positive? && width + char_width > target_width
222
+ lines << close_wrapped_line(current, active_sgr)
223
+ current = active_sgr.dup
224
+ width = 0
225
+ end
226
+
227
+ current << char
228
+ width += char_width
170
229
  end
230
+
231
+ lines << current unless current.empty?
232
+ lines.empty? ? [""] : lines
233
+ end
234
+
235
+ def close_wrapped_line(line, active_sgr)
236
+ active_sgr.empty? || line.end_with?(AnsiCode.reset) ? line : "#{line}#{AnsiCode.reset}"
171
237
  end
172
238
 
173
239
  def page_size
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.3
4
+ version: 0.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhuang biaowei