ruby_rich 0.4.3 → 0.4.5

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: fa1b6881cd40609ff69b7e2b4c766c5fa3551bf49cd5558ad79681d89abc4e1a
4
+ data.tar.gz: e596c29715037aca70508b3dc564b009bc20bfa8f56a6286fb173819f9b2750a
5
5
  SHA512:
6
- metadata.gz: c9e7643688bb98f73aa309238413535539448bc20b5ddb05fbdc83c476b8696c09df3990cbae1beb36095f6fd18c8228876026237ca6a934af7e4be1a0ff29d3
7
- data.tar.gz: 7b9fb1f6844b45679079474aad5a54ef97e343bc56459a873ccdaad7ef4f0e1b080fc1141dcce3647d47c152b9bd2443f164d266cfdaaa66f6dd78dbf6c6e44e
6
+ metadata.gz: 425b8e8584af060a2e9eb1ffe7666e0f2ddcec84c4a6a8005ffaf0f44c976b3e9c4c121a839a7c2b617f26ccabac20db8ef93ec1b3bc163761bd538a689e8f93
7
+ data.tar.gz: f17b46fb04ef923a0803163b06be590f985187ac50a81e9d76a47329ce2503c353c98f7d90295a94a534ecefe201907d0e6aa2585764c8a76df99f2ce436bf62
@@ -42,7 +42,7 @@ module RubyRich
42
42
  self
43
43
  end
44
44
 
45
- def start(refresh_rate: 24, mouse: true, alt_screen: false)
45
+ def start(refresh_rate: 24, mouse: true, alt_screen: true)
46
46
  @state_mutex.synchronize { @state = :starting }
47
47
  Live.start(@layout, refresh_rate: refresh_rate, mouse: mouse, alt_screen: alt_screen, autowrap: false) do |live|
48
48
  @state_mutex.synchronize do
@@ -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
@@ -141,7 +147,7 @@ module RubyRich
141
147
  false
142
148
  end
143
149
 
144
- def start(refresh_rate: 24, mouse: true, alt_screen: false)
150
+ def start(refresh_rate: 24, mouse: true, alt_screen: true)
145
151
  Live.start(@layout, refresh_rate: refresh_rate, mouse: mouse, alt_screen: alt_screen, autowrap: false) do |live|
146
152
  @live = live
147
153
  live.listening = true
@@ -73,6 +73,27 @@ module RubyRich
73
73
  @menu_open
74
74
  end
75
75
 
76
+ def native_cursor_position
77
+ return nil unless focused?
78
+
79
+ input_width = [inner_width - 2, 1].max
80
+ editor_row, editor_col = @editor.cursor_visual_position(width: input_width)
81
+ raw_row = [@attachments.length, 3].min + editor_row
82
+ raw_col = 2 + editor_col
83
+
84
+ raw_height = [@height.to_i, 1].max
85
+ raw_lines = []
86
+ raw_lines.concat(render_attachments)
87
+ raw_lines.concat(render_input_lines)
88
+ raw_lines.concat(render_menu_lines) if menu_open?
89
+
90
+ clipped_rows = [raw_lines.length - raw_height, 0].max
91
+ visible_row = raw_row - clipped_rows
92
+ return nil if visible_row.negative? || visible_row >= raw_height
93
+
94
+ [visible_row, raw_col]
95
+ end
96
+
76
97
  def wants_tab?
77
98
  focused? && (menu_open? || @editor.value.include?("/"))
78
99
  end
@@ -229,7 +250,7 @@ module RubyRich
229
250
  elsif !@editor.empty?
230
251
  @editor.clear
231
252
  else
232
- blur
253
+ focus
233
254
  end
234
255
  end
235
256
 
@@ -433,6 +454,8 @@ module RubyRich
433
454
  def colorize_input(line)
434
455
  color = AnsiCode.color(:white, true)
435
456
  reset = AnsiCode.reset
457
+ return line.to_s if line.to_s.include?(AnsiCode.inverse)
458
+
436
459
  "#{color}#{line.to_s.gsub(reset, "#{reset}#{color}")}#{reset}"
437
460
  end
438
461
 
@@ -29,8 +29,8 @@ module RubyRich
29
29
  end
30
30
  end
31
31
 
32
- root_layout.key(:mouse_down, priority) do |event_data, _live|
33
- entry = entry_at(event_data[:x], event_data[:y])
32
+ root_layout.key(:mouse_target, priority) do |event_data, _live|
33
+ entry = entry_for_layout(event_data[:target_layout]) || entry_at(event_data[:x], event_data[:y])
34
34
  if entry
35
35
  focus(entry[:name])
36
36
  false
@@ -73,5 +73,16 @@ module RubyRich
73
73
  def entry_at(x, y)
74
74
  @entries.reverse.find { |entry| entry[:layout].contains?(x, y) }
75
75
  end
76
+
77
+ def entry_for_layout(layout)
78
+ current = layout
79
+ while current
80
+ entry = @entries.reverse.find { |item| item[:layout] == current }
81
+ return entry if entry
82
+
83
+ current = current.parent
84
+ end
85
+ nil
86
+ end
76
87
  end
77
88
  end
@@ -334,6 +334,7 @@ module RubyRich
334
334
 
335
335
  capture = root.instance_variable_get(:@mouse_capture)
336
336
  target = capture && [:mouse_drag, :mouse_up].include?(event_data[:name]) ? capture : (hit_test(event_data[:x], event_data[:y]) || self)
337
+ dispatch_mouse_target_event(event_data, target) if event_data[:name] == :mouse_down
337
338
  handled = target.bubble_mouse_event(event_data)
338
339
 
339
340
  root.instance_variable_set(:@mouse_capture, target) if event_data[:name] == :mouse_down && handled
@@ -341,6 +342,16 @@ module RubyRich
341
342
  handled
342
343
  end
343
344
 
345
+ def dispatch_mouse_target_event(event_data, target)
346
+ target.bubble_mouse_event(
347
+ event_data.merge(
348
+ name: :mouse_target,
349
+ target_layout: target,
350
+ original_name: event_data[:name]
351
+ )
352
+ )
353
+ end
354
+
344
355
  protected
345
356
 
346
357
  def bubble_mouse_event(event_data)
@@ -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,31 +177,63 @@ 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)
196
+ _ = focused
172
197
  content = value
173
198
  return [placeholder.to_s] if content.empty? && placeholder
174
199
 
175
200
  rendered = []
176
- line_index, col = cursor_line_col
177
- lines.each_with_index do |line, index|
178
- marker_col = focused && index == line_index ? col : nil
179
- rendered.concat(wrap_line_with_cursor(line, width, marker_col))
201
+ lines.each do |line|
202
+ rendered.concat(wrap_line_without_cursor(line, width))
180
203
  end
181
204
  rendered.empty? ? [""] : rendered
182
205
  end
183
206
 
207
+ def cursor_visual_position(width:)
208
+ line_index, cursor_col = cursor_line_col
209
+ row = 0
210
+
211
+ lines.each_with_index do |line, index|
212
+ if index == line_index
213
+ cursor_row, display_col = cursor_position_in_wrapped_line(line, width, cursor_col)
214
+ return [row + cursor_row, display_col]
215
+ end
216
+
217
+ row += wrap_line_without_cursor(line, width).length
218
+ end
219
+
220
+ [row, 0]
221
+ end
222
+
184
223
  private
185
224
 
225
+ def invalidate_content_cache
226
+ @value_cache = nil
227
+ @lines_cache = nil
228
+ @line_starts_cache = nil
229
+ invalidate_cursor_cache
230
+ end
231
+
232
+ def invalidate_cursor_cache
233
+ @cursor_line_col_cache_key = nil
234
+ @cursor_line_col_cache = nil
235
+ end
236
+
186
237
  def normalize_insert_text(text)
187
238
  incoming = text.to_s.gsub(/\r\n?/, "\n")
188
239
  return incoming if @multiline
@@ -191,23 +242,13 @@ module RubyRich
191
242
  end
192
243
 
193
244
  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
245
+ line_starts[line_index_for_cursor]
201
246
  end
202
247
 
203
248
  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
249
+ line_index = line_index_for_cursor
250
+ next_start = line_starts[line_index + 1]
251
+ next_start ? next_start - 1 : @chars.length
211
252
  end
212
253
 
213
254
  def move_vertical(delta)
@@ -216,18 +257,43 @@ module RubyRich
216
257
  return self if target_line.negative? || target_line >= lines.length
217
258
 
218
259
  @cursor = index_for_line_col(target_line, current_col)
260
+ invalidate_cursor_cache
219
261
  self
220
262
  end
221
263
 
222
264
  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
265
+ line_start = line_starts[line_index]
266
+ return @chars.length unless line_start
227
267
 
228
- index += line_length + 1
268
+ line_length = lines[line_index].chars.length
269
+ line_start + [col, line_length].min
270
+ end
271
+
272
+ def line_starts
273
+ @line_starts_cache ||= begin
274
+ starts = [0]
275
+ @chars.each_with_index do |char, index|
276
+ starts << index + 1 if char == "\n"
277
+ end
278
+ starts
279
+ end
280
+ end
281
+
282
+ def line_index_for_cursor
283
+ starts = line_starts
284
+ low = 0
285
+ high = starts.length - 1
286
+
287
+ while low <= high
288
+ mid = (low + high) / 2
289
+ if starts[mid] <= @cursor
290
+ low = mid + 1
291
+ else
292
+ high = mid - 1
293
+ end
229
294
  end
230
- @chars.length
295
+
296
+ [high, 0].max
231
297
  end
232
298
 
233
299
  def previous_word_start
@@ -281,41 +347,46 @@ module RubyRich
281
347
  nil
282
348
  end
283
349
 
284
- def wrap_line_with_cursor(line, width, marker_col)
350
+ def wrap_line_without_cursor(line, width)
285
351
  width = [width, 1].max
286
352
  segments = []
287
353
  current = +""
288
354
  current_width = 0
289
- chars = line.chars
290
- 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
355
 
356
+ line.chars.each do |char|
302
357
  char_width = display_width(char)
303
- if current_width + char_width > width
358
+ if current_width + char_width > width && !current.empty?
304
359
  segments << current
305
360
  current = +""
306
361
  current_width = 0
307
362
  end
363
+
308
364
  current << char
309
365
  current_width += char_width
310
366
  end
311
367
 
312
- current << cursor_marker if marker_col == chars.length
313
368
  segments << current
314
369
  segments
315
370
  end
316
371
 
317
- def cursor_marker
318
- "#{AnsiCode.color(:blue, true)}_#{AnsiCode.reset}"
372
+ def cursor_position_in_wrapped_line(line, width, cursor_col)
373
+ width = [width, 1].max
374
+ row = 0
375
+ display_col = 0
376
+
377
+ line.chars.each_with_index do |char, index|
378
+ char_width = display_width(char)
379
+ if display_col + char_width > width && display_col.positive?
380
+ row += 1
381
+ display_col = 0
382
+ end
383
+
384
+ return [row, display_col] if index == cursor_col
385
+
386
+ display_col += char_width
387
+ end
388
+
389
+ [row, display_col]
319
390
  end
320
391
 
321
392
  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,28 +204,90 @@ 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)
239
+ position_native_cursor
240
+ end
241
+
242
+ def position_native_cursor
243
+ composer_layout = @layout[:composer]
244
+ return unless composer_layout
245
+
246
+ frame = composer_layout.content
247
+ component = frame.instance_variable_get(:@component) if frame
248
+ return unless component&.respond_to?(:native_cursor_position)
249
+
250
+ cursor = component.native_cursor_position
251
+ return unless cursor
252
+
253
+ row, col = cursor
254
+ terminal_row = composer_layout.y_offset.to_i + 1 + row.to_i
255
+ terminal_col = composer_layout.x_offset.to_i + 1 + col.to_i
256
+ print "\e[#{terminal_row + 1};#{terminal_col + 1}H"
257
+ $stdout.flush
258
+ end
259
+
260
+ def wait_for_activity
261
+ @wake_mutex.synchronize do
262
+ return unless @running
263
+ return unless @action_queue.empty?
264
+ return if @listening && !@event_queue.empty?
265
+
266
+ @wake_condition.wait(@wake_mutex, RESIZE_POLL_INTERVAL)
267
+ end
268
+ end
269
+
270
+ def wake
271
+ @wake_mutex.synchronize { @wake_condition.signal }
272
+ end
273
+
274
+ def mark_dirty
275
+ @wake_mutex.synchronize { @dirty = true }
276
+ end
277
+
278
+ def consume_dirty
279
+ @wake_mutex.synchronize do
280
+ dirty = @dirty
281
+ @dirty = false
282
+ dirty
283
+ end
284
+ end
285
+
286
+ def terminal_size_changed?
287
+ current_size = [terminal_width, terminal_height]
288
+ changed = @last_terminal_size != current_size
289
+ @last_terminal_size = current_size
290
+ changed
189
291
  end
190
292
 
191
293
  def terminal_width
@@ -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
@@ -42,7 +42,7 @@ module RubyRich
42
42
  class << self
43
43
  attr_accessor :debug_input
44
44
 
45
- def setup(mouse: false, hide_cursor: true, autowrap: true, alt_screen: false)
45
+ def setup(mouse: false, hide_cursor: false, autowrap: true, alt_screen: false)
46
46
  capture_state
47
47
  enable_virtual_terminal_on_windows
48
48
  system('stty -echo') unless windows?
@@ -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.5"
3
3
  end
@@ -14,6 +14,7 @@ module RubyRich
14
14
  @height = 0
15
15
  @scroll_top = 0
16
16
  @dragging_scrollbar = false
17
+ @dragging_viewport = false
17
18
  @drag_start_y = nil
18
19
  @drag_start_scroll_top = nil
19
20
  @selecting = false
@@ -21,6 +22,8 @@ module RubyRich
21
22
  @selection_end = nil
22
23
  @selected_text = ""
23
24
  @focused = true
25
+ @rendered_lines_cache_key = nil
26
+ @rendered_lines_cache = nil
24
27
  end
25
28
 
26
29
  def focus
@@ -36,6 +39,7 @@ module RubyRich
36
39
  def content=(new_content)
37
40
  was_at_bottom = at_bottom?
38
41
  @content = new_content
42
+ invalidate_rendered_lines
39
43
  scroll_to_bottom if @auto_scroll && was_at_bottom
40
44
  clamp_scroll
41
45
  end
@@ -59,7 +63,6 @@ module RubyRich
59
63
 
60
64
  def handle_event(event_data, layout = nil)
61
65
  return false if keyboard_event?(event_data) && !@focused
62
- return false if mouse_event?(event_data) && !@focused
63
66
 
64
67
  case event_data[:name]
65
68
  when :page_up
@@ -86,11 +89,11 @@ module RubyRich
86
89
  when :mouse_down
87
90
  return copy_selection if event_data[:button] == :right
88
91
 
89
- start_scrollbar_drag(event_data, layout) || start_selection(event_data, layout)
92
+ start_scrollbar_drag(event_data, layout) || start_viewport_drag(event_data, layout)
90
93
  when :mouse_drag
91
- drag_scrollbar(event_data, layout) || drag_selection(event_data, layout)
94
+ drag_scrollbar(event_data, layout) || drag_viewport(event_data, layout) || drag_selection(event_data, layout)
92
95
  when :mouse_up
93
- stop_scrollbar_drag || stop_selection
96
+ stop_scrollbar_drag || stop_viewport_drag || stop_selection
94
97
  else
95
98
  false
96
99
  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
@@ -286,6 +352,18 @@ module RubyRich
286
352
  true
287
353
  end
288
354
 
355
+ def start_viewport_drag(event_data, layout)
356
+ return false unless layout
357
+ return false unless event_data[:button] == :left
358
+ return false if event_data[:x] >= layout.x_offset + content_width
359
+ return false unless event_data[:y].between?(layout.y_offset, layout.y_offset + @height - 1)
360
+
361
+ @dragging_viewport = true
362
+ @drag_start_y = event_data[:y]
363
+ @drag_start_scroll_top = @scroll_top
364
+ true
365
+ end
366
+
289
367
  def drag_scrollbar(event_data, layout)
290
368
  return false unless @dragging_scrollbar && layout
291
369
 
@@ -301,6 +379,14 @@ module RubyRich
301
379
  true
302
380
  end
303
381
 
382
+ def drag_viewport(event_data, layout)
383
+ return false unless @dragging_viewport && layout
384
+
385
+ delta_y = event_data[:y] - @drag_start_y
386
+ scroll_to(@drag_start_scroll_top - delta_y)
387
+ true
388
+ end
389
+
304
390
  def drag_selection(event_data, layout)
305
391
  return false unless @selecting && layout
306
392
 
@@ -316,6 +402,14 @@ module RubyRich
316
402
  was_dragging
317
403
  end
318
404
 
405
+ def stop_viewport_drag
406
+ was_dragging = @dragging_viewport
407
+ @dragging_viewport = false
408
+ @drag_start_y = nil
409
+ @drag_start_scroll_top = nil
410
+ was_dragging
411
+ end
412
+
319
413
  def stop_selection
320
414
  return false unless @selecting
321
415
 
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.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - zhuang biaowei