echoes 0.2.0 → 0.3.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 +4 -4
- data/lib/echoes/client.rb +30 -9
- data/lib/echoes/configuration.rb +131 -0
- data/lib/echoes/embedded_shell.rb +9 -4
- data/lib/echoes/gui.rb +962 -108
- data/lib/echoes/iterm2_images.rb +122 -0
- data/lib/echoes/kitty_graphics.rb +320 -0
- data/lib/echoes/kitty_graphics_appkit.rb +174 -0
- data/lib/echoes/objc.rb +2 -0
- data/lib/echoes/pane.rb +108 -9
- data/lib/echoes/parser.rb +145 -10
- data/lib/echoes/profile.rb +75 -0
- data/lib/echoes/screen.rb +144 -9
- data/lib/echoes/tab.rb +2 -2
- data/lib/echoes/version.rb +1 -1
- metadata +5 -1
data/lib/echoes/screen.rb
CHANGED
|
@@ -8,6 +8,14 @@ module Echoes
|
|
|
8
8
|
:command_marks
|
|
9
9
|
attr_accessor :cell_pixel_width, :cell_pixel_height, :title, :current_directory,
|
|
10
10
|
:pending_wrap, :background, :bg_fills
|
|
11
|
+
# Tracks what kitty-graphics images are currently visible on
|
|
12
|
+
# this pane's grid. Each entry: {image_id:, anchor_row:,
|
|
13
|
+
# anchor_col:, cell_cols:, cell_rows:, x_off:, y_off:, image:}.
|
|
14
|
+
# image: is the {rgba:, width:, height:} hash put_kitty_image
|
|
15
|
+
# received (held by reference, not a deep copy — the parser's
|
|
16
|
+
# bitmap cache and the placement share the same object so a
|
|
17
|
+
# later a=d ;d=I … can delete by image id without re-decoding).
|
|
18
|
+
attr_reader :placements
|
|
11
19
|
|
|
12
20
|
def self.scrollback_limit
|
|
13
21
|
Echoes.config.scrollback_limit
|
|
@@ -30,6 +38,7 @@ module Echoes
|
|
|
30
38
|
@application_cursor_keys = false
|
|
31
39
|
@bracketed_paste_mode = false
|
|
32
40
|
@focus_reporting = false
|
|
41
|
+
@sync_active = false # DEC private mode 2026 (synchronized output)
|
|
33
42
|
@auto_wrap = true
|
|
34
43
|
@mouse_tracking = :off # :off, :x10, :normal, :button_event, :any_event
|
|
35
44
|
@mouse_encoding = :default # :default, :sgr
|
|
@@ -54,6 +63,7 @@ module Echoes
|
|
|
54
63
|
@pending_wrap = false
|
|
55
64
|
@last_char = nil
|
|
56
65
|
@title_stack = []
|
|
66
|
+
@placements = []
|
|
57
67
|
@dirty_rows = Set.new((0...rows).to_a)
|
|
58
68
|
@bg_fills = [] # OSC 7772 ;bg-fill regions; each: {rect:[r1,c1,r2,c2], color:[r,g,b,a]}
|
|
59
69
|
# OSC 133 prompt-boundary markers: each entry is a Hash with
|
|
@@ -163,12 +173,13 @@ module Echoes
|
|
|
163
173
|
n.times { put_char(@last_char) }
|
|
164
174
|
end
|
|
165
175
|
|
|
166
|
-
def put_multicell(text, scale:, width:, frac_n:, frac_d:, valign:, halign:,
|
|
176
|
+
def put_multicell(text, scale:, width:, frac_n:, frac_d:, valign:, halign:,
|
|
177
|
+
family: nil, flip_h: false, flip_v: false)
|
|
167
178
|
mc_rows = scale
|
|
168
179
|
|
|
169
180
|
if width > 0
|
|
170
181
|
# 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)
|
|
182
|
+
place_multicell_block(text, scale * width, mc_rows, scale, frac_n, frac_d, valign, halign, family, flip_h, flip_v)
|
|
172
183
|
elsif halign != 0
|
|
173
184
|
# h= is set: render the whole string as one block of
|
|
174
185
|
# `scale × source_chars` cells, so the renderer's halign math
|
|
@@ -188,7 +199,7 @@ module Echoes
|
|
|
188
199
|
mc_cols = [mc_cols, measured_cells].max
|
|
189
200
|
end
|
|
190
201
|
mc_cols = [mc_cols, 1].max
|
|
191
|
-
place_multicell_block(text, mc_cols, mc_rows, scale, frac_n, frac_d, valign, halign, family)
|
|
202
|
+
place_multicell_block(text, mc_cols, mc_rows, scale, frac_n, frac_d, valign, halign, family, flip_h, flip_v)
|
|
192
203
|
elsif family && @glyph_measurer && @cell_pixel_width && @cell_pixel_width > 0
|
|
193
204
|
# Proportional fonts have variable glyph widths, so reserving
|
|
194
205
|
# `char_width(grapheme) * scale` cells per grapheme leaves big
|
|
@@ -200,18 +211,126 @@ module Echoes
|
|
|
200
211
|
measured_px = @glyph_measurer.call(text, family, scale, frac_n, frac_d).to_f
|
|
201
212
|
mc_cols = (measured_px / @cell_pixel_width).ceil
|
|
202
213
|
mc_cols = [mc_cols, 1].max
|
|
203
|
-
place_multicell_block(text, mc_cols, mc_rows, scale, frac_n, frac_d, valign, halign, family)
|
|
214
|
+
place_multicell_block(text, mc_cols, mc_rows, scale, frac_n, frac_d, valign, halign, family, flip_h, flip_v)
|
|
204
215
|
else
|
|
205
216
|
# Auto width: each grapheme gets its own block (monospace
|
|
206
217
|
# assumption — fine for the configured terminal font).
|
|
207
218
|
text.each_grapheme_cluster do |grapheme|
|
|
208
219
|
cw = char_width(grapheme)
|
|
209
220
|
mc_cols = scale * cw
|
|
210
|
-
place_multicell_block(grapheme, mc_cols, mc_rows, scale, frac_n, frac_d, valign, halign, family)
|
|
221
|
+
place_multicell_block(grapheme, mc_cols, mc_rows, scale, frac_n, frac_d, valign, halign, family, flip_h, flip_v)
|
|
211
222
|
end
|
|
212
223
|
end
|
|
213
224
|
end
|
|
214
225
|
|
|
226
|
+
# Place a Kitty-graphics-protocol image as a multicell anchor.
|
|
227
|
+
# `rgba` is a Ruby string of width*height*4 bytes (RGBA8, top
|
|
228
|
+
# row first). `cells_w` / `cells_h` come from the wire's `c=` /
|
|
229
|
+
# `r=` options; when nil we size to the image's natural pixel
|
|
230
|
+
# dims divided by cell pixel size. `suppress_cursor` honors the
|
|
231
|
+
# `C=1` request to leave the cursor where it was.
|
|
232
|
+
#
|
|
233
|
+
# The renderer is format-agnostic: it just blits `multicell.sixel`'s
|
|
234
|
+
# RGBA into the reserved cell rect, so we reuse that storage key
|
|
235
|
+
# rather than introducing a parallel `:image` key.
|
|
236
|
+
def put_kitty_image(rgba:, width:, height:, cells_w: nil, cells_h: nil,
|
|
237
|
+
px_x_offset: 0, px_y_offset: 0,
|
|
238
|
+
suppress_cursor: false, image_id: nil)
|
|
239
|
+
return if rgba.nil? || width <= 0 || height <= 0
|
|
240
|
+
return if @cell_pixel_width.to_f <= 0 || @cell_pixel_height.to_f <= 0
|
|
241
|
+
|
|
242
|
+
mc_cols = cells_w && cells_w > 0 ? cells_w : (width / @cell_pixel_width ).ceil
|
|
243
|
+
mc_rows = cells_h && cells_h > 0 ? cells_h : (height / @cell_pixel_height).ceil
|
|
244
|
+
mc_cols = [mc_cols, 1].max
|
|
245
|
+
mc_rows = [mc_rows, 1].max
|
|
246
|
+
return if mc_cols > @cols || mc_rows > @rows
|
|
247
|
+
|
|
248
|
+
if suppress_cursor
|
|
249
|
+
# C=1 (slide-presentation mode): anchor at the current
|
|
250
|
+
# cursor without wrapping or scrolling. A multi-image
|
|
251
|
+
# slideshow would otherwise accumulate a cumulative
|
|
252
|
+
# scroll offset every time an image landed near the
|
|
253
|
+
# bottom, dragging earlier rows off-screen. If the image
|
|
254
|
+
# doesn't fit at the current position, bail — the client
|
|
255
|
+
# positions the cursor deliberately and would rather see
|
|
256
|
+
# nothing than have the layout shift out from under it.
|
|
257
|
+
return if @cursor.col + mc_cols > @cols
|
|
258
|
+
return if @cursor.row + mc_rows > @rows
|
|
259
|
+
else
|
|
260
|
+
if @cursor.col + mc_cols > @cols
|
|
261
|
+
@cursor.col = 0
|
|
262
|
+
line_feed
|
|
263
|
+
end
|
|
264
|
+
while @cursor.row + mc_rows > @rows
|
|
265
|
+
scroll_up(1)
|
|
266
|
+
@cursor.row = [@cursor.row - 1, 0].max
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
anchor_row = @cursor.row
|
|
271
|
+
anchor_col = @cursor.col
|
|
272
|
+
|
|
273
|
+
mc_rows.times do |dr|
|
|
274
|
+
mc_cols.times do |dc|
|
|
275
|
+
erase_multicell_at(anchor_row + dr, anchor_col + dc)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
anchor = @grid[anchor_row][anchor_col]
|
|
280
|
+
anchor.reset!
|
|
281
|
+
anchor.char = " "
|
|
282
|
+
anchor.width = 1
|
|
283
|
+
# Multicell anchor reserves the cell rect (cursor flow,
|
|
284
|
+
# `:cont` neighbors stay skipped by the renderer); the
|
|
285
|
+
# actual image is drawn by the GUI's placement re-blit
|
|
286
|
+
# pass off `screen.placements`, not via mc[:sixel]. Keeps
|
|
287
|
+
# the kitty path independent of the cell loop so deletes,
|
|
288
|
+
# scrolls, and font changes affect drawing through one
|
|
289
|
+
# code path.
|
|
290
|
+
anchor.multicell = {
|
|
291
|
+
cols: mc_cols, rows: mc_rows, scale: 1,
|
|
292
|
+
frac_n: 0, frac_d: 0, valign: 0, halign: 0,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
mc_rows.times do |dr|
|
|
296
|
+
mc_cols.times do |dc|
|
|
297
|
+
next if dr == 0 && dc == 0
|
|
298
|
+
cont = @grid[anchor_row + dr][anchor_col + dc]
|
|
299
|
+
cont.reset!
|
|
300
|
+
cont.multicell = :cont
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Record the placement BEFORE any post-place scroll fires —
|
|
305
|
+
# if the cursor advance below pushes us past the bottom and
|
|
306
|
+
# triggers scroll_up, that path needs the placement already
|
|
307
|
+
# in @placements so its anchor_row gets decremented along
|
|
308
|
+
# with the rest of the content.
|
|
309
|
+
@placements << {
|
|
310
|
+
image_id: image_id,
|
|
311
|
+
anchor_row: anchor_row,
|
|
312
|
+
anchor_col: anchor_col,
|
|
313
|
+
cell_cols: mc_cols,
|
|
314
|
+
cell_rows: mc_rows,
|
|
315
|
+
x_off: px_x_offset.to_i,
|
|
316
|
+
y_off: px_y_offset.to_i,
|
|
317
|
+
image: {rgba: rgba, width: width, height: height},
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
unless suppress_cursor
|
|
321
|
+
# Sixel parity: cursor lands at column 0 of the row after
|
|
322
|
+
# the image. If that row is past the bottom, scroll.
|
|
323
|
+
@cursor.col = 0
|
|
324
|
+
@cursor.row += mc_rows
|
|
325
|
+
while @cursor.row >= @rows
|
|
326
|
+
scroll_up(1)
|
|
327
|
+
@cursor.row -= 1
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
mark_all_dirty
|
|
332
|
+
end
|
|
333
|
+
|
|
215
334
|
def put_sixel(data, params)
|
|
216
335
|
decoder = SixelDecoder.new(params).decode(data)
|
|
217
336
|
return if decoder.width == 0 || decoder.height == 0
|
|
@@ -382,6 +501,7 @@ module Echoes
|
|
|
382
501
|
(0...@cursor.row).each { |r| clear_row(r); @line_wrapped[r] = false; mark_dirty(r) }
|
|
383
502
|
when 2
|
|
384
503
|
(0...@rows).each { |r| clear_row(r); @line_wrapped[r] = false }
|
|
504
|
+
@placements.clear
|
|
385
505
|
mark_all_dirty
|
|
386
506
|
when 3
|
|
387
507
|
@scrollback.clear
|
|
@@ -475,9 +595,20 @@ module Echoes
|
|
|
475
595
|
@grid.insert(@scroll_bottom, Array.new(@cols) { Cell.new })
|
|
476
596
|
@line_wrapped.insert(@scroll_bottom, false)
|
|
477
597
|
end
|
|
598
|
+
shift_placements(-n)
|
|
478
599
|
(@scroll_top..@scroll_bottom).each { |r| mark_dirty(r) }
|
|
479
600
|
end
|
|
480
601
|
|
|
602
|
+
# Move every placement anchor by `delta` rows and drop entries
|
|
603
|
+
# that have scrolled entirely off-screen. Called by scroll_up
|
|
604
|
+
# so the placement list tracks the same visual shift the grid
|
|
605
|
+
# rows just took.
|
|
606
|
+
def shift_placements(delta)
|
|
607
|
+
return if @placements.empty?
|
|
608
|
+
@placements.each { |p| p[:anchor_row] += delta }
|
|
609
|
+
@placements.reject! { |p| p[:anchor_row] + p[:cell_rows] <= 0 }
|
|
610
|
+
end
|
|
611
|
+
|
|
481
612
|
def scroll_down(n = 1)
|
|
482
613
|
@pending_wrap = false
|
|
483
614
|
n.times do
|
|
@@ -679,7 +810,7 @@ module Echoes
|
|
|
679
810
|
@pending_wrap = false
|
|
680
811
|
end
|
|
681
812
|
|
|
682
|
-
attr_accessor :mouse_tracking, :mouse_encoding, :insert_mode, :active_charset, :application_keypad, :cursor_style, :bell, :single_shift
|
|
813
|
+
attr_accessor :mouse_tracking, :mouse_encoding, :insert_mode, :active_charset, :application_keypad, :cursor_style, :bell, :single_shift, :sync_active
|
|
683
814
|
|
|
684
815
|
def push_title
|
|
685
816
|
@title_stack.push(@title)
|
|
@@ -868,7 +999,9 @@ module Echoes
|
|
|
868
999
|
end
|
|
869
1000
|
end
|
|
870
1001
|
|
|
871
|
-
attr_accessor :clipboard_handler, :palette_handler, :glyph_measurer
|
|
1002
|
+
attr_accessor :clipboard_handler, :palette_handler, :glyph_measurer,
|
|
1003
|
+
:capture_handler, :notification_handler,
|
|
1004
|
+
:display_info_handler, :open_window_handler
|
|
872
1005
|
|
|
873
1006
|
def set_clipboard(text)
|
|
874
1007
|
@clipboard_handler&.call(:set, text)
|
|
@@ -1019,6 +1152,7 @@ module Echoes
|
|
|
1019
1152
|
@application_cursor_keys = false
|
|
1020
1153
|
@bracketed_paste_mode = false
|
|
1021
1154
|
@focus_reporting = false
|
|
1155
|
+
@sync_active = false # DEC private mode 2026 (synchronized output)
|
|
1022
1156
|
@charset_g0 = :ascii
|
|
1023
1157
|
@charset_g1 = :ascii
|
|
1024
1158
|
@charset_g2 = :ascii
|
|
@@ -1057,6 +1191,7 @@ module Echoes
|
|
|
1057
1191
|
@scrollback_wrapped = []
|
|
1058
1192
|
@tab_stops = default_tab_stops
|
|
1059
1193
|
@pending_wrap = false
|
|
1194
|
+
@placements = []
|
|
1060
1195
|
mark_all_dirty
|
|
1061
1196
|
end
|
|
1062
1197
|
|
|
@@ -1265,7 +1400,7 @@ module Echoes
|
|
|
1265
1400
|
end
|
|
1266
1401
|
end
|
|
1267
1402
|
|
|
1268
|
-
def place_multicell_block(text, mc_cols, mc_rows, scale, frac_n, frac_d, valign, halign, family = nil)
|
|
1403
|
+
def place_multicell_block(text, mc_cols, mc_rows, scale, frac_n, frac_d, valign, halign, family = nil, flip_h = false, flip_v = false)
|
|
1269
1404
|
# Discard if block is larger than screen
|
|
1270
1405
|
return if mc_cols > @cols || mc_rows > @rows
|
|
1271
1406
|
|
|
@@ -1299,7 +1434,7 @@ module Echoes
|
|
|
1299
1434
|
anchor.multicell = {
|
|
1300
1435
|
cols: mc_cols, rows: mc_rows, scale: scale,
|
|
1301
1436
|
frac_n: frac_n, frac_d: frac_d, valign: valign, halign: halign,
|
|
1302
|
-
family: family
|
|
1437
|
+
family: family, flip_h: flip_h, flip_v: flip_v,
|
|
1303
1438
|
}
|
|
1304
1439
|
|
|
1305
1440
|
# Mark continuation cells
|
data/lib/echoes/tab.rb
CHANGED
|
@@ -5,13 +5,13 @@ module Echoes
|
|
|
5
5
|
attr_reader :pane_tree
|
|
6
6
|
attr_accessor :title
|
|
7
7
|
|
|
8
|
-
def initialize(command:, rows:, cols:, cwd: nil, embedded: false, editor_file: nil)
|
|
8
|
+
def initialize(command:, rows:, cols:, cwd: nil, embedded: false, editor_file: nil, env: nil)
|
|
9
9
|
@command = command
|
|
10
10
|
@rows = rows
|
|
11
11
|
@cols = cols
|
|
12
12
|
@embedded = embedded
|
|
13
13
|
pane = Pane.new(command: command, rows: rows, cols: cols, cwd: cwd,
|
|
14
|
-
embedded: embedded, editor_file: editor_file)
|
|
14
|
+
embedded: embedded, editor_file: editor_file, env: env)
|
|
15
15
|
@pane_tree = PaneTree.new(pane)
|
|
16
16
|
@title = pane.title
|
|
17
17
|
end
|
data/lib/echoes/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: echoes
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Akira Matsuda
|
|
@@ -75,11 +75,15 @@ files:
|
|
|
75
75
|
- lib/echoes/embedded_shell_helper.rb
|
|
76
76
|
- lib/echoes/gui.rb
|
|
77
77
|
- lib/echoes/installer.rb
|
|
78
|
+
- lib/echoes/iterm2_images.rb
|
|
79
|
+
- lib/echoes/kitty_graphics.rb
|
|
80
|
+
- lib/echoes/kitty_graphics_appkit.rb
|
|
78
81
|
- lib/echoes/objc.rb
|
|
79
82
|
- lib/echoes/pane.rb
|
|
80
83
|
- lib/echoes/pane_tree.rb
|
|
81
84
|
- lib/echoes/parser.rb
|
|
82
85
|
- lib/echoes/preferences.rb
|
|
86
|
+
- lib/echoes/profile.rb
|
|
83
87
|
- lib/echoes/screen.rb
|
|
84
88
|
- lib/echoes/sixel_decoder.rb
|
|
85
89
|
- lib/echoes/tab.rb
|