rcurses 4.8.3 → 4.9.1

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