rcurses 4.9.4 → 5.0.0

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.
@@ -0,0 +1,715 @@
1
+ module Rcurses
2
+ class Pane
3
+ require 'clipboard' # Ensure the 'clipboard' gem is installed
4
+ include Cursor
5
+ include Input
6
+ attr_accessor :x, :y, :w, :h, :fg, :bg
7
+ attr_accessor :border, :scroll, :text, :ix, :index, :align, :prompt
8
+ attr_accessor :moreup, :moredown
9
+ attr_accessor :record, :history
10
+
11
+ def initialize(x = 1, y = 1, w = 1, h = 1, fg = nil, bg = nil)
12
+ @max_h, @max_w = IO.console.winsize
13
+ @x = x
14
+ @y = y
15
+ @w = w
16
+ @h = h
17
+ @fg, @bg = fg, bg
18
+ @text = "" # Initialize text variable
19
+ @align = "l" # Default alignment
20
+ @scroll = true # Enable scroll indicators
21
+ @prompt = "" # Prompt for editline
22
+ @ix = 0 # Starting text line index
23
+ @prev_frame = nil # Holds the previously rendered frame (array of lines)
24
+ @line = 0 # For cursor tracking during editing:
25
+ @pos = 0 # For cursor tracking during editing:
26
+ @record = false # Don't record history unless explicitly set to true
27
+ @history = [] # History array
28
+ @max_history_size = 100 # Limit history to prevent memory leaks
29
+
30
+ ObjectSpace.define_finalizer(self, self.class.finalizer_proc)
31
+ end
32
+
33
+ def text=(new_text)
34
+ if @record && @text
35
+ @history << @text
36
+ @history.shift while @history.size > @max_history_size
37
+ end
38
+ @text = new_text
39
+ end
40
+
41
+ def ask(prompt, text)
42
+ @prompt = prompt
43
+ @text = text
44
+ editline
45
+ if @record && !@text.empty?
46
+ @history << @text
47
+ @history.shift while @history.size > @max_history_size
48
+ end
49
+ @text
50
+ end
51
+
52
+ def say(text)
53
+ if @record && !text.empty?
54
+ @history << text
55
+ @history.shift while @history.size > @max_history_size
56
+ end
57
+ @text = text
58
+ @ix = 0
59
+ refresh
60
+ end
61
+
62
+ def clear
63
+ @text = ""
64
+ @ix = 0
65
+ full_refresh
66
+ end
67
+
68
+ def cleanup
69
+ @prev_frame = nil
70
+ @lazy_txt = nil
71
+ @raw_txt = nil
72
+ @cached_text = nil
73
+ @txt = nil
74
+ @history.clear if @history
75
+ end
76
+
77
+ def self.finalizer_proc
78
+ proc do
79
+ # Cleanup code that doesn't reference instance variables
80
+ # since the object is already being finalized
81
+ end
82
+ end
83
+
84
+ def move(dx, dy)
85
+ @x += dx
86
+ @y += dy
87
+ refresh
88
+ end
89
+
90
+ def linedown
91
+ @ix += 1
92
+ @ix = @text.split("\n").length if @ix > @text.split("\n").length - 1
93
+ refresh
94
+ end
95
+
96
+ def lineup
97
+ @ix -= 1
98
+ @ix = 0 if @ix < 0
99
+ refresh
100
+ end
101
+
102
+ def pagedown
103
+ @ix = @ix + @h - 1
104
+ @ix = @text.split("\n").length - @h if @ix > @text.split("\n").length - @h
105
+ refresh
106
+ end
107
+
108
+ def pageup
109
+ @ix = @ix - @h + 1
110
+ @ix = 0 if @ix < 0
111
+ refresh
112
+ end
113
+
114
+ def bottom
115
+ @ix = @text.split("\n").length - @h
116
+ refresh
117
+ end
118
+
119
+ def top
120
+ @ix = 0
121
+ refresh
122
+ end
123
+
124
+ # full_refresh forces a complete repaint.
125
+ def full_refresh(cont = @text)
126
+ @prev_frame = nil
127
+ refresh(cont)
128
+ end
129
+
130
+ # Refresh only the border
131
+ def border_refresh
132
+ left_col = @x - 1
133
+ right_col = @x + @w
134
+ top_row = @y - 1
135
+ bottom_row = @y + @h
136
+
137
+ if @border
138
+ fmt = [@fg.to_s, @bg.to_s].join(',')
139
+ top = ("┌" + "─" * @w + "┐").c(fmt)
140
+ STDOUT.print "\e[#{top_row};#{left_col}H" + top
141
+ (0...@h).each do |i|
142
+ row = @y + i
143
+ STDOUT.print "\e[#{row};#{left_col}H" + "│".c(fmt)
144
+ STDOUT.print "\e[#{row};#{right_col}H" + "│".c(fmt)
145
+ end
146
+ bottom = ("└" + "─" * @w + "┘").c(fmt)
147
+ STDOUT.print "\e[#{bottom_row};#{left_col}H" + bottom
148
+ else
149
+ STDOUT.print "\e[#{top_row};#{left_col}H" + " " * (@w + 2)
150
+ (0...@h).each do |i|
151
+ row = @y + i
152
+ STDOUT.print "\e[#{row};#{left_col}H" + " "
153
+ STDOUT.print "\e[#{row};#{right_col}H" + " "
154
+ end
155
+ STDOUT.print "\e[#{bottom_row};#{left_col}H" + " " * (@w + 2)
156
+ end
157
+ end
158
+
159
+ # Diff-based refresh that minimizes flicker.
160
+ # In this updated version we lazily process only the raw lines required to fill the pane.
161
+ def refresh(cont = @text)
162
+ begin
163
+ @max_h, @max_w = IO.console.winsize
164
+ rescue => e
165
+ # Fallback to reasonable defaults if terminal size can't be determined
166
+ @max_h, @max_w = 24, 80
167
+ end
168
+
169
+ # Ensure minimum viable dimensions
170
+ @max_h = [[@max_h, 3].max, 1000].min # Between 3 and 1000 rows
171
+ @max_w = [[@max_w, 10].max, 1000].min # Between 10 and 1000 columns
172
+
173
+ # Ensure pane dimensions are reasonable
174
+ @w = [[@w, 1].max, @max_w].min
175
+ @h = [[@h, 1].max, @max_h].min
176
+
177
+ if @border
178
+ @w = @max_w - 2 if @w > @max_w - 2
179
+ @h = @max_h - 2 if @h > @max_h - 2
180
+ @x = [[2, @x].max, @max_w - @w].min
181
+ @y = [[2, @y].max, @max_h - @h].min
182
+ else
183
+ @w = @max_w if @w > @max_w
184
+ @h = @max_h if @h > @max_h
185
+ @x = [[1, @x].max, @max_w - @w + 1].min
186
+ @y = [[1, @y].max, @max_h - @h + 1].min
187
+ end
188
+
189
+ begin
190
+ o_row, o_col = pos
191
+ rescue => e
192
+ # Fallback cursor position
193
+ o_row, o_col = 1, 1
194
+ end
195
+
196
+ # Hide cursor, disable auto-wrap, reset all SGR and scroll margins
197
+ # (so stray underline, scroll regions, etc. can’t leak out)
198
+ STDOUT.print "\e[?25l\e[?7l\e[0m\e[r"
199
+
200
+ fmt = [@fg.to_s, @bg.to_s].join(',')
201
+
202
+ # Lazy evaluation: If the content or pane width has changed, reinitialize the lazy cache.
203
+ if !defined?(@cached_text) || @cached_text != cont || @cached_w != @w
204
+ begin
205
+ @raw_txt = (cont || "").split("\n").map { |line| line.chomp("\r") }
206
+ @lazy_txt = [] # This will hold the processed (wrapped) lines as needed.
207
+ @lazy_index = 0 # Pointer to the next raw line to process.
208
+ @cached_text = (cont || "").dup
209
+ @cached_w = @w
210
+ rescue => e
211
+ # Fallback if content processing fails
212
+ @raw_txt = [""]
213
+ @lazy_txt = []
214
+ @lazy_index = 0
215
+ @cached_text = ""
216
+ @cached_w = @w
217
+ end
218
+ end
219
+
220
+ content_rows = @h
221
+ # Ensure we have processed enough lines for the current scroll position + visible area.
222
+ required_lines = @ix + content_rows + 50 # Buffer a bit for smoother scrolling
223
+ max_cache_size = 1000 # Prevent excessive memory usage
224
+
225
+ while @lazy_txt.size < required_lines && @lazy_index < @raw_txt.size && @lazy_txt.size < max_cache_size
226
+ raw_line = @raw_txt[@lazy_index]
227
+ # If the raw line is short, no wrapping is needed.
228
+ if raw_line.respond_to?(:pure) && Rcurses.display_width(raw_line.pure) < @w
229
+ processed = [raw_line]
230
+ else
231
+ processed = split_line_with_ansi(raw_line, @w)
232
+ end
233
+ @lazy_txt.concat(processed)
234
+ @lazy_index += 1
235
+ end
236
+
237
+ # Simplified: just limit max processing, don't trim existing cache
238
+ # This avoids expensive array operations during scrolling
239
+
240
+ @txt = @lazy_txt
241
+
242
+ @ix = @txt.length - 1 if @ix > @txt.length - 1
243
+ @ix = 0 if @ix < 0
244
+
245
+ new_frame = []
246
+
247
+ content_rows.times do |i|
248
+ line_str = ""
249
+ l = @ix + i
250
+ if @txt[l].to_s != ""
251
+ pl = @w - Rcurses.display_width(@txt[l].pure)
252
+ pl = 0 if pl < 0
253
+ hl = pl / 2
254
+ case @align
255
+ when "l"
256
+ line_str = @txt[l].c(fmt) + " ".c(fmt) * pl
257
+ when "r"
258
+ line_str = " ".c(fmt) * pl + @txt[l].c(fmt)
259
+ when "c"
260
+ line_str = " ".c(fmt) * hl + @txt[l].c(fmt) + " ".c(fmt) * (pl - hl)
261
+ end
262
+ else
263
+ line_str = " ".c(fmt) * @w
264
+ end
265
+
266
+ new_frame << line_str
267
+ end
268
+
269
+ diff_buf = ""
270
+ new_frame.each_with_index do |line, i|
271
+ row_num = @y + i
272
+ col_num = @x
273
+ if @prev_frame.nil? || @prev_frame[i] != line
274
+ diff_buf << "\e[#{row_num};#{col_num}H" << line
275
+ end
276
+ end
277
+
278
+ # restore wrap, then also reset SGR and scroll-region one more time
279
+ diff_buf << "\e[#{o_row};#{o_col}H\e[?7h\e[0m\e[r"
280
+ begin
281
+ print diff_buf
282
+ rescue => e
283
+ # If printing fails, at least try to restore terminal state
284
+ begin
285
+ print "\e[0m\e[?25h\e[?7h"
286
+ rescue
287
+ end
288
+ end
289
+ @prev_frame = new_frame
290
+
291
+ # Draw scroll markers after printing the frame.
292
+ if @scroll
293
+ marker_col = @x + @w - 1
294
+ if @ix > 0
295
+ print "\e[#{@y};#{marker_col}H" + "∆".c(fmt)
296
+ end
297
+ # If there are more processed lines than fit in the pane
298
+ # OR there remain raw lines to process, show the down marker.
299
+ if (@txt.length - @ix) > @h || (@lazy_index < @raw_txt.size)
300
+ print "\e[#{@y + @h - 1};#{marker_col}H" + "∇".c(fmt)
301
+ end
302
+ end
303
+
304
+ if @border
305
+ # top
306
+ print "\e[#{@y - 1};#{@x - 1}H" + ("┌" + "─" * @w + "┐").c(fmt)
307
+ # sides
308
+ (0...@h).each do |i|
309
+ print "\e[#{@y + i};#{@x - 1}H" + "│".c(fmt)
310
+ print "\e[#{@y + i};#{@x + @w}H" + "│".c(fmt)
311
+ end
312
+ # bottom
313
+ print "\e[#{@y + @h};#{@x - 1}H" + ("└" + "─" * @w + "┘").c(fmt)
314
+ end
315
+
316
+ new_frame.join("\n")
317
+ end
318
+
319
+ def textformat(cont)
320
+ # This method is no longer used in refresh since we process lazily,
321
+ # but is kept here if needed elsewhere.
322
+ lines = cont.split("\n")
323
+ result = []
324
+ lines.each do |line|
325
+ split_lines = split_line_with_ansi(line, @w)
326
+ result.concat(split_lines)
327
+ end
328
+ result
329
+ end
330
+
331
+ def right
332
+ if @pos < @txt[@ix + @line].length
333
+ @pos += 1
334
+ if @pos == @w
335
+ @pos = 0
336
+ if @line == @h - 1
337
+ @ix += 1
338
+ else
339
+ @line += 1
340
+ end
341
+ end
342
+ else
343
+ if @line == @h - 1
344
+ @ix += 1 unless @ix >= @txt.length - @h
345
+ @pos = 0
346
+ elsif @line + @ix + 1 < @txt.length
347
+ @line += 1
348
+ @pos = 0
349
+ end
350
+ end
351
+ end
352
+
353
+ def left
354
+ if @pos == 0
355
+ if @line == 0
356
+ unless @ix == 0
357
+ @ix -= 1
358
+ @pos = @txt[@ix + @line].length
359
+ end
360
+ else
361
+ @line -= 1
362
+ @pos = @txt[@ix + @line].length
363
+ end
364
+ else
365
+ @pos -= 1
366
+ end
367
+ end
368
+
369
+ def up
370
+ if @line == 0
371
+ @ix -= 1 unless @ix == 0
372
+ else
373
+ @line -= 1
374
+ end
375
+ begin
376
+ @pos = [@pos, @txt[@ix + @line].length].min
377
+ rescue
378
+ end
379
+ end
380
+
381
+ def down
382
+ if @line == @h - 1
383
+ @ix += 1 unless @ix + @line >= @txt.length - 1
384
+ elsif @line + @ix + 1 < @txt.length
385
+ @line += 1
386
+ end
387
+ begin
388
+ @pos = [@pos, @txt[@ix + @line].length].min
389
+ rescue
390
+ end
391
+ end
392
+
393
+ def parse(cont)
394
+ cont.gsub!(/\*(.+?)\*/, '\1'.b)
395
+ cont.gsub!(/\/(.+?)\//, '\1'.i)
396
+ cont.gsub!(/_(.+?)_/, '\1'.u)
397
+ cont.gsub!(/#(.+?)#/, '\1'.r)
398
+ cont.gsub!(/<([^|]+)\|([^>]+)>/) do
399
+ text = $2; codes = $1
400
+ text.c(codes)
401
+ end
402
+ cont
403
+ end
404
+
405
+ def edit
406
+ begin
407
+ STDIN.cooked! rescue nil
408
+ STDIN.echo = true rescue nil
409
+ # Prepare content with visible newline markers
410
+ content = @text.pure.gsub("\n", "¬\n")
411
+ # Reset editing cursor state
412
+ @ix = 0
413
+ @line = 0
414
+ @pos = 0
415
+ # Initial render sets @txt internally for display and cursor math
416
+ refresh(content)
417
+ Rcurses::Cursor.show
418
+ input_char = ''
419
+
420
+ while input_char != 'ESC'
421
+ # Move the terminal cursor to the logical text cursor
422
+ row(@y + @line)
423
+ col(@x + @pos)
424
+ input_char = getchr(flush: false)
425
+ case input_char
426
+ when 'C-L'
427
+ @align = 'l'
428
+ when 'C-R'
429
+ @align = 'r'
430
+ when 'C-C'
431
+ @align = 'c'
432
+ when 'C-Y'
433
+ Clipboard.copy(@text.pure)
434
+ when 'C-S'
435
+ content = content.gsub('¬', "\n")
436
+ content = parse(content)
437
+ @text = content
438
+ input_char = 'ESC'
439
+ when 'DEL'
440
+ posx = calculate_posx
441
+ content.slice!(posx)
442
+ when 'BACK'
443
+ if @pos > 0
444
+ left
445
+ posx = calculate_posx
446
+ content.slice!(posx)
447
+ end
448
+ when 'WBACK'
449
+ while @pos > 0 && content[calculate_posx - 1] != ' '
450
+ left
451
+ posx = calculate_posx
452
+ content.slice!(posx)
453
+ end
454
+ when 'C-K'
455
+ line_start_pos = calculate_line_start_pos
456
+ line_length = @txt[@ix + @line]&.length || 0
457
+ content.slice!(line_start_pos + @pos, line_length - @pos)
458
+ when 'UP'
459
+ up
460
+ when 'DOWN'
461
+ down
462
+ when 'RIGHT'
463
+ right
464
+ when 'LEFT'
465
+ left
466
+ when 'HOME'
467
+ @pos = 0
468
+ when 'END'
469
+ current_line_length = @txt[@ix + @line]&.length || 0
470
+ @pos = current_line_length
471
+ when 'C-HOME'
472
+ @ix = 0; @line = 0; @pos = 0
473
+ when 'C-END'
474
+ total_lines = @txt.length
475
+ @ix = [total_lines - @h, 0].max
476
+ @line = [@h - 1, total_lines - @ix - 1].min
477
+ current_line_length = @txt[@ix + @line]&.length || 0
478
+ @pos = current_line_length
479
+ when 'ENTER'
480
+ posx = calculate_posx
481
+ content.insert(posx, "¬\n")
482
+ right
483
+ when /^.$/
484
+ posx = calculate_posx
485
+ content.insert(posx, input_char)
486
+ right
487
+ end
488
+
489
+ # Handle any buffered input
490
+ while IO.select([$stdin], nil, nil, 0)
491
+ input_char = $stdin.read_nonblock(1) rescue nil
492
+ break unless input_char
493
+ posx = calculate_posx
494
+ content.insert(posx, input_char)
495
+ right
496
+ end
497
+
498
+ # Re-render without overwriting the internal @txt
499
+ refresh(content)
500
+ Rcurses::Cursor.show
501
+ end
502
+ ensure
503
+ STDIN.raw! rescue nil
504
+ STDIN.echo = false rescue nil
505
+ while IO.select([$stdin], nil, nil, 0)
506
+ $stdin.read_nonblock(4096) rescue break
507
+ end
508
+ end
509
+ Rcurses::Cursor.hide
510
+ end
511
+
512
+ def editline
513
+ begin
514
+ STDIN.cooked! rescue nil
515
+ STDIN.echo = true rescue nil
516
+ Rcurses::Cursor.show
517
+ @x = [[@x, 1].max, @max_w - @w + 1].min
518
+ @y = [[@y, 1].max, @max_h - @h + 1].min
519
+ @scroll = false
520
+ @ix = 0
521
+ row(@y)
522
+ fmt = [@fg.to_s, @bg.to_s].join(',')
523
+ col(@x)
524
+ print @prompt.c(fmt)
525
+ prompt_len = @prompt.pure.length
526
+ content_len = @w - prompt_len
527
+ cont = @text.pure.slice(0, content_len)
528
+ @pos = cont.length
529
+ chr = ''
530
+ history_index = @history.size
531
+
532
+ while chr != 'ESC'
533
+ col(@x + prompt_len)
534
+ cont = cont.slice(0, content_len)
535
+ print cont.ljust(content_len).c(fmt)
536
+ col(@x + prompt_len + @pos)
537
+ chr = getchr(flush: false)
538
+ case chr
539
+ when 'LEFT'
540
+ @pos -= 1 if @pos > 0
541
+ when 'RIGHT'
542
+ @pos += 1 if @pos < cont.length
543
+ when 'HOME'
544
+ @pos = 0
545
+ when 'END'
546
+ @pos = cont.length
547
+ when 'DEL'
548
+ cont[@pos] = '' if @pos < cont.length
549
+ when 'BACK'
550
+ if @pos > 0
551
+ @pos -= 1
552
+ cont[@pos] = ''
553
+ end
554
+ when 'WBACK'
555
+ while @pos > 0 && cont[@pos - 1] != ' '
556
+ @pos -= 1
557
+ cont[@pos] = ''
558
+ end
559
+ when 'C-K'
560
+ cont = ''
561
+ @pos = 0
562
+ when 'ENTER'
563
+ @text = cont
564
+ chr = 'ESC'
565
+ when 'UP'
566
+ if @history.any? && history_index > 0
567
+ history_index -= 1
568
+ cont = @history[history_index].pure.slice(0, content_len)
569
+ @pos = cont.length
570
+ end
571
+ when 'DOWN'
572
+ if history_index < @history.size - 1
573
+ history_index += 1
574
+ cont = @history[history_index].pure.slice(0, content_len)
575
+ @pos = cont.length
576
+ elsif history_index == @history.size - 1
577
+ history_index += 1
578
+ cont = ""
579
+ @pos = 0
580
+ end
581
+ when /^.$/
582
+ if @pos < content_len
583
+ cont.insert(@pos, chr)
584
+ @pos += 1
585
+ end
586
+ end
587
+
588
+ while IO.select([$stdin], nil, nil, 0)
589
+ chr = $stdin.read_nonblock(1) rescue nil
590
+ break unless chr
591
+ if @pos < content_len
592
+ cont.insert(@pos, chr)
593
+ @pos += 1
594
+ end
595
+ end
596
+ end
597
+ ensure
598
+ STDIN.raw! rescue nil
599
+ STDIN.echo = false rescue nil
600
+ while IO.select([$stdin], nil, nil, 0)
601
+ $stdin.read_nonblock(4096) rescue break
602
+ end
603
+ end
604
+ prompt_len = @prompt.pure.length
605
+ new_col = @x + prompt_len + (@pos > 0 ? @pos - 1 : 0)
606
+ col(new_col)
607
+ Rcurses::Cursor.hide
608
+ end
609
+
610
+ private
611
+
612
+ def flush_stdin
613
+ while IO.select([$stdin], nil, nil, 0.005)
614
+ begin
615
+ $stdin.read_nonblock(1024)
616
+ rescue IO::WaitReadable, EOFError
617
+ break
618
+ end
619
+ end
620
+ end
621
+
622
+ def calculate_posx
623
+ total_length = 0
624
+ (@ix + @line).times do |i|
625
+ total_length += Rcurses.display_width(@txt[i].pure) + 1 # +1 for newline
626
+ end
627
+ total_length += @pos
628
+ total_length
629
+ end
630
+
631
+ def calculate_line_start_pos
632
+ total_length = 0
633
+ (@ix + @line).times do |i|
634
+ total_length += Rcurses.display_width(@txt[i].pure) + 1
635
+ end
636
+ total_length
637
+ end
638
+
639
+ def split_line_with_ansi(line, w)
640
+ begin
641
+ return [""] if line.nil? || w <= 0
642
+
643
+ open_sequences = {
644
+ "\e[1m" => "\e[22m",
645
+ "\e[3m" => "\e[23m",
646
+ "\e[4m" => "\e[24m",
647
+ "\e[5m" => "\e[25m",
648
+ "\e[7m" => "\e[27m"
649
+ }
650
+ close_sequences = open_sequences.values + ["\e[0m"]
651
+ ansi_regex = /\e\[[0-9;]*m/
652
+ result = []
653
+ tokens = line.scan(/(\e\[[0-9;]*m|[^\e]+)/).flatten.compact
654
+ current_line = ''
655
+ current_line_length = 0
656
+ active_sequences = []
657
+
658
+ tokens.each do |token|
659
+ if token.match?(ansi_regex)
660
+ current_line << token
661
+ if close_sequences.include?(token)
662
+ if token == "\e[0m"
663
+ active_sequences.clear
664
+ else
665
+ corresponding_open = open_sequences.key(token)
666
+ active_sequences.delete(corresponding_open)
667
+ end
668
+ else
669
+ active_sequences << token
670
+ end
671
+ else
672
+ words = token.scan(/\s+|\S+/)
673
+ words.each do |word|
674
+ begin
675
+ word_length = Rcurses.display_width(word.gsub(ansi_regex, ''))
676
+ if current_line_length + word_length <= w
677
+ current_line << word
678
+ current_line_length += word_length
679
+ else
680
+ if current_line_length > 0
681
+ result << current_line
682
+ current_line = active_sequences.join
683
+ current_line_length = 0
684
+ end
685
+ while word_length > w
686
+ part = word[0, [w, word.length].min]
687
+ current_line << part
688
+ result << current_line
689
+ word = word[[w, word.length].min..-1] || ""
690
+ word_length = Rcurses.display_width(word.gsub(ansi_regex, ''))
691
+ current_line = active_sequences.join
692
+ current_line_length = 0
693
+ end
694
+ if word_length > 0
695
+ current_line << word
696
+ current_line_length += word_length
697
+ end
698
+ end
699
+ rescue => e
700
+ # Skip problematic word but continue
701
+ next
702
+ end
703
+ end
704
+ end
705
+ end
706
+ result << current_line unless current_line.empty?
707
+ result.empty? ? [""] : result
708
+ rescue => e
709
+ # Complete fallback
710
+ return [""]
711
+ end
712
+ end
713
+ end
714
+ end
715
+