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.
- checksums.yaml +4 -4
- data/LICENSE +24 -0
- data/{rcurses_example.rb → examples/basic_panes.rb} +1 -1
- data/lib/rcurses/cursor.rb +79 -0
- data/lib/rcurses/input.rb +83 -0
- data/lib/rcurses/pane.rb +494 -0
- data/lib/rcurses.rb +5 -699
- data/lib/string_extensions.rb +34 -0
- metadata +11 -8
- data/extconf.rb +0 -33
data/lib/rcurses/pane.rb
ADDED
@@ -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
|
+
|