echoes 0.2.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.
- checksums.yaml +7 -0
- data/CLAUDE.md +33 -0
- data/Echoes.app/Contents/Info.plist +16 -0
- data/Echoes.app/Contents/MacOS/Echoes +50 -0
- data/EchoesEmbed.app/Contents/Info.plist +16 -0
- data/EchoesEmbed.app/Contents/MacOS/EchoesEmbed +41 -0
- data/LICENSE.txt +21 -0
- data/README.md +133 -0
- data/Rakefile +45 -0
- data/exe/echoes +15 -0
- data/lib/echoes/cell.rb +54 -0
- data/lib/echoes/client.rb +96 -0
- data/lib/echoes/configuration.rb +135 -0
- data/lib/echoes/copy_mode.rb +545 -0
- data/lib/echoes/cursor.rb +18 -0
- data/lib/echoes/editor.rb +225 -0
- data/lib/echoes/embedded_shell.rb +360 -0
- data/lib/echoes/embedded_shell_helper.rb +265 -0
- data/lib/echoes/gui.rb +2861 -0
- data/lib/echoes/installer.rb +95 -0
- data/lib/echoes/objc.rb +188 -0
- data/lib/echoes/pane.rb +1122 -0
- data/lib/echoes/pane_tree.rb +194 -0
- data/lib/echoes/parser.rb +821 -0
- data/lib/echoes/preferences.rb +45 -0
- data/lib/echoes/screen.rb +1468 -0
- data/lib/echoes/sixel_decoder.rb +221 -0
- data/lib/echoes/tab.rb +152 -0
- data/lib/echoes/terminal.rb +124 -0
- data/lib/echoes/version.rb +5 -0
- data/lib/echoes.rb +37 -0
- data/sig/echoes.rbs +4 -0
- metadata +123 -0
|
@@ -0,0 +1,1468 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Echoes
|
|
6
|
+
class Screen
|
|
7
|
+
attr_reader :rows, :cols, :cursor, :grid, :scrollback, :dirty_rows,
|
|
8
|
+
:command_marks
|
|
9
|
+
attr_accessor :cell_pixel_width, :cell_pixel_height, :title, :current_directory,
|
|
10
|
+
:pending_wrap, :background, :bg_fills
|
|
11
|
+
|
|
12
|
+
def self.scrollback_limit
|
|
13
|
+
Echoes.config.scrollback_limit
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(rows: 24, cols: 80)
|
|
17
|
+
@rows = rows
|
|
18
|
+
@cols = cols
|
|
19
|
+
@cursor = Cursor.new
|
|
20
|
+
@attrs = Cell.new
|
|
21
|
+
@grid = Array.new(rows) { Array.new(cols) { Cell.new } }
|
|
22
|
+
@line_wrapped = Array.new(rows, false)
|
|
23
|
+
@scroll_top = 0
|
|
24
|
+
@scroll_bottom = rows - 1
|
|
25
|
+
@saved_cursor = nil
|
|
26
|
+
@scrollback = []
|
|
27
|
+
@scrollback_wrapped = []
|
|
28
|
+
@cell_pixel_width = 8.0
|
|
29
|
+
@cell_pixel_height = 16.0
|
|
30
|
+
@application_cursor_keys = false
|
|
31
|
+
@bracketed_paste_mode = false
|
|
32
|
+
@focus_reporting = false
|
|
33
|
+
@auto_wrap = true
|
|
34
|
+
@mouse_tracking = :off # :off, :x10, :normal, :button_event, :any_event
|
|
35
|
+
@mouse_encoding = :default # :default, :sgr
|
|
36
|
+
@origin_mode = false
|
|
37
|
+
@insert_mode = false
|
|
38
|
+
@application_keypad = false
|
|
39
|
+
@cursor_style = 0 # 0=default, 1=blinking block, 2=steady block, 3=blinking underline, 4=steady underline, 5=blinking bar, 6=steady bar
|
|
40
|
+
@using_alt_screen = false
|
|
41
|
+
@charset_g0 = :ascii # :ascii or :dec_special
|
|
42
|
+
@charset_g1 = :ascii
|
|
43
|
+
@charset_g2 = :ascii
|
|
44
|
+
@charset_g3 = :ascii
|
|
45
|
+
@active_charset = 0 # 0 = G0, 1 = G1
|
|
46
|
+
@single_shift = nil # nil, 2, or 3 (for SS2/SS3)
|
|
47
|
+
@tab_stops = default_tab_stops
|
|
48
|
+
@main_grid = nil
|
|
49
|
+
@main_cursor = nil
|
|
50
|
+
@main_scroll_top = nil
|
|
51
|
+
@main_scroll_bottom = nil
|
|
52
|
+
@main_saved_cursor = nil
|
|
53
|
+
@main_scrollback = nil
|
|
54
|
+
@pending_wrap = false
|
|
55
|
+
@last_char = nil
|
|
56
|
+
@title_stack = []
|
|
57
|
+
@dirty_rows = Set.new((0...rows).to_a)
|
|
58
|
+
@bg_fills = [] # OSC 7772 ;bg-fill regions; each: {rect:[r1,c1,r2,c2], color:[r,g,b,a]}
|
|
59
|
+
# OSC 133 prompt-boundary markers: each entry is a Hash with
|
|
60
|
+
# :prompt_start / :input_start / :output_start / :output_end /
|
|
61
|
+
# :exit_code keys, where the row values are *visual* row indices —
|
|
62
|
+
# `scrollback_size + grid_row` at the moment the marker was seen.
|
|
63
|
+
# When scrollback shifts off the front, mark rows decrement so
|
|
64
|
+
# they keep pointing at the same content; marks that fall before
|
|
65
|
+
# the scrollback floor are dropped.
|
|
66
|
+
@command_marks = []
|
|
67
|
+
@current_command_mark = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
DEC_SPECIAL = {
|
|
71
|
+
'`' => "\u{25C6}", 'a' => "\u{2592}", 'b' => "\u{2409}", 'c' => "\u{240C}",
|
|
72
|
+
'd' => "\u{240D}", 'e' => "\u{240A}", 'f' => "\u{00B0}", 'g' => "\u{00B1}",
|
|
73
|
+
'h' => "\u{2424}", 'i' => "\u{240B}", 'j' => "\u{2518}", 'k' => "\u{2510}",
|
|
74
|
+
'l' => "\u{250C}", 'm' => "\u{2514}", 'n' => "\u{253C}", 'o' => "\u{23BA}",
|
|
75
|
+
'p' => "\u{23BB}", 'q' => "\u{2500}", 'r' => "\u{23BC}", 's' => "\u{23BD}",
|
|
76
|
+
't' => "\u{251C}", 'u' => "\u{2524}", 'v' => "\u{2534}", 'w' => "\u{252C}",
|
|
77
|
+
'x' => "\u{2502}", 'y' => "\u{2264}", 'z' => "\u{2265}", '{' => "\u{03C0}",
|
|
78
|
+
'|' => "\u{2260}", '}' => "\u{00A3}", '~' => "\u{00B7}",
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
def put_char(c)
|
|
82
|
+
if c.bytesize == 1
|
|
83
|
+
if @single_shift
|
|
84
|
+
cs = @single_shift == 2 ? @charset_g2 : @charset_g3
|
|
85
|
+
@single_shift = nil
|
|
86
|
+
else
|
|
87
|
+
cs = @active_charset == 0 ? @charset_g0 : @charset_g1
|
|
88
|
+
end
|
|
89
|
+
if cs == :dec_special
|
|
90
|
+
c = DEC_SPECIAL.fetch(c, c)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Combining characters: append to previous cell
|
|
95
|
+
if combining?(c)
|
|
96
|
+
col = @pending_wrap ? @cursor.col : [0, @cursor.col - 1].max
|
|
97
|
+
col -= 1 if col > 0 && @grid[@cursor.row][col].width == 0
|
|
98
|
+
@grid[@cursor.row][col].char += c
|
|
99
|
+
@last_char = @grid[@cursor.row][col].char
|
|
100
|
+
return
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
w = char_width(c)
|
|
104
|
+
|
|
105
|
+
if @auto_wrap
|
|
106
|
+
# Deferred wrap: if the previous character set the flag, wrap now
|
|
107
|
+
if @pending_wrap
|
|
108
|
+
@pending_wrap = false
|
|
109
|
+
@line_wrapped[@cursor.row] = true
|
|
110
|
+
@cursor.col = 0
|
|
111
|
+
line_feed
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Wide char at last column: doesn't fit, wrap first
|
|
115
|
+
if w == 2 && @cursor.col == @cols - 1
|
|
116
|
+
@grid[@cursor.row][@cursor.col].reset!
|
|
117
|
+
@line_wrapped[@cursor.row] = true
|
|
118
|
+
@cursor.col = 0
|
|
119
|
+
line_feed
|
|
120
|
+
end
|
|
121
|
+
else
|
|
122
|
+
# No wrap: clamp cursor to last column
|
|
123
|
+
if w == 2 && @cursor.col >= @cols - 1
|
|
124
|
+
@cursor.col = @cols - 2
|
|
125
|
+
elsif @cursor.col >= @cols
|
|
126
|
+
@cursor.col = @cols - 1
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
erase_multicell_at(@cursor.row, @cursor.col)
|
|
131
|
+
|
|
132
|
+
if @insert_mode
|
|
133
|
+
row = @grid[@cursor.row]
|
|
134
|
+
w.times { row.pop; row.insert(@cursor.col, Cell.new) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
cell = @grid[@cursor.row][@cursor.col]
|
|
138
|
+
cell.copy_from(@attrs)
|
|
139
|
+
cell.char = c
|
|
140
|
+
cell.width = w
|
|
141
|
+
|
|
142
|
+
if w == 2 && @cursor.col + 1 < @cols
|
|
143
|
+
# Mark the next cell as a continuation (width 0)
|
|
144
|
+
next_cell = @grid[@cursor.row][@cursor.col + 1]
|
|
145
|
+
next_cell.reset!
|
|
146
|
+
next_cell.width = 0
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
mark_dirty(@cursor.row)
|
|
150
|
+
|
|
151
|
+
@cursor.col += w
|
|
152
|
+
if @cursor.col >= @cols
|
|
153
|
+
@cursor.col = @cols - 1
|
|
154
|
+
@pending_wrap = true if @auto_wrap
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
@last_char = c
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def repeat_char(n = 1)
|
|
161
|
+
return unless @last_char
|
|
162
|
+
|
|
163
|
+
n.times { put_char(@last_char) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def put_multicell(text, scale:, width:, frac_n:, frac_d:, valign:, halign:, family: nil)
|
|
167
|
+
mc_rows = scale
|
|
168
|
+
|
|
169
|
+
if width > 0
|
|
170
|
+
# Explicit width: entire text in one block of scale*width cols × scale rows
|
|
171
|
+
place_multicell_block(text, scale * width, mc_rows, scale, frac_n, frac_d, valign, halign, family)
|
|
172
|
+
elsif halign != 0
|
|
173
|
+
# h= is set: render the whole string as one block of
|
|
174
|
+
# `scale × source_chars` cells, so the renderer's halign math
|
|
175
|
+
# has room to center / right-align the glyphs. The spec only
|
|
176
|
+
# mandates h= when the glyphs are smaller than the block
|
|
177
|
+
# (fractional n<d), but extending it to non-fractional /
|
|
178
|
+
# proportional text is a natural superset — other terminals
|
|
179
|
+
# just ignore the attribute. With a proportional family we
|
|
180
|
+
# widen the block to `max(scale × source_chars, measured)` so
|
|
181
|
+
# the text never overflows but still gets visible side
|
|
182
|
+
# margins for centering.
|
|
183
|
+
source_chars = text.each_grapheme_cluster.sum { |g| char_width(g) }
|
|
184
|
+
mc_cols = scale * source_chars
|
|
185
|
+
if family && @glyph_measurer && @cell_pixel_width && @cell_pixel_width > 0
|
|
186
|
+
measured_px = @glyph_measurer.call(text, family, scale, frac_n, frac_d).to_f
|
|
187
|
+
measured_cells = (measured_px / @cell_pixel_width).ceil
|
|
188
|
+
mc_cols = [mc_cols, measured_cells].max
|
|
189
|
+
end
|
|
190
|
+
mc_cols = [mc_cols, 1].max
|
|
191
|
+
place_multicell_block(text, mc_cols, mc_rows, scale, frac_n, frac_d, valign, halign, family)
|
|
192
|
+
elsif family && @glyph_measurer && @cell_pixel_width && @cell_pixel_width > 0
|
|
193
|
+
# Proportional fonts have variable glyph widths, so reserving
|
|
194
|
+
# `char_width(grapheme) * scale` cells per grapheme leaves big
|
|
195
|
+
# letters (Noto Serif "H" at 2×) overflowing into the next
|
|
196
|
+
# cell and small letters ("l") under-filling theirs. Ask the
|
|
197
|
+
# host to measure the whole text in the requested font and
|
|
198
|
+
# reserve enough cells for the entire block, drawn as one
|
|
199
|
+
# unit by the renderer's existing string-draw path.
|
|
200
|
+
measured_px = @glyph_measurer.call(text, family, scale, frac_n, frac_d).to_f
|
|
201
|
+
mc_cols = (measured_px / @cell_pixel_width).ceil
|
|
202
|
+
mc_cols = [mc_cols, 1].max
|
|
203
|
+
place_multicell_block(text, mc_cols, mc_rows, scale, frac_n, frac_d, valign, halign, family)
|
|
204
|
+
else
|
|
205
|
+
# Auto width: each grapheme gets its own block (monospace
|
|
206
|
+
# assumption — fine for the configured terminal font).
|
|
207
|
+
text.each_grapheme_cluster do |grapheme|
|
|
208
|
+
cw = char_width(grapheme)
|
|
209
|
+
mc_cols = scale * cw
|
|
210
|
+
place_multicell_block(grapheme, mc_cols, mc_rows, scale, frac_n, frac_d, valign, halign, family)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def put_sixel(data, params)
|
|
216
|
+
decoder = SixelDecoder.new(params).decode(data)
|
|
217
|
+
return if decoder.width == 0 || decoder.height == 0
|
|
218
|
+
|
|
219
|
+
mc_cols = (decoder.width / @cell_pixel_width).ceil
|
|
220
|
+
mc_rows = (decoder.height / @cell_pixel_height).ceil
|
|
221
|
+
|
|
222
|
+
return if mc_cols > @cols || mc_rows > @rows
|
|
223
|
+
|
|
224
|
+
# Wrap if it doesn't fit on current line
|
|
225
|
+
if @cursor.col + mc_cols > @cols
|
|
226
|
+
@cursor.col = 0
|
|
227
|
+
line_feed
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Scroll if block doesn't fit vertically
|
|
231
|
+
while @cursor.row + mc_rows > @rows
|
|
232
|
+
scroll_up(1)
|
|
233
|
+
@cursor.row = [@cursor.row - 1, 0].max
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
anchor_row = @cursor.row
|
|
237
|
+
anchor_col = @cursor.col
|
|
238
|
+
|
|
239
|
+
# Erase existing cells in the block area
|
|
240
|
+
mc_rows.times do |dr|
|
|
241
|
+
mc_cols.times do |dc|
|
|
242
|
+
erase_multicell_at(anchor_row + dr, anchor_col + dc)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Set anchor cell with sixel data
|
|
247
|
+
anchor = @grid[anchor_row][anchor_col]
|
|
248
|
+
anchor.reset!
|
|
249
|
+
anchor.char = " "
|
|
250
|
+
anchor.width = 1
|
|
251
|
+
anchor.multicell = {
|
|
252
|
+
cols: mc_cols, rows: mc_rows, scale: 1,
|
|
253
|
+
frac_n: 0, frac_d: 0, valign: 0, halign: 0,
|
|
254
|
+
sixel: { width: decoder.width, height: decoder.height, rgba: decoder.to_rgba }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
# Mark continuation cells
|
|
258
|
+
mc_rows.times do |dr|
|
|
259
|
+
mc_cols.times do |dc|
|
|
260
|
+
next if dr == 0 && dc == 0
|
|
261
|
+
cont = @grid[anchor_row + dr][anchor_col + dc]
|
|
262
|
+
cont.reset!
|
|
263
|
+
cont.multicell = :cont
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
@cursor.col = 0
|
|
268
|
+
@cursor.row = [anchor_row + mc_rows, @rows - 1].min
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def move_cursor(row, col)
|
|
272
|
+
@pending_wrap = false
|
|
273
|
+
if @origin_mode
|
|
274
|
+
@cursor.row = (row + @scroll_top).clamp(@scroll_top, @scroll_bottom)
|
|
275
|
+
else
|
|
276
|
+
@cursor.row = clamp_row(row)
|
|
277
|
+
end
|
|
278
|
+
@cursor.col = clamp_col(col)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def move_cursor_up(n = 1)
|
|
282
|
+
@pending_wrap = false
|
|
283
|
+
top = @cursor.row >= @scroll_top ? @scroll_top : 0
|
|
284
|
+
@cursor.row = [top, @cursor.row - n].max
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def move_cursor_down(n = 1)
|
|
288
|
+
@pending_wrap = false
|
|
289
|
+
bottom = @cursor.row <= @scroll_bottom ? @scroll_bottom : @rows - 1
|
|
290
|
+
@cursor.row = [bottom, @cursor.row + n].min
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def move_cursor_next_line(n = 1)
|
|
294
|
+
@pending_wrap = false
|
|
295
|
+
bottom = @cursor.row <= @scroll_bottom ? @scroll_bottom : @rows - 1
|
|
296
|
+
@cursor.row = [bottom, @cursor.row + n].min
|
|
297
|
+
@cursor.col = 0
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def move_cursor_prev_line(n = 1)
|
|
301
|
+
@pending_wrap = false
|
|
302
|
+
top = @cursor.row >= @scroll_top ? @scroll_top : 0
|
|
303
|
+
@cursor.row = [top, @cursor.row - n].max
|
|
304
|
+
@cursor.col = 0
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def move_cursor_forward(n = 1)
|
|
308
|
+
@pending_wrap = false
|
|
309
|
+
@cursor.col = [@cols - 1, @cursor.col + n].min
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def move_cursor_backward(n = 1)
|
|
313
|
+
@pending_wrap = false
|
|
314
|
+
@cursor.col = [0, @cursor.col - n].max
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def carriage_return
|
|
318
|
+
@pending_wrap = false
|
|
319
|
+
@cursor.col = 0
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def line_feed
|
|
323
|
+
@pending_wrap = false
|
|
324
|
+
if @cursor.row == @scroll_bottom
|
|
325
|
+
scroll_up(1)
|
|
326
|
+
else
|
|
327
|
+
@cursor.row = [@cursor.row + 1, @rows - 1].min
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def reverse_index
|
|
332
|
+
@pending_wrap = false
|
|
333
|
+
if @cursor.row == @scroll_top
|
|
334
|
+
scroll_down(1)
|
|
335
|
+
else
|
|
336
|
+
@cursor.row = [0, @cursor.row - 1].max
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def tab
|
|
341
|
+
@pending_wrap = false
|
|
342
|
+
next_stop = @tab_stops.find { |s| s > @cursor.col }
|
|
343
|
+
@cursor.col = next_stop ? [next_stop, @cols - 1].min : @cols - 1
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def backward_tab(n = 1)
|
|
347
|
+
@pending_wrap = false
|
|
348
|
+
n.times do
|
|
349
|
+
prev_stop = @tab_stops.reverse.find { |s| s < @cursor.col }
|
|
350
|
+
@cursor.col = prev_stop || 0
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def set_tab_stop
|
|
355
|
+
@tab_stops << @cursor.col unless @tab_stops.include?(@cursor.col)
|
|
356
|
+
@tab_stops.sort!
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def clear_tab_stop(mode = 0)
|
|
360
|
+
case mode
|
|
361
|
+
when 0
|
|
362
|
+
@tab_stops.delete(@cursor.col)
|
|
363
|
+
when 3
|
|
364
|
+
@tab_stops.clear
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def backspace
|
|
369
|
+
@pending_wrap = false
|
|
370
|
+
@cursor.col = [0, @cursor.col - 1].max
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def erase_in_display(mode = 0)
|
|
374
|
+
@pending_wrap = false
|
|
375
|
+
case mode
|
|
376
|
+
when 0
|
|
377
|
+
@line_wrapped[@cursor.row] = false
|
|
378
|
+
erase_in_line(0)
|
|
379
|
+
((@cursor.row + 1)...@rows).each { |r| clear_row(r); @line_wrapped[r] = false; mark_dirty(r) }
|
|
380
|
+
when 1
|
|
381
|
+
erase_in_line(1)
|
|
382
|
+
(0...@cursor.row).each { |r| clear_row(r); @line_wrapped[r] = false; mark_dirty(r) }
|
|
383
|
+
when 2
|
|
384
|
+
(0...@rows).each { |r| clear_row(r); @line_wrapped[r] = false }
|
|
385
|
+
mark_all_dirty
|
|
386
|
+
when 3
|
|
387
|
+
@scrollback.clear
|
|
388
|
+
@scrollback_wrapped.clear
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def erase_in_line(mode = 0)
|
|
393
|
+
@pending_wrap = false
|
|
394
|
+
mark_dirty(@cursor.row)
|
|
395
|
+
case mode
|
|
396
|
+
when 0
|
|
397
|
+
(@cursor.col...@cols).each { |c| @grid[@cursor.row][c].reset! }
|
|
398
|
+
when 1
|
|
399
|
+
(0..@cursor.col).each { |c| @grid[@cursor.row][c].reset! }
|
|
400
|
+
when 2
|
|
401
|
+
clear_row(@cursor.row)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def insert_lines(n = 1)
|
|
406
|
+
@pending_wrap = false
|
|
407
|
+
return unless @cursor.row >= @scroll_top && @cursor.row <= @scroll_bottom
|
|
408
|
+
|
|
409
|
+
n.times do
|
|
410
|
+
@grid.insert(@cursor.row, Array.new(@cols) { Cell.new })
|
|
411
|
+
@line_wrapped.insert(@cursor.row, false)
|
|
412
|
+
@grid.delete_at(@scroll_bottom + 1)
|
|
413
|
+
@line_wrapped.delete_at(@scroll_bottom + 1)
|
|
414
|
+
end
|
|
415
|
+
(@cursor.row..@scroll_bottom).each { |r| mark_dirty(r) }
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def delete_lines(n = 1)
|
|
419
|
+
@pending_wrap = false
|
|
420
|
+
return unless @cursor.row >= @scroll_top && @cursor.row <= @scroll_bottom
|
|
421
|
+
|
|
422
|
+
n.times do
|
|
423
|
+
@grid.delete_at(@cursor.row)
|
|
424
|
+
@line_wrapped.delete_at(@cursor.row)
|
|
425
|
+
@grid.insert(@scroll_bottom, Array.new(@cols) { Cell.new })
|
|
426
|
+
@line_wrapped.insert(@scroll_bottom, false)
|
|
427
|
+
end
|
|
428
|
+
(@cursor.row..@scroll_bottom).each { |r| mark_dirty(r) }
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def delete_chars(n = 1)
|
|
432
|
+
@pending_wrap = false
|
|
433
|
+
row = @grid[@cursor.row]
|
|
434
|
+
n.times do
|
|
435
|
+
row.delete_at(@cursor.col)
|
|
436
|
+
row.push(Cell.new)
|
|
437
|
+
end
|
|
438
|
+
mark_dirty(@cursor.row)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def insert_chars(n = 1)
|
|
442
|
+
@pending_wrap = false
|
|
443
|
+
row = @grid[@cursor.row]
|
|
444
|
+
n.times do
|
|
445
|
+
row.pop
|
|
446
|
+
row.insert(@cursor.col, Cell.new)
|
|
447
|
+
end
|
|
448
|
+
mark_dirty(@cursor.row)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def erase_chars(n = 1)
|
|
452
|
+
n.times do |i|
|
|
453
|
+
col = @cursor.col + i
|
|
454
|
+
break if col >= @cols
|
|
455
|
+
@grid[@cursor.row][col].reset!
|
|
456
|
+
end
|
|
457
|
+
mark_dirty(@cursor.row)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def scroll_up(n = 1)
|
|
461
|
+
@pending_wrap = false
|
|
462
|
+
n.times do
|
|
463
|
+
if @scroll_top == 0
|
|
464
|
+
row = @grid[@scroll_top]
|
|
465
|
+
@scrollback << row.map { |cell| c = Cell.new; c.copy_from(cell); c.width = cell.width; c.multicell = cell.multicell; c }
|
|
466
|
+
@scrollback_wrapped << @line_wrapped[@scroll_top]
|
|
467
|
+
if @scrollback.size > self.class.scrollback_limit
|
|
468
|
+
@scrollback.shift
|
|
469
|
+
adjust_command_marks(-1)
|
|
470
|
+
end
|
|
471
|
+
@scrollback_wrapped.shift if @scrollback_wrapped.size > self.class.scrollback_limit
|
|
472
|
+
end
|
|
473
|
+
@grid.delete_at(@scroll_top)
|
|
474
|
+
@line_wrapped.delete_at(@scroll_top)
|
|
475
|
+
@grid.insert(@scroll_bottom, Array.new(@cols) { Cell.new })
|
|
476
|
+
@line_wrapped.insert(@scroll_bottom, false)
|
|
477
|
+
end
|
|
478
|
+
(@scroll_top..@scroll_bottom).each { |r| mark_dirty(r) }
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def scroll_down(n = 1)
|
|
482
|
+
@pending_wrap = false
|
|
483
|
+
n.times do
|
|
484
|
+
@grid.delete_at(@scroll_bottom)
|
|
485
|
+
@line_wrapped.delete_at(@scroll_bottom)
|
|
486
|
+
@grid.insert(@scroll_top, Array.new(@cols) { Cell.new })
|
|
487
|
+
@line_wrapped.insert(@scroll_top, false)
|
|
488
|
+
end
|
|
489
|
+
(@scroll_top..@scroll_bottom).each { |r| mark_dirty(r) }
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def set_scroll_region(top, bottom)
|
|
493
|
+
@pending_wrap = false
|
|
494
|
+
@scroll_top = clamp_row(top)
|
|
495
|
+
@scroll_bottom = clamp_row(bottom)
|
|
496
|
+
@cursor.row = 0
|
|
497
|
+
@cursor.col = 0
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def set_graphics(params)
|
|
501
|
+
params = [0] if params.empty?
|
|
502
|
+
i = 0
|
|
503
|
+
while i < params.length
|
|
504
|
+
p = params[i]
|
|
505
|
+
|
|
506
|
+
# Handle colon sub-parameter arrays (e.g. [38, 2, nil, R, G, B])
|
|
507
|
+
if p.is_a?(Array)
|
|
508
|
+
apply_sgr_subparams(p)
|
|
509
|
+
i += 1
|
|
510
|
+
next
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
case p
|
|
514
|
+
when 0, nil
|
|
515
|
+
@attrs.reset!
|
|
516
|
+
when 1
|
|
517
|
+
@attrs.bold = true
|
|
518
|
+
when 2
|
|
519
|
+
@attrs.faint = true
|
|
520
|
+
when 3
|
|
521
|
+
@attrs.italic = true
|
|
522
|
+
when 4
|
|
523
|
+
@attrs.underline = true
|
|
524
|
+
when 7
|
|
525
|
+
@attrs.inverse = true
|
|
526
|
+
when 5, 6
|
|
527
|
+
@attrs.blink = true
|
|
528
|
+
when 8
|
|
529
|
+
@attrs.concealed = true
|
|
530
|
+
when 9
|
|
531
|
+
@attrs.strikethrough = true
|
|
532
|
+
when 22
|
|
533
|
+
@attrs.bold = false
|
|
534
|
+
@attrs.faint = false
|
|
535
|
+
when 23
|
|
536
|
+
@attrs.italic = false
|
|
537
|
+
when 24
|
|
538
|
+
@attrs.underline = false
|
|
539
|
+
when 27
|
|
540
|
+
@attrs.inverse = false
|
|
541
|
+
when 25
|
|
542
|
+
@attrs.blink = false
|
|
543
|
+
when 28
|
|
544
|
+
@attrs.concealed = false
|
|
545
|
+
when 29
|
|
546
|
+
@attrs.strikethrough = false
|
|
547
|
+
when 30..37
|
|
548
|
+
@attrs.fg = p - 30
|
|
549
|
+
when 38
|
|
550
|
+
if params[i + 1] == 2 && params[i + 2] && params[i + 3] && params[i + 4]
|
|
551
|
+
@attrs.fg = [params[i + 2], params[i + 3], params[i + 4]]
|
|
552
|
+
i += 4
|
|
553
|
+
elsif params[i + 1] == 5 && params[i + 2]
|
|
554
|
+
@attrs.fg = params[i + 2]
|
|
555
|
+
i += 2
|
|
556
|
+
end
|
|
557
|
+
when 39
|
|
558
|
+
@attrs.fg = nil
|
|
559
|
+
when 40..47
|
|
560
|
+
@attrs.bg = p - 40
|
|
561
|
+
when 48
|
|
562
|
+
if params[i + 1] == 2 && params[i + 2] && params[i + 3] && params[i + 4]
|
|
563
|
+
@attrs.bg = [params[i + 2], params[i + 3], params[i + 4]]
|
|
564
|
+
i += 4
|
|
565
|
+
elsif params[i + 1] == 5 && params[i + 2]
|
|
566
|
+
@attrs.bg = params[i + 2]
|
|
567
|
+
i += 2
|
|
568
|
+
end
|
|
569
|
+
when 49
|
|
570
|
+
@attrs.bg = nil
|
|
571
|
+
when 90..97
|
|
572
|
+
@attrs.fg = p - 90 + 8
|
|
573
|
+
when 100..107
|
|
574
|
+
@attrs.bg = p - 100 + 8
|
|
575
|
+
end
|
|
576
|
+
i += 1
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def apply_sgr_subparams(sub)
|
|
581
|
+
case sub[0]
|
|
582
|
+
when 4
|
|
583
|
+
# Underline style: 4:0=off, 4:1=single, 4:2=double, 4:3=curly, 4:4=dotted, 4:5=dashed
|
|
584
|
+
style = sub[1] || 1
|
|
585
|
+
if style == 0
|
|
586
|
+
@attrs.underline = false
|
|
587
|
+
else
|
|
588
|
+
@attrs.underline = style
|
|
589
|
+
end
|
|
590
|
+
when 38
|
|
591
|
+
# Foreground color with sub-parameters
|
|
592
|
+
if sub[1] == 2
|
|
593
|
+
# 38:2:cs:R:G:B or 38:2:R:G:B (cs = color space, often empty/omitted)
|
|
594
|
+
r, g, b = extract_rgb_subparams(sub, 2)
|
|
595
|
+
@attrs.fg = [r, g, b] if r && g && b
|
|
596
|
+
elsif sub[1] == 5 && sub[2]
|
|
597
|
+
@attrs.fg = sub[2]
|
|
598
|
+
end
|
|
599
|
+
when 48
|
|
600
|
+
# Background color with sub-parameters
|
|
601
|
+
if sub[1] == 2
|
|
602
|
+
r, g, b = extract_rgb_subparams(sub, 2)
|
|
603
|
+
@attrs.bg = [r, g, b] if r && g && b
|
|
604
|
+
elsif sub[1] == 5 && sub[2]
|
|
605
|
+
@attrs.bg = sub[2]
|
|
606
|
+
end
|
|
607
|
+
when 58
|
|
608
|
+
# Underline color
|
|
609
|
+
if sub[1] == 2
|
|
610
|
+
r, g, b = extract_rgb_subparams(sub, 2)
|
|
611
|
+
@attrs.underline_color = [r, g, b] if r && g && b
|
|
612
|
+
elsif sub[1] == 5 && sub[2]
|
|
613
|
+
@attrs.underline_color = sub[2]
|
|
614
|
+
end
|
|
615
|
+
when 59
|
|
616
|
+
@attrs.underline_color = nil
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def save_cursor
|
|
621
|
+
saved_attrs = Cell.new
|
|
622
|
+
saved_attrs.copy_from(@attrs)
|
|
623
|
+
@saved_cursor = {
|
|
624
|
+
row: @cursor.row, col: @cursor.col,
|
|
625
|
+
attrs: saved_attrs,
|
|
626
|
+
origin_mode: @origin_mode,
|
|
627
|
+
auto_wrap: @auto_wrap,
|
|
628
|
+
charset_g0: @charset_g0,
|
|
629
|
+
charset_g1: @charset_g1,
|
|
630
|
+
active_charset: @active_charset,
|
|
631
|
+
pending_wrap: @pending_wrap,
|
|
632
|
+
}
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def restore_cursor
|
|
636
|
+
if @saved_cursor
|
|
637
|
+
@cursor.row = @saved_cursor[:row]
|
|
638
|
+
@cursor.col = @saved_cursor[:col]
|
|
639
|
+
@attrs.copy_from(@saved_cursor[:attrs])
|
|
640
|
+
@origin_mode = @saved_cursor[:origin_mode]
|
|
641
|
+
@auto_wrap = @saved_cursor[:auto_wrap]
|
|
642
|
+
@charset_g0 = @saved_cursor[:charset_g0]
|
|
643
|
+
@charset_g1 = @saved_cursor[:charset_g1]
|
|
644
|
+
@active_charset = @saved_cursor[:active_charset]
|
|
645
|
+
@pending_wrap = @saved_cursor[:pending_wrap] || false
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def application_cursor_keys?
|
|
650
|
+
@application_cursor_keys
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def application_cursor_keys=(val)
|
|
654
|
+
@application_cursor_keys = val
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def bracketed_paste_mode?
|
|
658
|
+
@bracketed_paste_mode
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def bracketed_paste_mode=(val)
|
|
662
|
+
@bracketed_paste_mode = val
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def focus_reporting?
|
|
666
|
+
@focus_reporting
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def focus_reporting=(val)
|
|
670
|
+
@focus_reporting = val
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def auto_wrap?
|
|
674
|
+
@auto_wrap
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def auto_wrap=(val)
|
|
678
|
+
@auto_wrap = val
|
|
679
|
+
@pending_wrap = false
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
attr_accessor :mouse_tracking, :mouse_encoding, :insert_mode, :active_charset, :application_keypad, :cursor_style, :bell, :single_shift
|
|
683
|
+
|
|
684
|
+
def push_title
|
|
685
|
+
@title_stack.push(@title)
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def pop_title
|
|
689
|
+
@title = @title_stack.pop if @title_stack.any?
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
def mark_dirty(row)
|
|
693
|
+
@dirty_rows << row
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
def mark_all_dirty
|
|
697
|
+
@dirty_rows = Set.new((0...@rows).to_a)
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def clear_dirty
|
|
701
|
+
@dirty_rows = Set.new
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def set_hyperlink(uri)
|
|
705
|
+
@attrs.hyperlink = uri
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# Write a sequence of styled prompt segments directly into the
|
|
709
|
+
# cell grid, bypassing the ANSI SGR parser. Each segment is a
|
|
710
|
+
# `{text:, fg:, bg:, bold:, italic:, underline:, inverse:}` Hash
|
|
711
|
+
# (see `Rubish::REPL#prompt_segments`). Color values follow
|
|
712
|
+
# rubish's encoding: nil = default, 0..255 = palette index,
|
|
713
|
+
# `[:rgb, r, g, b]` = true color (translated to `[r, g, b]` for
|
|
714
|
+
# this Screen's storage convention).
|
|
715
|
+
#
|
|
716
|
+
# Existing `@attrs` is snapshotted and restored so any in-flight
|
|
717
|
+
# SGR state from prior parser-driven rendering is preserved.
|
|
718
|
+
def put_styled_segments(segments)
|
|
719
|
+
saved_fg = @attrs.fg
|
|
720
|
+
saved_bg = @attrs.bg
|
|
721
|
+
saved_bold = @attrs.bold
|
|
722
|
+
saved_italic = @attrs.italic
|
|
723
|
+
saved_underline = @attrs.underline
|
|
724
|
+
saved_inverse = @attrs.inverse
|
|
725
|
+
begin
|
|
726
|
+
segments.each do |seg|
|
|
727
|
+
@attrs.fg = translate_segment_color(seg[:fg])
|
|
728
|
+
@attrs.bg = translate_segment_color(seg[:bg])
|
|
729
|
+
@attrs.bold = !!seg[:bold]
|
|
730
|
+
@attrs.italic = !!seg[:italic]
|
|
731
|
+
@attrs.underline = !!seg[:underline]
|
|
732
|
+
@attrs.inverse = !!seg[:inverse]
|
|
733
|
+
(seg[:text] || '').each_char { |c| put_char(c) }
|
|
734
|
+
end
|
|
735
|
+
ensure
|
|
736
|
+
@attrs.fg = saved_fg
|
|
737
|
+
@attrs.bg = saved_bg
|
|
738
|
+
@attrs.bold = saved_bold
|
|
739
|
+
@attrs.italic = saved_italic
|
|
740
|
+
@attrs.underline = saved_underline
|
|
741
|
+
@attrs.inverse = saved_inverse
|
|
742
|
+
end
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
private def translate_segment_color(color)
|
|
746
|
+
case color
|
|
747
|
+
when nil then nil
|
|
748
|
+
when Integer then color
|
|
749
|
+
when Array
|
|
750
|
+
# rubish exposes true color as [:rgb, r, g, b]; this Screen
|
|
751
|
+
# stores it as [r, g, b].
|
|
752
|
+
color.first == :rgb ? color[1..3] : color
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
public
|
|
756
|
+
|
|
757
|
+
# OSC 133 prompt boundary marker. `kind` is one of:
|
|
758
|
+
# :prompt_start — OSC 133 ; A — beginning of a fresh prompt block
|
|
759
|
+
# :prompt_end — OSC 133 ; B — end of prompt / start of input
|
|
760
|
+
# :command_start — OSC 133 ; C — start of command output
|
|
761
|
+
# :command_end — OSC 133 ; D — end of command output (with optional exit code)
|
|
762
|
+
#
|
|
763
|
+
# Marks are stored as visual rows (scrollback rows + grid rows from 0).
|
|
764
|
+
# `:prompt_start` opens a new mark; subsequent kinds populate it.
|
|
765
|
+
def osc133_mark(kind, exit_code: nil)
|
|
766
|
+
row = @scrollback.size + @cursor.row
|
|
767
|
+
case kind
|
|
768
|
+
when :prompt_start
|
|
769
|
+
@current_command_mark = {
|
|
770
|
+
prompt_start: row, input_start: nil,
|
|
771
|
+
output_start: nil, output_end: nil, exit_code: nil,
|
|
772
|
+
}
|
|
773
|
+
@command_marks << @current_command_mark
|
|
774
|
+
when :prompt_end
|
|
775
|
+
@current_command_mark ||= {prompt_start: row, input_start: nil,
|
|
776
|
+
output_start: nil, output_end: nil, exit_code: nil}
|
|
777
|
+
@command_marks << @current_command_mark unless @command_marks.last.equal?(@current_command_mark)
|
|
778
|
+
@current_command_mark[:input_start] = row
|
|
779
|
+
when :command_start
|
|
780
|
+
return unless @current_command_mark
|
|
781
|
+
@current_command_mark[:output_start] = row
|
|
782
|
+
when :command_end
|
|
783
|
+
return unless @current_command_mark
|
|
784
|
+
@current_command_mark[:output_end] = row
|
|
785
|
+
@current_command_mark[:exit_code] = exit_code
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
# Extract the visible text of a command's output region. `mark` is
|
|
790
|
+
# one of the entries from `@command_marks`. Rows that have scrolled
|
|
791
|
+
# off the front of the scrollback are silently skipped — the text
|
|
792
|
+
# is no longer recoverable. Returns "" when the mark is incomplete
|
|
793
|
+
# (no :output_start or :output_end yet).
|
|
794
|
+
def text_for_command_output(mark)
|
|
795
|
+
return '' unless mark && mark[:output_start] && mark[:output_end]
|
|
796
|
+
from = mark[:output_start]
|
|
797
|
+
to = mark[:output_end]
|
|
798
|
+
return '' if to <= from
|
|
799
|
+
sb_size = @scrollback.size
|
|
800
|
+
lines = []
|
|
801
|
+
(from...to).each do |abs_row|
|
|
802
|
+
row = abs_row < 0 ? nil :
|
|
803
|
+
abs_row < sb_size ? @scrollback[abs_row] :
|
|
804
|
+
@grid[abs_row - sb_size]
|
|
805
|
+
next unless row
|
|
806
|
+
lines << row.map { |c| c.char || ' ' }.join.rstrip
|
|
807
|
+
end
|
|
808
|
+
lines.join("\n")
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
# Most recently completed command mark (D was emitted). nil if no
|
|
812
|
+
# command has finished yet on this pane.
|
|
813
|
+
def last_completed_command_mark
|
|
814
|
+
@command_marks.reverse_each.find { |m| m[:output_end] }
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
# Attach the literal command text to the most recently opened mark.
|
|
818
|
+
# The host calls this at submit time (between OSC 133 ;B and ;C)
|
|
819
|
+
# so click-to-rerun can recover the command text from a clicked
|
|
820
|
+
# prompt row long after submission.
|
|
821
|
+
def set_current_command_text(text)
|
|
822
|
+
return unless @current_command_mark
|
|
823
|
+
@current_command_mark[:command_text] = text
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
# Find the command mark whose prompt+input region covers `abs_row`,
|
|
827
|
+
# or nil if no mark covers that row. The region runs from
|
|
828
|
+
# :prompt_start (inclusive) up to :output_start (exclusive). If a
|
|
829
|
+
# command is still running and no :output_start has been recorded
|
|
830
|
+
# yet, the region is taken as just :prompt_start itself (one row).
|
|
831
|
+
def find_command_mark_at_row(abs_row)
|
|
832
|
+
@command_marks.reverse_each.find do |m|
|
|
833
|
+
next false unless m[:prompt_start]
|
|
834
|
+
upper = m[:output_start] || (m[:prompt_start] + 1)
|
|
835
|
+
abs_row >= m[:prompt_start] && abs_row < upper
|
|
836
|
+
end
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
# Find the command mark whose *output* region covers `abs_row` and
|
|
840
|
+
# return [start_row, end_row] (both inclusive) so callers like the
|
|
841
|
+
# GUI's triple-click can highlight or copy that whole region. nil
|
|
842
|
+
# if no completed mark covers the row.
|
|
843
|
+
def output_region_for_row(abs_row)
|
|
844
|
+
mark = @command_marks.reverse_each.find do |m|
|
|
845
|
+
next false unless m[:output_start] && m[:output_end]
|
|
846
|
+
abs_row >= m[:output_start] && abs_row < m[:output_end]
|
|
847
|
+
end
|
|
848
|
+
return nil unless mark
|
|
849
|
+
[mark[:output_start], mark[:output_end] - 1]
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
# When scrollback shifts (oldest row dropped), every row index in
|
|
853
|
+
# @command_marks moves by `delta` (typically -1). Marks that would
|
|
854
|
+
# now point before the scrollback floor are dropped — their content
|
|
855
|
+
# is no longer reachable.
|
|
856
|
+
def adjust_command_marks(delta)
|
|
857
|
+
return if @command_marks.empty?
|
|
858
|
+
@command_marks.each do |m|
|
|
859
|
+
m.each_key do |k|
|
|
860
|
+
next if k == :exit_code
|
|
861
|
+
v = m[k]
|
|
862
|
+
m[k] = v + delta if v
|
|
863
|
+
end
|
|
864
|
+
end
|
|
865
|
+
@command_marks.reject! { |m| m[:prompt_start] && m[:prompt_start] < 0 }
|
|
866
|
+
if @current_command_mark && (@current_command_mark[:prompt_start] || 0) < 0
|
|
867
|
+
@current_command_mark = nil
|
|
868
|
+
end
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
attr_accessor :clipboard_handler, :palette_handler, :glyph_measurer
|
|
872
|
+
|
|
873
|
+
def set_clipboard(text)
|
|
874
|
+
@clipboard_handler&.call(:set, text)
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
def clipboard_content
|
|
878
|
+
@clipboard_handler&.call(:get, nil)
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
def designate_charset(g, charset)
|
|
882
|
+
case g
|
|
883
|
+
when 0 then @charset_g0 = charset
|
|
884
|
+
when 1 then @charset_g1 = charset
|
|
885
|
+
when 2 then @charset_g2 = charset
|
|
886
|
+
when 3 then @charset_g3 = charset
|
|
887
|
+
end
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
def origin_mode?
|
|
891
|
+
@origin_mode
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
def origin_mode=(val)
|
|
895
|
+
@origin_mode = val
|
|
896
|
+
@pending_wrap = false
|
|
897
|
+
if val
|
|
898
|
+
@cursor.row = @scroll_top
|
|
899
|
+
@cursor.col = 0
|
|
900
|
+
end
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
def using_alt_screen?
|
|
904
|
+
@using_alt_screen
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
def switch_to_alt_screen
|
|
908
|
+
return if @using_alt_screen
|
|
909
|
+
|
|
910
|
+
@main_grid = @grid
|
|
911
|
+
@main_line_wrapped = @line_wrapped
|
|
912
|
+
@main_cursor = [@cursor.row, @cursor.col, @cursor.visible]
|
|
913
|
+
@main_scroll_top = @scroll_top
|
|
914
|
+
@main_scroll_bottom = @scroll_bottom
|
|
915
|
+
@main_saved_cursor = @saved_cursor
|
|
916
|
+
@main_scrollback = @scrollback
|
|
917
|
+
@main_scrollback_wrapped = @scrollback_wrapped
|
|
918
|
+
@main_rows = @rows
|
|
919
|
+
@main_cols = @cols
|
|
920
|
+
|
|
921
|
+
@grid = Array.new(@rows) { Array.new(@cols) { Cell.new } }
|
|
922
|
+
@line_wrapped = Array.new(@rows, false)
|
|
923
|
+
@cursor = Cursor.new
|
|
924
|
+
@attrs = Cell.new
|
|
925
|
+
@scroll_top = 0
|
|
926
|
+
@scroll_bottom = @rows - 1
|
|
927
|
+
@saved_cursor = nil
|
|
928
|
+
@scrollback = []
|
|
929
|
+
@scrollback_wrapped = []
|
|
930
|
+
@pending_wrap = false
|
|
931
|
+
@using_alt_screen = true
|
|
932
|
+
mark_all_dirty
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
def switch_to_main_screen
|
|
936
|
+
return unless @using_alt_screen
|
|
937
|
+
|
|
938
|
+
current_rows = @rows
|
|
939
|
+
current_cols = @cols
|
|
940
|
+
|
|
941
|
+
@grid = @main_grid
|
|
942
|
+
@line_wrapped = @main_line_wrapped
|
|
943
|
+
@cursor = Cursor.new
|
|
944
|
+
@cursor.row, @cursor.col, @cursor.visible = @main_cursor
|
|
945
|
+
@scroll_top = @main_scroll_top
|
|
946
|
+
@scroll_bottom = @main_scroll_bottom
|
|
947
|
+
@saved_cursor = @main_saved_cursor
|
|
948
|
+
@scrollback = @main_scrollback
|
|
949
|
+
@scrollback_wrapped = @main_scrollback_wrapped
|
|
950
|
+
@rows = @main_rows
|
|
951
|
+
@cols = @main_cols
|
|
952
|
+
@attrs = Cell.new
|
|
953
|
+
|
|
954
|
+
@main_grid = nil
|
|
955
|
+
@main_line_wrapped = nil
|
|
956
|
+
@main_cursor = nil
|
|
957
|
+
@main_scroll_top = nil
|
|
958
|
+
@main_scroll_bottom = nil
|
|
959
|
+
@main_saved_cursor = nil
|
|
960
|
+
@main_scrollback = nil
|
|
961
|
+
@main_scrollback_wrapped = nil
|
|
962
|
+
@main_rows = nil
|
|
963
|
+
@main_cols = nil
|
|
964
|
+
@pending_wrap = false
|
|
965
|
+
@using_alt_screen = false
|
|
966
|
+
|
|
967
|
+
# If terminal was resized while in alt screen, adjust the restored main grid
|
|
968
|
+
if current_rows != @rows || current_cols != @cols
|
|
969
|
+
resize(current_rows, current_cols)
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
mark_all_dirty
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
def show_cursor
|
|
976
|
+
@cursor.visible = true
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
def hide_cursor
|
|
980
|
+
@cursor.visible = false
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
def to_text
|
|
984
|
+
@grid.map { |row| row.map { |cell| cell.char }.join.rstrip }.join("\n").rstrip
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
def selected_text(sr, sc, er, ec)
|
|
988
|
+
lines = []
|
|
989
|
+
(sr..er).each do |r|
|
|
990
|
+
from = (r == sr) ? sc : 0
|
|
991
|
+
to = (r == er) ? ec : @cols - 1
|
|
992
|
+
lines << @grid[r][from..to].map { |cell| cell.char }.join.rstrip
|
|
993
|
+
end
|
|
994
|
+
lines.join("\n")
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
def word_boundaries_at(row, col)
|
|
998
|
+
return nil if row < 0 || row >= @rows || col < 0 || col >= @cols
|
|
999
|
+
|
|
1000
|
+
line = @grid[row]
|
|
1001
|
+
cls = char_class(line[col].char)
|
|
1002
|
+
|
|
1003
|
+
start_col = col
|
|
1004
|
+
start_col -= 1 while start_col > 0 && char_class(line[start_col - 1].char) == cls
|
|
1005
|
+
|
|
1006
|
+
end_col = col
|
|
1007
|
+
end_col += 1 while end_col < @cols - 1 && char_class(line[end_col + 1].char) == cls
|
|
1008
|
+
|
|
1009
|
+
[start_col, end_col]
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
def soft_reset
|
|
1013
|
+
@attrs = Cell.new
|
|
1014
|
+
@cursor.visible = true
|
|
1015
|
+
@saved_cursor = nil
|
|
1016
|
+
@origin_mode = false
|
|
1017
|
+
@auto_wrap = true
|
|
1018
|
+
@insert_mode = false
|
|
1019
|
+
@application_cursor_keys = false
|
|
1020
|
+
@bracketed_paste_mode = false
|
|
1021
|
+
@focus_reporting = false
|
|
1022
|
+
@charset_g0 = :ascii
|
|
1023
|
+
@charset_g1 = :ascii
|
|
1024
|
+
@charset_g2 = :ascii
|
|
1025
|
+
@charset_g3 = :ascii
|
|
1026
|
+
@active_charset = 0
|
|
1027
|
+
@single_shift = nil
|
|
1028
|
+
@cursor_style = 0
|
|
1029
|
+
@tab_stops = default_tab_stops
|
|
1030
|
+
@scroll_top = 0
|
|
1031
|
+
@scroll_bottom = @rows - 1
|
|
1032
|
+
@pending_wrap = false
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
def decaln
|
|
1036
|
+
@grid.each do |row|
|
|
1037
|
+
row.each do |cell|
|
|
1038
|
+
cell.reset!
|
|
1039
|
+
cell.char = 'E'
|
|
1040
|
+
end
|
|
1041
|
+
end
|
|
1042
|
+
@cursor.row = 0
|
|
1043
|
+
@cursor.col = 0
|
|
1044
|
+
@pending_wrap = false
|
|
1045
|
+
mark_all_dirty
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
def reset
|
|
1049
|
+
@cursor = Cursor.new
|
|
1050
|
+
@attrs = Cell.new
|
|
1051
|
+
@grid = Array.new(@rows) { Array.new(@cols) { Cell.new } }
|
|
1052
|
+
@line_wrapped = Array.new(@rows, false)
|
|
1053
|
+
@scroll_top = 0
|
|
1054
|
+
@scroll_bottom = @rows - 1
|
|
1055
|
+
@saved_cursor = nil
|
|
1056
|
+
@scrollback = []
|
|
1057
|
+
@scrollback_wrapped = []
|
|
1058
|
+
@tab_stops = default_tab_stops
|
|
1059
|
+
@pending_wrap = false
|
|
1060
|
+
mark_all_dirty
|
|
1061
|
+
end
|
|
1062
|
+
|
|
1063
|
+
def resize(new_rows, new_cols)
|
|
1064
|
+
old_cols = @cols
|
|
1065
|
+
@rows = new_rows
|
|
1066
|
+
@cols = new_cols
|
|
1067
|
+
|
|
1068
|
+
if new_cols != old_cols
|
|
1069
|
+
reflow(new_rows, new_cols, old_cols)
|
|
1070
|
+
else
|
|
1071
|
+
# Only row count changed — simple add/remove
|
|
1072
|
+
if new_rows > @grid.size
|
|
1073
|
+
(new_rows - @grid.size).times do
|
|
1074
|
+
@grid.push(Array.new(new_cols) { Cell.new })
|
|
1075
|
+
@line_wrapped.push(false)
|
|
1076
|
+
end
|
|
1077
|
+
elsif new_rows < @grid.size
|
|
1078
|
+
@grid.slice!(new_rows..)
|
|
1079
|
+
@line_wrapped.slice!(new_rows..)
|
|
1080
|
+
end
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
@scroll_top = 0
|
|
1084
|
+
@scroll_bottom = new_rows - 1
|
|
1085
|
+
@cursor.row = clamp_row(@cursor.row)
|
|
1086
|
+
@cursor.col = clamp_col(@cursor.col)
|
|
1087
|
+
@pending_wrap = false
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
private
|
|
1091
|
+
|
|
1092
|
+
# Extract R, G, B from sub-params like [38, 2, cs, R, G, B] or [38, 2, R, G, B]
|
|
1093
|
+
# The color space ID (cs) may be nil/empty, so we try both layouts.
|
|
1094
|
+
def extract_rgb_subparams(sub, type_idx)
|
|
1095
|
+
# sub[type_idx] is the type (2 or 5)
|
|
1096
|
+
# Try [_, 2, cs, R, G, B] first (6 elements), then [_, 2, R, G, B] (5 elements)
|
|
1097
|
+
if sub.length >= type_idx + 5 && sub[type_idx + 2] && sub[type_idx + 3] && sub[type_idx + 4]
|
|
1098
|
+
if sub[type_idx + 1].nil?
|
|
1099
|
+
# Color space ID is empty/nil: [38, 2, nil, R, G, B]
|
|
1100
|
+
[sub[type_idx + 2], sub[type_idx + 3], sub[type_idx + 4]]
|
|
1101
|
+
else
|
|
1102
|
+
# No color space ID: [38, 2, R, G, B]
|
|
1103
|
+
[sub[type_idx + 1], sub[type_idx + 2], sub[type_idx + 3]]
|
|
1104
|
+
end
|
|
1105
|
+
elsif sub.length >= type_idx + 4 && sub[type_idx + 1] && sub[type_idx + 2] && sub[type_idx + 3]
|
|
1106
|
+
[sub[type_idx + 1], sub[type_idx + 2], sub[type_idx + 3]]
|
|
1107
|
+
else
|
|
1108
|
+
[nil, nil, nil]
|
|
1109
|
+
end
|
|
1110
|
+
end
|
|
1111
|
+
|
|
1112
|
+
def reflow(new_rows, new_cols, old_cols)
|
|
1113
|
+
# Convert cursor to absolute position (scrollback + grid row index)
|
|
1114
|
+
cursor_abs = @scrollback.size + @cursor.row
|
|
1115
|
+
|
|
1116
|
+
# Merge scrollback and grid into logical lines
|
|
1117
|
+
all_rows = @scrollback + @grid
|
|
1118
|
+
all_wrapped = @scrollback_wrapped + @line_wrapped
|
|
1119
|
+
|
|
1120
|
+
logical_lines = []
|
|
1121
|
+
i = 0
|
|
1122
|
+
while i < all_rows.size
|
|
1123
|
+
line = all_rows[i].dup
|
|
1124
|
+
while i < all_rows.size - 1 && all_wrapped[i]
|
|
1125
|
+
i += 1
|
|
1126
|
+
line.concat(all_rows[i])
|
|
1127
|
+
end
|
|
1128
|
+
logical_lines << line
|
|
1129
|
+
i += 1
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
# Re-wrap logical lines to new width
|
|
1133
|
+
new_all_rows = []
|
|
1134
|
+
new_all_wrapped = []
|
|
1135
|
+
cursor_new_abs = nil
|
|
1136
|
+
|
|
1137
|
+
# Track cursor: find which logical line row cursor_abs falls in
|
|
1138
|
+
logical_row_start = 0
|
|
1139
|
+
logical_lines.each do |line|
|
|
1140
|
+
# Count how many original rows this logical line spanned
|
|
1141
|
+
span = 1
|
|
1142
|
+
temp = logical_row_start
|
|
1143
|
+
while temp < all_wrapped.size - 1 && all_wrapped[temp]
|
|
1144
|
+
span += 1
|
|
1145
|
+
temp += 1
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
# Strip trailing blank cells
|
|
1149
|
+
content_len = line.size
|
|
1150
|
+
while content_len > 0 && line[content_len - 1].char == ' ' && line[content_len - 1].fg.nil? && line[content_len - 1].bg.nil? && !line[content_len - 1].bold && !line[content_len - 1].underline && !line[content_len - 1].inverse
|
|
1151
|
+
content_len -= 1
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
if content_len == 0
|
|
1155
|
+
new_all_rows << Array.new(new_cols) { Cell.new }
|
|
1156
|
+
new_all_wrapped << false
|
|
1157
|
+
if cursor_abs >= logical_row_start && cursor_abs < logical_row_start + span
|
|
1158
|
+
cursor_new_abs = new_all_rows.size - 1
|
|
1159
|
+
end
|
|
1160
|
+
else
|
|
1161
|
+
col = 0
|
|
1162
|
+
row_cells = []
|
|
1163
|
+
line[0...content_len].each do |cell|
|
|
1164
|
+
if col + [cell.width, 1].max > new_cols
|
|
1165
|
+
# Pad remaining
|
|
1166
|
+
while row_cells.size < new_cols
|
|
1167
|
+
row_cells << Cell.new
|
|
1168
|
+
end
|
|
1169
|
+
new_all_rows << row_cells
|
|
1170
|
+
new_all_wrapped << true
|
|
1171
|
+
row_cells = []
|
|
1172
|
+
col = 0
|
|
1173
|
+
end
|
|
1174
|
+
row_cells << cell
|
|
1175
|
+
col += [cell.width, 1].max
|
|
1176
|
+
end
|
|
1177
|
+
# Pad last row
|
|
1178
|
+
while row_cells.size < new_cols
|
|
1179
|
+
row_cells << Cell.new
|
|
1180
|
+
end
|
|
1181
|
+
new_all_rows << row_cells
|
|
1182
|
+
new_all_wrapped << false
|
|
1183
|
+
|
|
1184
|
+
if cursor_abs >= logical_row_start && cursor_abs < logical_row_start + span && cursor_new_abs.nil?
|
|
1185
|
+
# Place cursor on the last physical row of this logical line
|
|
1186
|
+
cursor_new_abs = new_all_rows.size - 1
|
|
1187
|
+
end
|
|
1188
|
+
end
|
|
1189
|
+
|
|
1190
|
+
logical_row_start += span
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
cursor_new_abs ||= [new_all_rows.size - 1, 0].max
|
|
1194
|
+
|
|
1195
|
+
# Split into scrollback and visible grid
|
|
1196
|
+
# The visible grid should have new_rows rows; excess goes to scrollback
|
|
1197
|
+
if new_all_rows.size <= new_rows
|
|
1198
|
+
@scrollback = []
|
|
1199
|
+
@scrollback_wrapped = []
|
|
1200
|
+
@grid = new_all_rows
|
|
1201
|
+
@line_wrapped = new_all_wrapped
|
|
1202
|
+
# Pad to fill screen
|
|
1203
|
+
while @grid.size < new_rows
|
|
1204
|
+
@grid.push(Array.new(new_cols) { Cell.new })
|
|
1205
|
+
@line_wrapped.push(false)
|
|
1206
|
+
end
|
|
1207
|
+
@cursor.row = [cursor_new_abs, new_rows - 1].min
|
|
1208
|
+
else
|
|
1209
|
+
split = new_all_rows.size - new_rows
|
|
1210
|
+
# Ensure cursor is visible
|
|
1211
|
+
if cursor_new_abs < split
|
|
1212
|
+
split = cursor_new_abs
|
|
1213
|
+
end
|
|
1214
|
+
@scrollback = new_all_rows[0...split]
|
|
1215
|
+
@scrollback_wrapped = new_all_wrapped[0...split]
|
|
1216
|
+
@grid = new_all_rows[split..]
|
|
1217
|
+
@line_wrapped = new_all_wrapped[split..]
|
|
1218
|
+
# Trim or pad grid to exactly new_rows
|
|
1219
|
+
while @grid.size < new_rows
|
|
1220
|
+
@grid.push(Array.new(new_cols) { Cell.new })
|
|
1221
|
+
@line_wrapped.push(false)
|
|
1222
|
+
end
|
|
1223
|
+
if @grid.size > new_rows
|
|
1224
|
+
# Push excess to scrollback
|
|
1225
|
+
excess = @grid.size - new_rows
|
|
1226
|
+
@scrollback.concat(@grid.slice!(0, excess))
|
|
1227
|
+
@scrollback_wrapped.concat(@line_wrapped.slice!(0, excess))
|
|
1228
|
+
end
|
|
1229
|
+
@cursor.row = cursor_new_abs - split
|
|
1230
|
+
@cursor.row = @cursor.row.clamp(0, new_rows - 1)
|
|
1231
|
+
end
|
|
1232
|
+
|
|
1233
|
+
# Trim scrollback to limit
|
|
1234
|
+
while @scrollback.size > self.class.scrollback_limit
|
|
1235
|
+
@scrollback.shift
|
|
1236
|
+
@scrollback_wrapped.shift
|
|
1237
|
+
end
|
|
1238
|
+
|
|
1239
|
+
@cursor.col = [@cursor.col, new_cols - 1].min
|
|
1240
|
+
end
|
|
1241
|
+
|
|
1242
|
+
def default_tab_stops
|
|
1243
|
+
(8...@cols).step(8).to_a
|
|
1244
|
+
end
|
|
1245
|
+
|
|
1246
|
+
def clamp_row(row)
|
|
1247
|
+
[[row, 0].max, @rows - 1].min
|
|
1248
|
+
end
|
|
1249
|
+
|
|
1250
|
+
def clamp_col(col)
|
|
1251
|
+
[[col, 0].max, @cols - 1].min
|
|
1252
|
+
end
|
|
1253
|
+
|
|
1254
|
+
def clear_row(r)
|
|
1255
|
+
@grid[r].each(&:reset!)
|
|
1256
|
+
end
|
|
1257
|
+
|
|
1258
|
+
def char_class(c)
|
|
1259
|
+
if c =~ /\s/
|
|
1260
|
+
:space
|
|
1261
|
+
elsif c =~ /\w/
|
|
1262
|
+
:word
|
|
1263
|
+
else
|
|
1264
|
+
:other
|
|
1265
|
+
end
|
|
1266
|
+
end
|
|
1267
|
+
|
|
1268
|
+
def place_multicell_block(text, mc_cols, mc_rows, scale, frac_n, frac_d, valign, halign, family = nil)
|
|
1269
|
+
# Discard if block is larger than screen
|
|
1270
|
+
return if mc_cols > @cols || mc_rows > @rows
|
|
1271
|
+
|
|
1272
|
+
# Wrap if it doesn't fit on current line
|
|
1273
|
+
if @cursor.col + mc_cols > @cols
|
|
1274
|
+
@cursor.col = 0
|
|
1275
|
+
line_feed
|
|
1276
|
+
end
|
|
1277
|
+
|
|
1278
|
+
# Scroll if block doesn't fit vertically from cursor
|
|
1279
|
+
while @cursor.row + mc_rows > @rows
|
|
1280
|
+
scroll_up(1)
|
|
1281
|
+
@cursor.row = [@cursor.row - 1, 0].max
|
|
1282
|
+
end
|
|
1283
|
+
|
|
1284
|
+
anchor_row = @cursor.row
|
|
1285
|
+
anchor_col = @cursor.col
|
|
1286
|
+
|
|
1287
|
+
# Erase any existing multicells in the block area
|
|
1288
|
+
mc_rows.times do |dr|
|
|
1289
|
+
mc_cols.times do |dc|
|
|
1290
|
+
erase_multicell_at(anchor_row + dr, anchor_col + dc)
|
|
1291
|
+
end
|
|
1292
|
+
end
|
|
1293
|
+
|
|
1294
|
+
# Set anchor cell
|
|
1295
|
+
anchor = @grid[anchor_row][anchor_col]
|
|
1296
|
+
anchor.copy_from(@attrs)
|
|
1297
|
+
anchor.char = text
|
|
1298
|
+
anchor.width = 1
|
|
1299
|
+
anchor.multicell = {
|
|
1300
|
+
cols: mc_cols, rows: mc_rows, scale: scale,
|
|
1301
|
+
frac_n: frac_n, frac_d: frac_d, valign: valign, halign: halign,
|
|
1302
|
+
family: family
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
# Mark continuation cells
|
|
1306
|
+
mc_rows.times do |dr|
|
|
1307
|
+
mc_cols.times do |dc|
|
|
1308
|
+
next if dr == 0 && dc == 0
|
|
1309
|
+
cont = @grid[anchor_row + dr][anchor_col + dc]
|
|
1310
|
+
cont.reset!
|
|
1311
|
+
cont.multicell = :cont
|
|
1312
|
+
end
|
|
1313
|
+
end
|
|
1314
|
+
|
|
1315
|
+
@cursor.col += mc_cols
|
|
1316
|
+
end
|
|
1317
|
+
|
|
1318
|
+
def erase_multicell_at(row, col)
|
|
1319
|
+
cell = @grid[row][col]
|
|
1320
|
+
return unless cell.multicell
|
|
1321
|
+
|
|
1322
|
+
if cell.multicell.is_a?(Hash)
|
|
1323
|
+
# This is the anchor — erase the whole block
|
|
1324
|
+
mc = cell.multicell
|
|
1325
|
+
mc[:rows].times do |dr|
|
|
1326
|
+
mc[:cols].times do |dc|
|
|
1327
|
+
@grid[row + dr][col + dc].reset!
|
|
1328
|
+
end
|
|
1329
|
+
end
|
|
1330
|
+
elsif cell.multicell == :cont
|
|
1331
|
+
# Find the anchor by scanning up and left
|
|
1332
|
+
find_multicell_anchor(row, col)&.then do |ar, ac|
|
|
1333
|
+
erase_multicell_at(ar, ac)
|
|
1334
|
+
end
|
|
1335
|
+
end
|
|
1336
|
+
end
|
|
1337
|
+
|
|
1338
|
+
def find_multicell_anchor(row, col)
|
|
1339
|
+
# Scan backwards to find the anchor cell
|
|
1340
|
+
(row).downto(0) do |r|
|
|
1341
|
+
start_col = (r == row) ? col : @cols - 1
|
|
1342
|
+
start_col.downto(0) do |c|
|
|
1343
|
+
cell = @grid[r][c]
|
|
1344
|
+
if cell.multicell.is_a?(Hash)
|
|
1345
|
+
mc = cell.multicell
|
|
1346
|
+
# Check if (row, col) falls within this anchor's block
|
|
1347
|
+
if row < r + mc[:rows] && col >= c && col < c + mc[:cols]
|
|
1348
|
+
return [r, c]
|
|
1349
|
+
end
|
|
1350
|
+
end
|
|
1351
|
+
end
|
|
1352
|
+
end
|
|
1353
|
+
nil
|
|
1354
|
+
end
|
|
1355
|
+
|
|
1356
|
+
def combining?(c)
|
|
1357
|
+
cp = c.ord
|
|
1358
|
+
return false if cp < 0x0300
|
|
1359
|
+
(cp >= 0x0300 && cp <= 0x036F) || # Combining Diacritical Marks
|
|
1360
|
+
(cp >= 0x0483 && cp <= 0x0489) || # Cyrillic combining marks
|
|
1361
|
+
(cp >= 0x0591 && cp <= 0x05BD) || # Hebrew combining marks
|
|
1362
|
+
cp == 0x05BF ||
|
|
1363
|
+
(cp >= 0x05C1 && cp <= 0x05C2) ||
|
|
1364
|
+
(cp >= 0x05C4 && cp <= 0x05C5) ||
|
|
1365
|
+
cp == 0x05C7 ||
|
|
1366
|
+
(cp >= 0x0610 && cp <= 0x061A) || # Arabic combining marks
|
|
1367
|
+
(cp >= 0x064B && cp <= 0x065F) ||
|
|
1368
|
+
cp == 0x0670 ||
|
|
1369
|
+
(cp >= 0x06D6 && cp <= 0x06DC) ||
|
|
1370
|
+
(cp >= 0x06DF && cp <= 0x06E4) ||
|
|
1371
|
+
(cp >= 0x06E7 && cp <= 0x06E8) ||
|
|
1372
|
+
(cp >= 0x06EA && cp <= 0x06ED) ||
|
|
1373
|
+
cp == 0x0711 ||
|
|
1374
|
+
(cp >= 0x0730 && cp <= 0x074A) || # Syriac
|
|
1375
|
+
(cp >= 0x07A6 && cp <= 0x07B0) || # Thaana
|
|
1376
|
+
(cp >= 0x0816 && cp <= 0x0819) || # Samaritan
|
|
1377
|
+
(cp >= 0x081B && cp <= 0x0823) ||
|
|
1378
|
+
(cp >= 0x0825 && cp <= 0x0827) ||
|
|
1379
|
+
(cp >= 0x0829 && cp <= 0x082D) ||
|
|
1380
|
+
(cp >= 0x0859 && cp <= 0x085B) || # Mandaic
|
|
1381
|
+
(cp >= 0x0900 && cp <= 0x0903) || # Devanagari
|
|
1382
|
+
(cp >= 0x093A && cp <= 0x094F) ||
|
|
1383
|
+
(cp >= 0x0951 && cp <= 0x0957) ||
|
|
1384
|
+
(cp >= 0x0962 && cp <= 0x0963) ||
|
|
1385
|
+
(cp >= 0x0981 && cp <= 0x0983) || # Bengali
|
|
1386
|
+
cp == 0x09BC || cp == 0x09CD ||
|
|
1387
|
+
(cp >= 0x09BE && cp <= 0x09C4) ||
|
|
1388
|
+
(cp >= 0x0A01 && cp <= 0x0A03) || # Gurmukhi
|
|
1389
|
+
(cp >= 0x0A3C && cp <= 0x0A51) ||
|
|
1390
|
+
(cp >= 0x0B01 && cp <= 0x0B03) || # Oriya
|
|
1391
|
+
(cp >= 0x0B3C && cp <= 0x0B57) ||
|
|
1392
|
+
(cp >= 0x0BBE && cp <= 0x0BCD) || # Tamil
|
|
1393
|
+
(cp >= 0x0C00 && cp <= 0x0C04) || # Telugu
|
|
1394
|
+
(cp >= 0x0C3E && cp <= 0x0C56) ||
|
|
1395
|
+
(cp >= 0x0C81 && cp <= 0x0C83) || # Kannada
|
|
1396
|
+
(cp >= 0x0CBC && cp <= 0x0CD6) ||
|
|
1397
|
+
(cp >= 0x0D00 && cp <= 0x0D03) || # Malayalam
|
|
1398
|
+
(cp >= 0x0D3B && cp <= 0x0D4D) ||
|
|
1399
|
+
(cp >= 0x0D57 && cp <= 0x0D57) ||
|
|
1400
|
+
(cp >= 0x0DCA && cp <= 0x0DDF) || # Sinhala
|
|
1401
|
+
(cp >= 0x0E31 && cp <= 0x0E3A) || # Thai
|
|
1402
|
+
(cp >= 0x0E47 && cp <= 0x0E4E) ||
|
|
1403
|
+
(cp >= 0x0EB1 && cp <= 0x0EBC) || # Lao
|
|
1404
|
+
(cp >= 0x0EC8 && cp <= 0x0ECD) ||
|
|
1405
|
+
(cp >= 0x0F18 && cp <= 0x0F19) || # Tibetan
|
|
1406
|
+
cp == 0x0F35 || cp == 0x0F37 || cp == 0x0F39 ||
|
|
1407
|
+
(cp >= 0x0F3E && cp <= 0x0F3F) ||
|
|
1408
|
+
(cp >= 0x0F71 && cp <= 0x0F84) ||
|
|
1409
|
+
(cp >= 0x0F86 && cp <= 0x0F87) ||
|
|
1410
|
+
(cp >= 0x0F8D && cp <= 0x0FBC) ||
|
|
1411
|
+
cp == 0x0FC6 ||
|
|
1412
|
+
(cp >= 0x1000 && cp <= 0x1059) && c =~ /\p{M}/ || # Myanmar (selective)
|
|
1413
|
+
(cp >= 0x135D && cp <= 0x135F) || # Ethiopic
|
|
1414
|
+
(cp >= 0x1712 && cp <= 0x1714) || # Tagalog
|
|
1415
|
+
(cp >= 0x1732 && cp <= 0x1734) || # Hanunoo
|
|
1416
|
+
(cp >= 0x17B4 && cp <= 0x17D3) || # Khmer
|
|
1417
|
+
cp == 0x17DD ||
|
|
1418
|
+
(cp >= 0x180B && cp <= 0x180D) || # Mongolian
|
|
1419
|
+
cp == 0x180F ||
|
|
1420
|
+
(cp >= 0x1885 && cp <= 0x1886) ||
|
|
1421
|
+
cp == 0x18A9 ||
|
|
1422
|
+
(cp >= 0x1920 && cp <= 0x193B) || # Limbu/Tai Le
|
|
1423
|
+
(cp >= 0x1A17 && cp <= 0x1A1B) || # Buginese
|
|
1424
|
+
(cp >= 0x1A55 && cp <= 0x1A7F) || # Tai Tham
|
|
1425
|
+
(cp >= 0x1AB0 && cp <= 0x1ACE) || # Combining Diacritical Marks Extended
|
|
1426
|
+
(cp >= 0x1B00 && cp <= 0x1B04) || # Balinese
|
|
1427
|
+
(cp >= 0x1B34 && cp <= 0x1B44) ||
|
|
1428
|
+
(cp >= 0x1B6B && cp <= 0x1B73) ||
|
|
1429
|
+
(cp >= 0x1B80 && cp <= 0x1B82) || # Sundanese
|
|
1430
|
+
(cp >= 0x1BA1 && cp <= 0x1BAD) ||
|
|
1431
|
+
(cp >= 0x1BE6 && cp <= 0x1BF3) || # Batak
|
|
1432
|
+
(cp >= 0x1C24 && cp <= 0x1C37) || # Lepcha
|
|
1433
|
+
(cp >= 0x1CD0 && cp <= 0x1CF9) || # Vedic Extensions
|
|
1434
|
+
(cp >= 0x1DC0 && cp <= 0x1DFF) || # Combining Diacritical Marks Supplement
|
|
1435
|
+
(cp >= 0x20D0 && cp <= 0x20FF) || # Combining Diacritical Marks for Symbols
|
|
1436
|
+
(cp >= 0xFE00 && cp <= 0xFE0F) || # Variation Selectors
|
|
1437
|
+
(cp >= 0xFE20 && cp <= 0xFE2F) || # Combining Half Marks
|
|
1438
|
+
(cp >= 0x101FD && cp <= 0x101FD) || # Phaistos Disc
|
|
1439
|
+
(cp >= 0x102E0 && cp <= 0x102E0) ||
|
|
1440
|
+
(cp >= 0x10376 && cp <= 0x1037A) ||
|
|
1441
|
+
(cp >= 0x10A01 && cp <= 0x10A0F) ||
|
|
1442
|
+
(cp >= 0x10A38 && cp <= 0x10A3F) ||
|
|
1443
|
+
(cp >= 0x11000 && cp <= 0x1104D) && c =~ /\p{M}/ || # Brahmi etc (selective)
|
|
1444
|
+
(cp >= 0x1D165 && cp <= 0x1D1AD) || # Musical Symbols combining
|
|
1445
|
+
(cp >= 0x1D242 && cp <= 0x1D244) ||
|
|
1446
|
+
(cp >= 0xE0100 && cp <= 0xE01EF) # Variation Selectors Supplement
|
|
1447
|
+
end
|
|
1448
|
+
|
|
1449
|
+
def char_width(c)
|
|
1450
|
+
cp = c.ord
|
|
1451
|
+
return 2 if (cp >= 0x1100 && cp <= 0x115F) || # Hangul Jamo
|
|
1452
|
+
cp == 0x2329 || cp == 0x232A || # angle brackets
|
|
1453
|
+
(cp >= 0x2E80 && cp <= 0x303E) || # CJK Radicals..CJK Symbols
|
|
1454
|
+
(cp >= 0x3040 && cp <= 0x33BF) || # Hiragana..CJK Compat
|
|
1455
|
+
(cp >= 0x3400 && cp <= 0x4DBF) || # CJK Unified Ext A
|
|
1456
|
+
(cp >= 0x4E00 && cp <= 0xA4CF) || # CJK Unified..Yi
|
|
1457
|
+
(cp >= 0xA960 && cp <= 0xA97C) || # Hangul Jamo Extended-A
|
|
1458
|
+
(cp >= 0xAC00 && cp <= 0xD7A3) || # Hangul Syllables
|
|
1459
|
+
(cp >= 0xF900 && cp <= 0xFAFF) || # CJK Compat Ideographs
|
|
1460
|
+
(cp >= 0xFE10 && cp <= 0xFE6F) || # Vertical forms..CJK Compat Forms
|
|
1461
|
+
(cp >= 0xFF01 && cp <= 0xFF60) || # Fullwidth Forms
|
|
1462
|
+
(cp >= 0xFFE0 && cp <= 0xFFE6) || # Fullwidth Signs
|
|
1463
|
+
(cp >= 0x1F000 && cp <= 0x1FBFF) || # Emoji & symbols
|
|
1464
|
+
(cp >= 0x20000 && cp <= 0x3FFFF) # CJK Unified Ext B-G
|
|
1465
|
+
1
|
|
1466
|
+
end
|
|
1467
|
+
end
|
|
1468
|
+
end
|