rcurses 4.9.0 → 4.9.3

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.
data/lib/rcurses/pane.rb DELETED
@@ -1,765 +0,0 @@
1
- module Rcurses
2
- # Enhanced display_width function with better Unicode support
3
- def self.display_width(str)
4
- width = 0
5
- str.each_char do |char|
6
- cp = char.ord
7
-
8
- # Handle NUL and control characters
9
- if cp == 0
10
- # NUL – no width
11
- next
12
- elsif cp < 32 || (cp >= 0x7F && cp < 0xA0)
13
- # Control characters: no width
14
- next
15
- end
16
-
17
- # Handle combining characters (zero width)
18
- if (cp >= 0x0300 && cp <= 0x036F) || # Combining Diacritical Marks
19
- (cp >= 0x1AB0 && cp <= 0x1AFF) || # Combining Diacritical Marks Extended
20
- (cp >= 0x1DC0 && cp <= 0x1DFF) || # Combining Diacritical Marks Supplement
21
- (cp >= 0x20D0 && cp <= 0x20FF) || # Combining Diacritical Marks for Symbols
22
- (cp >= 0xFE20 && cp <= 0xFE2F) # Combining Half Marks
23
- next
24
- end
25
-
26
- # Handle wide characters (East Asian width)
27
- if (cp >= 0x1100 && cp <= 0x115F) || # Hangul Jamo
28
- (cp >= 0x2329 && cp <= 0x232A) || # Left/Right-Pointing Angle Bracket
29
- (cp >= 0x2E80 && cp <= 0x2EFF) || # CJK Radicals Supplement
30
- (cp >= 0x2F00 && cp <= 0x2FDF) || # Kangxi Radicals
31
- (cp >= 0x2FF0 && cp <= 0x2FFF) || # Ideographic Description Characters
32
- (cp >= 0x3000 && cp <= 0x303E) || # CJK Symbols and Punctuation
33
- (cp >= 0x3041 && cp <= 0x3096) || # Hiragana
34
- (cp >= 0x30A1 && cp <= 0x30FA) || # Katakana
35
- (cp >= 0x3105 && cp <= 0x312D) || # Bopomofo
36
- (cp >= 0x3131 && cp <= 0x318E) || # Hangul Compatibility Jamo
37
- (cp >= 0x3190 && cp <= 0x31BA) || # Kanbun
38
- (cp >= 0x31C0 && cp <= 0x31E3) || # CJK Strokes
39
- (cp >= 0x31F0 && cp <= 0x31FF) || # Katakana Phonetic Extensions
40
- (cp >= 0x3200 && cp <= 0x32FF) || # Enclosed CJK Letters and Months
41
- (cp >= 0x3300 && cp <= 0x33FF) || # CJK Compatibility
42
- (cp >= 0x3400 && cp <= 0x4DBF) || # CJK Unified Ideographs Extension A
43
- (cp >= 0x4E00 && cp <= 0x9FFF) || # CJK Unified Ideographs
44
- (cp >= 0xA960 && cp <= 0xA97F) || # Hangul Jamo Extended-A
45
- (cp >= 0xAC00 && cp <= 0xD7A3) || # Hangul Syllables
46
- (cp >= 0xD7B0 && cp <= 0xD7FF) || # Hangul Jamo Extended-B
47
- (cp >= 0xF900 && cp <= 0xFAFF) || # CJK Compatibility Ideographs
48
- (cp >= 0xFE10 && cp <= 0xFE19) || # Vertical Forms
49
- (cp >= 0xFE30 && cp <= 0xFE6F) || # CJK Compatibility Forms
50
- (cp >= 0xFF00 && cp <= 0xFF60) || # Fullwidth Forms
51
- (cp >= 0xFFE0 && cp <= 0xFFE6) || # Fullwidth Forms
52
- (cp >= 0x1F000 && cp <= 0x1F02F) || # Mahjong Tiles
53
- (cp >= 0x1F030 && cp <= 0x1F09F) || # Domino Tiles
54
- (cp >= 0x1F100 && cp <= 0x1F1FF) || # Enclosed Alphanumeric Supplement
55
- (cp >= 0x1F200 && cp <= 0x1F2FF) || # Enclosed Ideographic Supplement
56
- (cp >= 0x1F300 && cp <= 0x1F5FF) || # Miscellaneous Symbols and Pictographs
57
- (cp >= 0x1F600 && cp <= 0x1F64F) || # Emoticons
58
- (cp >= 0x1F650 && cp <= 0x1F67F) || # Ornamental Dingbats
59
- (cp >= 0x1F680 && cp <= 0x1F6FF) || # Transport and Map Symbols
60
- (cp >= 0x1F700 && cp <= 0x1F77F) || # Alchemical Symbols
61
- (cp >= 0x1F780 && cp <= 0x1F7FF) || # Geometric Shapes Extended
62
- (cp >= 0x1F800 && cp <= 0x1F8FF) || # Supplemental Arrows-C
63
- (cp >= 0x1F900 && cp <= 0x1F9FF) || # Supplemental Symbols and Pictographs
64
- (cp >= 0x20000 && cp <= 0x2FFFF) || # CJK Unified Ideographs Extension B-F
65
- (cp >= 0x30000 && cp <= 0x3FFFF) # CJK Unified Ideographs Extension G
66
- width += 2
67
- else
68
- width += 1
69
- end
70
- end
71
- width
72
- end
73
-
74
- class Pane
75
- require 'clipboard' # Ensure the 'clipboard' gem is installed
76
- include Cursor
77
- include Input
78
-
79
- # Compiled regex patterns for performance
80
- ANSI_REGEX = /\e\[[0-9;]*m/.freeze
81
- SGR_REGEX = /\e\[\d+(?:;\d+)*m/.freeze
82
- attr_accessor :x, :y, :w, :h, :fg, :bg
83
- attr_accessor :border, :scroll, :text, :ix, :index, :align, :prompt
84
- attr_accessor :moreup, :moredown
85
- attr_accessor :record, :history
86
- attr_accessor :updates_suspended
87
-
88
- def initialize(x = 1, y = 1, w = 1, h = 1, fg = nil, bg = nil)
89
- @terminal_size_cache = nil
90
- @terminal_size_time = nil
91
- @x = x
92
- @y = y
93
- @w = w
94
- @h = h
95
- @fg, @bg = fg, bg
96
- @text = "" # Initialize text variable
97
- @align = "l" # Default alignment
98
- @scroll = true # Enable scroll indicators
99
- @prompt = "" # Prompt for editline
100
- @ix = 0 # Starting text line index
101
- @prev_frame = nil # Holds the previously rendered frame (array of lines)
102
- @line = 0 # For cursor tracking during editing:
103
- @pos = 0 # For cursor tracking during editing:
104
- @record = false # Don't record history unless explicitly set to true
105
- @history = [] # History array
106
- @updates_suspended = false
107
- @lazy_cache_limit = 1000 # Limit for lazy text cache
108
- end
109
-
110
- def text=(new_text)
111
- (@history << @text) if @record && @text
112
- @text = new_text
113
- end
114
-
115
- def ask(prompt, text)
116
- @prompt = prompt
117
- @text = text
118
- editline
119
- (@history << @text) if @record && !@text.empty?
120
- @text
121
- end
122
-
123
- def say(text)
124
- (@history << text) if @record && !text.empty?
125
- @text = text
126
- @ix = 0
127
- refresh
128
- end
129
-
130
- def clear
131
- @text = ""
132
- @ix = 0
133
- full_refresh
134
- end
135
-
136
- def suspend_updates
137
- @updates_suspended = true
138
- end
139
-
140
- def resume_updates
141
- @updates_suspended = false
142
- refresh
143
- end
144
-
145
- def get_terminal_size
146
- now = Time.now
147
- if @terminal_size_cache.nil? || @terminal_size_time.nil? || (now - @terminal_size_time) > 0.5
148
- @terminal_size_cache = IO.console.winsize
149
- @terminal_size_time = now
150
- end
151
- @terminal_size_cache
152
- end
153
-
154
- def move(dx, dy)
155
- @x += dx
156
- @y += dy
157
- refresh
158
- end
159
-
160
- def linedown
161
- @ix += 1
162
- text_lines = @text.split("\n")
163
- @ix = text_lines.length if @ix > text_lines.length - 1
164
- refresh
165
- end
166
-
167
- def lineup
168
- @ix -= 1
169
- @ix = 0 if @ix < 0
170
- refresh
171
- end
172
-
173
- def pagedown
174
- @ix = @ix + @h - 1
175
- text_lines = @text.split("\n")
176
- @ix = text_lines.length - @h if @ix > text_lines.length - @h
177
- refresh
178
- end
179
-
180
- def pageup
181
- @ix = @ix - @h + 1
182
- @ix = 0 if @ix < 0
183
- refresh
184
- end
185
-
186
- def bottom
187
- text_lines = @text.split("\n")
188
- @ix = text_lines.length - @h
189
- refresh
190
- end
191
-
192
- def top
193
- @ix = 0
194
- refresh
195
- end
196
-
197
- # full_refresh forces a complete repaint.
198
- def full_refresh(cont = @text)
199
- @prev_frame = nil
200
- refresh(cont)
201
- end
202
-
203
- # Refresh only the border
204
- def border_refresh
205
- left_col = @x - 1
206
- right_col = @x + @w
207
- top_row = @y - 1
208
- bottom_row = @y + @h
209
-
210
- if @border
211
- fmt = [@fg.to_s, @bg.to_s].join(',')
212
- top = ("┌" + "─" * @w + "┐").c(fmt)
213
- STDOUT.print "\e[#{top_row};#{left_col}H" + top
214
- (0...@h).each do |i|
215
- row = @y + i
216
- STDOUT.print "\e[#{row};#{left_col}H" + "│".c(fmt)
217
- STDOUT.print "\e[#{row};#{right_col}H" + "│".c(fmt)
218
- end
219
- bottom = ("└" + "─" * @w + "┘").c(fmt)
220
- STDOUT.print "\e[#{bottom_row};#{left_col}H" + bottom
221
- else
222
- STDOUT.print "\e[#{top_row};#{left_col}H" + " " * (@w + 2)
223
- (0...@h).each do |i|
224
- row = @y + i
225
- STDOUT.print "\e[#{row};#{left_col}H" + " "
226
- STDOUT.print "\e[#{row};#{right_col}H" + " "
227
- end
228
- STDOUT.print "\e[#{bottom_row};#{left_col}H" + " " * (@w + 2)
229
- end
230
- end
231
-
232
- # Diff-based refresh that minimizes flicker.
233
- # In this updated version we lazily process only the raw lines required to fill the pane.
234
- def refresh(cont = @text)
235
- return if @updates_suspended
236
-
237
- # Check if we're in batch mode and suspend updates accordingly
238
- if Rcurses.batch_mode?
239
- Rcurses.add_to_batch(self)
240
- return
241
- end
242
-
243
- @max_h, @max_w = get_terminal_size
244
-
245
- if @border
246
- @w = @max_w - 2 if @w > @max_w - 2
247
- @h = @max_h - 2 if @h > @max_h - 2
248
- @x = 2 if @x < 2; @x = @max_w - @w if @x + @w > @max_w
249
- @y = 2 if @y < 2; @y = @max_h - @h if @y + @h > @max_h
250
- else
251
- @w = @max_w if @w > @max_w
252
- @h = @max_h if @h > @max_h
253
- @x = 1 if @x < 1; @x = @max_w - @w + 1 if @x + @w > @max_w + 1
254
- @y = 1 if @y < 1; @y = @max_h - @h + 1 if @y + @h > @max_h + 1
255
- end
256
-
257
- o_row, o_col = pos
258
-
259
- # Hide cursor, disable auto-wrap, reset all SGR and scroll margins
260
- # (so stray underline, scroll regions, etc. can’t leak out)
261
- STDOUT.print "\e[?25l\e[?7l\e[0m\e[r"
262
-
263
- fmt = [@fg.to_s, @bg.to_s].join(',')
264
-
265
- # Lazy evaluation: If the content or pane width has changed, reinitialize the lazy cache.
266
- if !defined?(@cached_text) || @cached_text != cont || @cached_w != @w
267
- @raw_txt = cont.split("\n")
268
- @lazy_txt = [] # This will hold the processed (wrapped) lines as needed.
269
- @lazy_index = 0 # Pointer to the next raw line to process.
270
- @cached_text = cont.dup
271
- @cached_w = @w
272
-
273
- # Clear cache if it gets too large to prevent memory leaks
274
- if @lazy_txt.size > @lazy_cache_limit
275
- @lazy_txt.clear
276
- @lazy_index = 0
277
- end
278
- end
279
-
280
- content_rows = @h
281
- # Ensure we have processed enough lines for the current scroll position + visible area.
282
- required_lines = @ix + content_rows
283
- while @lazy_txt.size < required_lines && @lazy_index < @raw_txt.size
284
- raw_line = @raw_txt[@lazy_index]
285
- # If the raw line is short, no wrapping is needed.
286
- if raw_line.respond_to?(:pure) && Rcurses.display_width(raw_line.pure) < @w
287
- processed = [raw_line]
288
- else
289
- processed = split_line_with_ansi(raw_line, @w)
290
- end
291
- @lazy_txt.concat(processed)
292
- @lazy_index += 1
293
- end
294
- @txt = @lazy_txt
295
-
296
- @ix = @txt.length - 1 if @ix > @txt.length - 1
297
- @ix = 0 if @ix < 0
298
-
299
- new_frame = []
300
-
301
- content_rows.times do |i|
302
- line_str = ""
303
- l = @ix + i
304
- if @txt[l].to_s != ""
305
- pl = @w - Rcurses.display_width(@txt[l].pure)
306
- pl = 0 if pl < 0
307
- hl = pl / 2
308
- case @align
309
- when "l"
310
- line_str = @txt[l].pure.c(fmt) + " ".c(fmt) * pl
311
- when "r"
312
- line_str = " ".c(fmt) * pl + @txt[l].pure.c(fmt)
313
- when "c"
314
- line_str = " ".c(fmt) * hl + @txt[l].pure.c(fmt) + " ".c(fmt) * (pl - hl)
315
- end
316
- else
317
- line_str = " ".c(fmt) * @w
318
- end
319
-
320
- new_frame << line_str
321
- end
322
-
323
- diff_buf = ""
324
- new_frame.each_with_index do |line, i|
325
- row_num = @y + i
326
- col_num = @x
327
- if @prev_frame.nil? || @prev_frame[i] != line
328
- diff_buf << "\e[#{row_num};#{col_num}H" << line
329
- end
330
- end
331
-
332
- # restore wrap, then also reset SGR and scroll-region one more time
333
- diff_buf << "\e[#{o_row};#{o_col}H\e[?7h\e[0m\e[r"
334
- print diff_buf
335
- @prev_frame = new_frame
336
-
337
- # Draw scroll markers after printing the frame.
338
- if @scroll
339
- marker_col = @x + @w - 1
340
- if @ix > 0
341
- print "\e[#{@y};#{marker_col}H" + "∆".c(fmt)
342
- end
343
- # If there are more processed lines than fit in the pane
344
- # OR there remain raw lines to process, show the down marker.
345
- if (@txt.length - @ix) > @h || (@lazy_index < @raw_txt.size)
346
- print "\e[#{@y + @h - 1};#{marker_col}H" + "∇".c(fmt)
347
- end
348
- end
349
-
350
- if @border
351
- # top
352
- print "\e[#{@y - 1};#{@x - 1}H" + ("┌" + "─" * @w + "┐").c(fmt)
353
- # sides
354
- (0...@h).each do |i|
355
- print "\e[#{@y + i};#{@x - 1}H" + "│".c(fmt)
356
- print "\e[#{@y + i};#{@x + @w}H" + "│".c(fmt)
357
- end
358
- # bottom
359
- print "\e[#{@y + @h};#{@x - 1}H" + ("└" + "─" * @w + "┘").c(fmt)
360
- end
361
-
362
- new_frame.join("\n")
363
- end
364
-
365
- def textformat(cont)
366
- # This method is no longer used in refresh since we process lazily,
367
- # but is kept here if needed elsewhere.
368
- lines = cont.split("\n")
369
- result = []
370
- lines.each do |line|
371
- split_lines = split_line_with_ansi(line, @w)
372
- result.concat(split_lines)
373
- end
374
- result
375
- end
376
-
377
- def right
378
- if @pos < @txt[@ix + @line].length
379
- @pos += 1
380
- if @pos == @w
381
- @pos = 0
382
- if @line == @h - 1
383
- @ix += 1
384
- else
385
- @line += 1
386
- end
387
- end
388
- else
389
- if @line == @h - 1
390
- @ix += 1 unless @ix >= @txt.length - @h
391
- @pos = 0
392
- elsif @line + @ix + 1 < @txt.length
393
- @line += 1
394
- @pos = 0
395
- end
396
- end
397
- end
398
-
399
- def left
400
- if @pos == 0
401
- if @line == 0
402
- unless @ix == 0
403
- @ix -= 1
404
- @pos = @txt[@ix + @line].length
405
- end
406
- else
407
- @line -= 1
408
- @pos = @txt[@ix + @line].length
409
- end
410
- else
411
- @pos -= 1
412
- end
413
- end
414
-
415
- def up
416
- if @line == 0
417
- @ix -= 1 unless @ix == 0
418
- else
419
- @line -= 1
420
- end
421
- begin
422
- @pos = [@pos, @txt[@ix + @line].length].min
423
- rescue
424
- end
425
- end
426
-
427
- def down
428
- if @line == @h - 1
429
- @ix += 1 unless @ix + @line >= @txt.length - 1
430
- elsif @line + @ix + 1 < @txt.length
431
- @line += 1
432
- end
433
- begin
434
- @pos = [@pos, @txt[@ix + @line].length].min
435
- rescue
436
- end
437
- end
438
-
439
- def parse(cont)
440
- cont.gsub!(/\*(.+?)\*/, '\1'.b)
441
- cont.gsub!(/\/(.+?)\//, '\1'.i)
442
- cont.gsub!(/_(.+?)_/, '\1'.u)
443
- cont.gsub!(/#(.+?)#/, '\1'.r)
444
- cont.gsub!(/<([^|]+)\|([^>]+)>/) do
445
- text = $2; codes = $1
446
- text.c(codes)
447
- end
448
- cont
449
- end
450
-
451
- def edit
452
- begin
453
- STDIN.cooked! rescue nil
454
- STDIN.echo = true rescue nil
455
- # Prepare content with visible newline markers
456
- content = @text.pure.gsub("\n", "¬\n")
457
- # Reset editing cursor state
458
- @ix = 0
459
- @line = 0
460
- @pos = 0
461
- # Initial render sets @txt internally for display and cursor math
462
- refresh(content)
463
- Rcurses::Cursor.show
464
- input_char = ''
465
-
466
- while input_char != 'ESC'
467
- # Move the terminal cursor to the logical text cursor
468
- row(@y + @line)
469
- col(@x + @pos)
470
- input_char = getchr(flush: false)
471
- case input_char
472
- when 'C-L'
473
- @align = 'l'
474
- when 'C-R'
475
- @align = 'r'
476
- when 'C-C'
477
- @align = 'c'
478
- when 'C-Y'
479
- Clipboard.copy(@text.pure)
480
- when 'C-S'
481
- content = content.gsub('¬', "\n")
482
- content = parse(content)
483
- @text = content
484
- input_char = 'ESC'
485
- when 'DEL'
486
- posx = calculate_posx
487
- content.slice!(posx)
488
- when 'BACK'
489
- if @pos > 0
490
- left
491
- posx = calculate_posx
492
- content.slice!(posx)
493
- end
494
- when 'WBACK'
495
- while @pos > 0 && content[calculate_posx - 1] != ' '
496
- left
497
- posx = calculate_posx
498
- content.slice!(posx)
499
- end
500
- when 'C-K'
501
- line_start_pos = calculate_line_start_pos
502
- line_length = @txt[@ix + @line]&.length || 0
503
- content.slice!(line_start_pos + @pos, line_length - @pos)
504
- when 'UP'
505
- up
506
- when 'DOWN'
507
- down
508
- when 'RIGHT'
509
- right
510
- when 'LEFT'
511
- left
512
- when 'HOME'
513
- @pos = 0
514
- when 'END'
515
- current_line_length = @txt[@ix + @line]&.length || 0
516
- @pos = current_line_length
517
- when 'C-HOME'
518
- @ix = 0; @line = 0; @pos = 0
519
- when 'C-END'
520
- total_lines = @txt.length
521
- @ix = [total_lines - @h, 0].max
522
- @line = [@h - 1, total_lines - @ix - 1].min
523
- current_line_length = @txt[@ix + @line]&.length || 0
524
- @pos = current_line_length
525
- when 'ENTER'
526
- posx = calculate_posx
527
- content.insert(posx, "¬\n")
528
- right
529
- when /^.$/
530
- posx = calculate_posx
531
- content.insert(posx, input_char)
532
- right
533
- end
534
-
535
- # Handle any buffered input
536
- while IO.select([$stdin], nil, nil, 0)
537
- input_char = $stdin.read_nonblock(1) rescue nil
538
- break unless input_char
539
- posx = calculate_posx
540
- content.insert(posx, input_char)
541
- right
542
- end
543
-
544
- # Re-render without overwriting the internal @txt
545
- refresh(content)
546
- Rcurses::Cursor.show
547
- end
548
- ensure
549
- STDIN.raw! rescue nil
550
- STDIN.echo = false rescue nil
551
- while IO.select([$stdin], nil, nil, 0)
552
- $stdin.read_nonblock(4096) rescue break
553
- end
554
- end
555
- Rcurses::Cursor.hide
556
- end
557
-
558
- def editline
559
- begin
560
- STDIN.cooked! rescue nil
561
- STDIN.echo = true rescue nil
562
- Rcurses::Cursor.show
563
- @x = [[@x, 1].max, @max_w - @w + 1].min
564
- @y = [[@y, 1].max, @max_h - @h + 1].min
565
- @scroll = false
566
- @ix = 0
567
- row(@y)
568
- fmt = [@fg.to_s, @bg.to_s].join(',')
569
- col(@x)
570
- print @prompt.c(fmt)
571
- prompt_len = @prompt.pure.length
572
- content_len = @w - prompt_len
573
- cont = @text.pure.slice(0, content_len)
574
- @pos = cont.length
575
- chr = ''
576
- history_index = @history.size
577
-
578
- while chr != 'ESC'
579
- col(@x + prompt_len)
580
- cont = cont.slice(0, content_len)
581
- print cont.ljust(content_len).c(fmt)
582
- col(@x + prompt_len + @pos)
583
- chr = getchr(flush: false)
584
- case chr
585
- when 'LEFT'
586
- @pos -= 1 if @pos > 0
587
- when 'RIGHT'
588
- @pos += 1 if @pos < cont.length
589
- when 'HOME'
590
- @pos = 0
591
- when 'END'
592
- @pos = cont.length
593
- when 'DEL'
594
- cont[@pos] = '' if @pos < cont.length
595
- when 'BACK'
596
- if @pos > 0
597
- @pos -= 1
598
- cont[@pos] = ''
599
- end
600
- when 'WBACK'
601
- while @pos > 0 && cont[@pos - 1] != ' '
602
- @pos -= 1
603
- cont[@pos] = ''
604
- end
605
- when 'C-K'
606
- cont = ''
607
- @pos = 0
608
- when 'ENTER'
609
- @text = cont
610
- chr = 'ESC'
611
- when 'UP'
612
- if @history.any? && history_index > 0
613
- history_index -= 1
614
- cont = @history[history_index].pure.slice(0, content_len)
615
- @pos = cont.length
616
- end
617
- when 'DOWN'
618
- if history_index < @history.size - 1
619
- history_index += 1
620
- cont = @history[history_index].pure.slice(0, content_len)
621
- @pos = cont.length
622
- elsif history_index == @history.size - 1
623
- history_index += 1
624
- cont = ""
625
- @pos = 0
626
- end
627
- when /^.$/
628
- if @pos < content_len
629
- cont.insert(@pos, chr)
630
- @pos += 1
631
- end
632
- end
633
-
634
- while IO.select([$stdin], nil, nil, 0)
635
- chr = $stdin.read_nonblock(1) rescue nil
636
- break unless chr
637
- if @pos < content_len
638
- cont.insert(@pos, chr)
639
- @pos += 1
640
- end
641
- end
642
- end
643
- ensure
644
- STDIN.raw! rescue nil
645
- STDIN.echo = false rescue nil
646
- while IO.select([$stdin], nil, nil, 0)
647
- $stdin.read_nonblock(4096) rescue break
648
- end
649
- end
650
- prompt_len = @prompt.pure.length
651
- new_col = @x + prompt_len + (@pos > 0 ? @pos - 1 : 0)
652
- col(new_col)
653
- Rcurses::Cursor.hide
654
- end
655
-
656
- private
657
-
658
- def flush_stdin
659
- while IO.select([$stdin], nil, nil, 0.005)
660
- begin
661
- $stdin.read_nonblock(1024)
662
- rescue IO::WaitReadable, EOFError
663
- break
664
- end
665
- end
666
- end
667
-
668
- def calculate_posx
669
- total_length = 0
670
- (@ix + @line).times do |i|
671
- total_length += Rcurses.display_width(@txt[i].pure) + 1 # +1 for newline
672
- end
673
- total_length += @pos
674
- total_length
675
- end
676
-
677
- def calculate_line_start_pos
678
- total_length = 0
679
- (@ix + @line).times do |i|
680
- total_length += Rcurses.display_width(@txt[i].pure) + 1
681
- end
682
- total_length
683
- end
684
-
685
- def split_line_with_ansi(line, w)
686
- open_sequences = {
687
- "\e[1m" => "\e[22m",
688
- "\e[3m" => "\e[23m",
689
- "\e[4m" => "\e[24m",
690
- "\e[5m" => "\e[25m",
691
- "\e[7m" => "\e[27m"
692
- }
693
- close_sequences = open_sequences.values + ["\e[0m"]
694
- result = []
695
- tokens = line.scan(/(\e\[[0-9;]*m|[^\e]+)/).flatten.compact
696
- current_line = ''
697
- current_line_length = 0
698
- active_sequences = []
699
- tokens.each do |token|
700
- if token.match?(ANSI_REGEX)
701
- current_line << token
702
- if close_sequences.include?(token)
703
- if token == "\e[0m"
704
- active_sequences.clear
705
- else
706
- corresponding_open = open_sequences.key(token)
707
- active_sequences.delete(corresponding_open)
708
- end
709
- else
710
- active_sequences << token
711
- end
712
- else
713
- words = token.scan(/\s+|\S+/)
714
- words.each do |word|
715
- word_length = Rcurses.display_width(word.gsub(ANSI_REGEX, ''))
716
- if current_line_length + word_length <= w
717
- current_line << word
718
- current_line_length += word_length
719
- else
720
- if current_line_length > 0
721
- result << current_line
722
- current_line = active_sequences.join
723
- current_line_length = 0
724
- end
725
- while word_length > w
726
- # Split safely respecting UTF-8 boundaries and display width
727
- part = safe_substring_by_width(word, w)
728
- current_line << part
729
- result << current_line
730
- word = word[part.length..-1]
731
- word_length = Rcurses.display_width(word.gsub(ANSI_REGEX, ''))
732
- current_line = active_sequences.join
733
- current_line_length = 0
734
- end
735
- if word_length > 0
736
- current_line << word
737
- current_line_length += word_length
738
- end
739
- end
740
- end
741
- end
742
- end
743
- result << current_line unless current_line.empty?
744
- result
745
- end
746
-
747
- # Helper method to safely split strings by display width while respecting UTF-8 boundaries
748
- def safe_substring_by_width(str, max_width)
749
- return str if Rcurses.display_width(str) <= max_width
750
-
751
- result = ''
752
- current_width = 0
753
-
754
- str.each_char do |char|
755
- char_width = Rcurses.display_width(char)
756
- break if current_width + char_width > max_width
757
- result += char
758
- current_width += char_width
759
- end
760
-
761
- result
762
- end
763
- end
764
- end
765
-