rcurses 2.0 → 2.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.
@@ -0,0 +1,494 @@
1
+ module Rcurses
2
+ class Pane
3
+ require 'clipboard' # Ensure the 'clipboard' gem is installed
4
+ include Cursor
5
+ include Input
6
+ attr_accessor :startx, :starty, :width, :height, :fg, :bg
7
+ attr_accessor :x, :y, :w, :h
8
+ attr_accessor :border, :scroll, :text, :ix, :align, :prompt
9
+
10
+ def initialize(startx = 1, starty = 1, width = 1, height = 1, fg = nil, bg = nil)
11
+ # Using Procs or Lambdas instead of eval
12
+ @startx = startx.is_a?(Proc) ? startx : -> { startx }
13
+ @starty = starty.is_a?(Proc) ? starty : -> { starty }
14
+ @width = width.is_a?(Proc) ? width : -> { width }
15
+ @height = height.is_a?(Proc) ? height : -> { height }
16
+ @fg, @bg = fg, bg
17
+ @text = "" # Initialize text variable
18
+ @align = "l" # Default alignment
19
+ @scroll = true # Initialize scroll indicators to true
20
+ @prompt = "" # Initialize prompt for editline
21
+ @ix = 0 # Text index (starting text line in pane)
22
+ @max_h, @max_w = IO.console.winsize
23
+ end
24
+
25
+ def move(x, y)
26
+ @startx = -> { @x + x }
27
+ @starty = -> { @y + y }
28
+ refresh
29
+ end
30
+
31
+ def refresh(cont = @text)
32
+ @max_h, @max_w = IO.console.winsize
33
+
34
+ # Define the core of the ANSI escape sequence handling
35
+ def split_line_with_ansi(line, w)
36
+ # Define opening and closing sequences
37
+ open_sequences = {
38
+ "\e[1m" => "\e[22m",
39
+ "\e[3m" => "\e[23m",
40
+ "\e[4m" => "\e[24m",
41
+ "\e[5m" => "\e[25m",
42
+ "\e[7m" => "\e[27m" }
43
+ # All known closing sequences
44
+ close_sequences = open_sequences.values + ["\e[0m"]
45
+ # Regex to match ANSI escape sequences
46
+ ansi_regex = /\e\[[0-9;]*m/
47
+ result = []
48
+ # Tokenize the line into ANSI sequences and plain text
49
+ tokens = line.scan(/(\e\[[0-9;]*m|[^\e]+)/).flatten.compact
50
+ current_line = ''
51
+ current_line_length = 0
52
+ active_sequences = []
53
+ tokens.each do |token|
54
+ if token.match?(ansi_regex)
55
+ # It's an ANSI sequence
56
+ current_line << token
57
+ if close_sequences.include?(token)
58
+ # It's a closing sequence
59
+ if token == "\e[0m"
60
+ # Reset all sequences
61
+ active_sequences.clear
62
+ else
63
+ # Remove the corresponding opening sequence
64
+ corresponding_open = open_sequences.key(token)
65
+ active_sequences.delete(corresponding_open)
66
+ end
67
+ else
68
+ # It's an opening sequence (or any other ANSI sequence)
69
+ active_sequences << token
70
+ end
71
+ else
72
+ # It's plain text, split into words and spaces
73
+ words = token.scan(/\S+\s*/)
74
+ words.each do |word|
75
+ word_length = word.gsub(ansi_regex, '').length
76
+ if current_line_length + word_length <= w
77
+ # Append word to current line
78
+ current_line << word
79
+ current_line_length += word_length
80
+ else
81
+ # Word doesn't fit in the current line
82
+ if current_line_length > 0
83
+ # Finish the current line and start a new one
84
+ result << current_line
85
+ # Start new line with active ANSI sequences
86
+ current_line = active_sequences.join
87
+ current_line_length = 0
88
+ end
89
+ # Handle long words that might need splitting
90
+ while word_length > w
91
+ # Split the word
92
+ part = word[0, w]
93
+ current_line << part
94
+ result << current_line
95
+ # Update word and lengths
96
+ word = word[w..-1]
97
+ word_length = word.gsub(ansi_regex, '').length
98
+ # Start new line
99
+ current_line = active_sequences.join
100
+ current_line_length = 0
101
+ end
102
+ # Append any remaining part of the word
103
+ if word_length > 0
104
+ current_line << word
105
+ current_line_length += word_length
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ # Append any remaining text in the current line
112
+ result << current_line unless current_line.empty?
113
+ result
114
+ end
115
+
116
+ # Define the main textformat function
117
+ def textformat(cont)
118
+ # Split the content by '\n'
119
+ lines = cont.split("\n")
120
+ result = []
121
+ lines.each do |line|
122
+ split_lines = split_line_with_ansi(line, @w)
123
+ result.concat(split_lines)
124
+ end
125
+ result
126
+ end
127
+
128
+ # Start the actual refresh
129
+ o_row, o_col = pos
130
+ @x = @startx.call
131
+ @y = @starty.call
132
+ @w = @width.call
133
+ @h = @height.call
134
+
135
+ # Adjust pane dimensions and positions
136
+ if @border # Keep panes inside screen
137
+ @w = @max_w - 2 if @w > @max_w - 2
138
+ @h = @max_h - 2 if @h > @max_h - 2
139
+ @x = 2 if @x < 2; @x = @max_w - @w if @x + @w > @max_w
140
+ @y = 2 if @y < 2; @y = @max_h - @h if @y + @h > @max_h
141
+ else
142
+ @w = @max_w if @w > @max_w
143
+ @h = @max_h if @h > @max_h
144
+ @x = 1 if @x < 1; @x = @max_w - @w + 1 if @x + @w > @max_w + 1
145
+ @y = 1 if @y < 1; @y = @max_h - @h + 1 if @y + @h > @max_h + 1
146
+ end
147
+
148
+ col(@x); row(@y) # Cursor to start of pane
149
+ fmt = [@fg, @bg].compact.join(',') # Format for printing in pane (fg,bg)
150
+ @txt = cont.split("\n") # Split content into array
151
+ @txt = textformat(cont) if @txt.any? { |line| line.pure.length >= @w } # Reformat lines if necessary
152
+ @ix = @txt.length - 1 if @ix > @txt.length - 1; @ix = 0 if @ix < 0 # Ensure no out-of-bounds
153
+
154
+ @h.times do |i| # Print pane content
155
+ l = @ix + i # The current line to be printed
156
+ if @txt[l].to_s != "" # Print the text line
157
+ # Get padding width and half width
158
+ pl = @w - @txt[l].pure.length
159
+ pl = 0 if pl < 0
160
+ hl = pl / 2
161
+ case @align
162
+ when "l"
163
+ print @txt[l].c(fmt) + " ".c(fmt) * pl
164
+ when "r"
165
+ print " ".c(fmt) * pl + @txt[l].c(fmt)
166
+ when "c"
167
+ print " ".c(fmt) * hl + @txt[l].c(fmt) + " ".c(fmt) * (pl - hl)
168
+ end
169
+ else
170
+ print " ".c(fmt) * @w
171
+ end
172
+ col(@x) # Cursor to start of pane
173
+ row(@y + i + 1)
174
+ end
175
+
176
+ if @ix > 0 and @scroll # Print "more" marker at top
177
+ col(@x + @w - 1); row(@y)
178
+ print "▲".c(fmt)
179
+ end
180
+
181
+ if @txt.length - @ix > @h and @scroll # Print bottom "more" marker
182
+ col(@x + @w - 1); row(@y + @h - 1)
183
+ print "▼".c(fmt)
184
+ end
185
+
186
+ if @border # Print border if @border is set to true
187
+ row(@y - 1); col(@x - 1)
188
+ print ("┌" + "─" * @w + "┐").c(fmt)
189
+ @h.times do |i|
190
+ row(@y + i); col(@x - 1)
191
+ print "│".c(fmt)
192
+ col(@x + @w)
193
+ print "│".c(fmt)
194
+ end
195
+ row(@y + @h); col(@x - 1)
196
+ print ("└" + "─" * @w + "┘").c(fmt)
197
+ end
198
+
199
+ row(o_row)
200
+ col(o_col)
201
+ @txt
202
+ end
203
+
204
+ def textformat(cont)
205
+ # Split the content by '\n'
206
+ lines = cont.split("\n")
207
+ result = []
208
+ lines.each do |line|
209
+ split_lines = split_line_with_ansi(line, @w)
210
+ result.concat(split_lines)
211
+ end
212
+ result
213
+ end
214
+
215
+ def right
216
+ if @pos < @txt[@ix + @line].length
217
+ @pos += 1
218
+ if @pos == @w
219
+ @pos = 0
220
+ if @line == @h - 1
221
+ @ix += 1
222
+ else
223
+ @line += 1
224
+ end
225
+ end
226
+ else
227
+ if @line == @h - 1
228
+ @ix += 1 unless @ix >= @txt.length - @h
229
+ @pos = 0
230
+ elsif @line + @ix + 1 < @txt.length
231
+ @line += 1
232
+ @pos = 0
233
+ end
234
+ end
235
+ end
236
+ def left
237
+ if @pos == 0
238
+ if @line == 0
239
+ unless @ix == 0
240
+ @ix -= 1
241
+ @pos = @txt[@ix + @line].length
242
+ end
243
+ else
244
+ @line -= 1
245
+ @pos = @txt[@ix + @line].length
246
+ end
247
+ else
248
+ @pos -= 1
249
+ end
250
+ end
251
+ def up
252
+ if @line == 0
253
+ @ix -= 1 unless @ix == 0
254
+ else
255
+ @line -= 1
256
+ end
257
+ begin
258
+ @pos = [@pos, @txt[@ix + @line].length].min
259
+ rescue
260
+ end
261
+ end
262
+ def down
263
+ if @line == @h - 1
264
+ @ix += 1 unless @ix + @line >= @txt.length - 1
265
+ elsif @line + @ix + 1 < @txt.length
266
+ @line += 1
267
+ end
268
+ begin
269
+ @pos = [@pos, @txt[@ix + @line].length].min
270
+ rescue
271
+ end
272
+ end
273
+
274
+ def parse(cont)
275
+ cont.gsub!(/\*(.+?)\*/, '\1'.b)
276
+ cont.gsub!(/\/(.+?)\//, '\1'.i)
277
+ cont.gsub!(/_(.+?)_/, '\1'.u)
278
+ cont.gsub!(/#(.+?)#/, '\1'.r)
279
+ cont.gsub!(/<([^|]+)\|([^>]+)>/) do
280
+ text = $2; codes = $1
281
+ text.c(codes)
282
+ end
283
+ cont
284
+ end
285
+
286
+ def edit
287
+ begin
288
+ # Switch to raw mode without echoing input
289
+ STDIN.raw!
290
+ # Prepare content for editing, replacing newlines with a placeholder
291
+ content = @text.pure.gsub("\n", "¬\n")
292
+ # Initialize cursor position and indices
293
+ @ix = 0 # Starting index of text lines displayed in the pane
294
+ @line = 0 # Current line number relative to the pane's visible area
295
+ @pos = 0 # Position within the current line (character index)
296
+ @txt = refresh(content)
297
+ input_char = ''
298
+
299
+ while input_char != 'ESC' # Continue until ESC is pressed
300
+ row(@y + @line) # Move cursor to the correct row
301
+ col(@x + @pos) # Move cursor to the correct column
302
+
303
+ input_char = getchr # Read user input
304
+ case input_char
305
+ when 'C-L' # Left justify
306
+ @align = 'l'
307
+ when 'C-R' # Right justify
308
+ @align = 'r'
309
+ when 'C-C' # Center justify
310
+ @align = 'c'
311
+ when 'C-Y' # Copy pane content to clipboard
312
+ Clipboard.copy(@text.pure)
313
+ when 'C-S' # Save edited text back to @text and exit
314
+ content = content.gsub('¬', "\n")
315
+ content = parse(content)
316
+ @text = content
317
+ input_char = 'ESC'
318
+ when 'DEL' # Delete character at current position
319
+ posx = calculate_posx
320
+ content.slice!(posx)
321
+ when 'BACK' # Backspace (delete character before current position)
322
+ if @pos > 0
323
+ left
324
+ posx = calculate_posx
325
+ content.slice!(posx)
326
+ end
327
+ when 'WBACK' # Word backspace
328
+ while @pos > 0 && content[calculate_posx - 1] != ' '
329
+ left
330
+ posx = calculate_posx
331
+ content.slice!(posx)
332
+ end
333
+ when 'C-K' # Kill line (delete from cursor to end of line)
334
+ line_start_pos = calculate_line_start_pos
335
+ line_length = @txt[@ix + @line]&.length || 0
336
+ content.slice!(line_start_pos + @pos, line_length - @pos)
337
+ when 'UP' # Move cursor up one line
338
+ up
339
+ when 'DOWN' # Move cursor down one line
340
+ down
341
+ when 'RIGHT' # Move cursor right one character
342
+ right
343
+ when 'LEFT' # Move cursor left one character
344
+ left
345
+ when 'HOME' # Move to start of line
346
+ @pos = 0
347
+ when 'END' # Move to end of line
348
+ current_line_length = @txt[@ix + @line]&.length || 0
349
+ @pos = current_line_length
350
+ when 'C-HOME' # Move to start of pane
351
+ @ix = 0
352
+ @line = 0
353
+ @pos = 0
354
+ when 'C-END' # Move to end of pane
355
+ total_lines = @txt.length
356
+ @ix = [total_lines - @h, 0].max
357
+ @line = [@h - 1, total_lines - @ix - 1].min
358
+ current_line_length = @txt[@ix + @line]&.length || 0
359
+ @pos = current_line_length
360
+ when 'ENTER' # Insert newline at current position
361
+ posx = calculate_posx
362
+ content.insert(posx, "¬\n")
363
+ right
364
+ when /^.$/ # Insert character at current position
365
+ posx = calculate_posx
366
+ content.insert(posx, input_char)
367
+ right
368
+ else
369
+ # Handle unrecognized input if necessary
370
+ end
371
+
372
+ # Handle pasted input (additional characters in the buffer)
373
+ while IO.select([$stdin], nil, nil, 0)
374
+ input_char = $stdin.read_nonblock(1) rescue nil
375
+ break unless input_char
376
+ posx = calculate_posx
377
+ content.insert(posx, input_char)
378
+ right
379
+ end
380
+
381
+ @txt = refresh(content) # Refresh the pane with the current content
382
+ end
383
+ ensure
384
+ # Restore terminal mode
385
+ STDIN.cooked!
386
+ end
387
+ end
388
+
389
+ def editline
390
+ begin
391
+ # Switch to raw mode without echo
392
+ STDIN.raw!
393
+
394
+ # Initialize position and dimensions
395
+ @x = @startx.call
396
+ @y = @starty.call
397
+ @w = @width.call
398
+ @h = @height.call
399
+ # Ensure pane is within screen bounds
400
+ @x = [[@x, 1].max, @max_w - @w + 1].min
401
+ @y = [[@y, 1].max, @max_h - @h + 1].min
402
+
403
+ @scroll = false
404
+ row(@y)
405
+
406
+ fmt = [@fg, @bg].compact.join(',')
407
+ col(@x)
408
+ print @prompt.c(fmt) # Print prompt at the pane's starting position
409
+
410
+ prompt_len = @prompt.pure.length
411
+ content_len = @w - prompt_len
412
+ cont = @text.pure.slice(0, content_len)
413
+ @pos = cont.length # Set initial cursor position at the end of content
414
+ chr = ''
415
+
416
+ while chr != 'ESC' # Continue until ESC is pressed
417
+ col(@x + prompt_len) # Set cursor at start of content
418
+ cont = cont.slice(0, content_len) # Trim content to max length
419
+ print cont.ljust(content_len).c(fmt) # Print content, left-justified
420
+ col(@x + prompt_len + @pos) # Set cursor to current position
421
+
422
+ chr = getchr # Read user input
423
+ case chr
424
+ when 'LEFT'
425
+ @pos -= 1 if @pos > 0
426
+ when 'RIGHT'
427
+ @pos += 1 if @pos < cont.length
428
+ when 'HOME'
429
+ @pos = 0
430
+ when 'END'
431
+ @pos = cont.length
432
+ when 'DEL'
433
+ cont[@pos] = '' if @pos < cont.length
434
+ when 'BACK'
435
+ if @pos > 0
436
+ @pos -= 1
437
+ cont[@pos] = ''
438
+ end
439
+ when 'WBACK'
440
+ while @pos > 0 && cont[@pos - 1] != ' '
441
+ @pos -= 1
442
+ cont[@pos] = ''
443
+ end
444
+ when 'C-K'
445
+ cont = ''
446
+ @pos = 0
447
+ when 'ENTER'
448
+ @text = parse(cont)
449
+ chr = 'ESC'
450
+ when /^.$/
451
+ if @pos < content_len
452
+ cont.insert(@pos, chr)
453
+ @pos += 1
454
+ end
455
+ end
456
+
457
+ # Handle pasted input
458
+ while IO.select([$stdin], nil, nil, 0)
459
+ chr = $stdin.read_nonblock(1) rescue nil
460
+ break unless chr
461
+ if @pos < content_len
462
+ cont.insert(@pos, chr)
463
+ @pos += 1
464
+ end
465
+ end
466
+ end
467
+ ensure
468
+ # Restore terminal mode
469
+ STDIN.cooked!
470
+ end
471
+ end
472
+
473
+ private
474
+
475
+ # Calculates the position in the content string corresponding to the current cursor position
476
+ def calculate_posx
477
+ total_length = 0
478
+ (@ix + @line).times do |i|
479
+ total_length += @txt[i].pure.length + 1 # +1 for the newline character
480
+ end
481
+ total_length += @pos
482
+ total_length
483
+ end
484
+ # Calculates the starting position of the current line in the content string
485
+ def calculate_line_start_pos
486
+ total_length = 0
487
+ (@ix + @line).times do |i|
488
+ total_length += @txt[i].pure.length + 1 # +1 for the newline character
489
+ end
490
+ total_length
491
+ end
492
+ end
493
+ end
494
+