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.
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:, family: nil)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Echoes
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
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.2.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