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/gui.rb CHANGED
@@ -4,6 +4,7 @@ require 'pty'
4
4
  require 'shellwords'
5
5
  require 'socket'
6
6
  require 'uri'
7
+ require 'json'
7
8
 
8
9
  module Echoes
9
10
  class GUI
@@ -24,6 +25,11 @@ module Echoes
24
25
  end
25
26
 
26
27
  def initialize(command: Echoes.config.shell, rows: Echoes.config.rows, cols: Echoes.config.cols, font_size: nil)
28
+ # Advertise ourselves the way other terminals do (iTerm2,
29
+ # WezTerm, Ghostty, …). Child shells and any program they
30
+ # spawn inherit these via the normal env-inheritance path.
31
+ ENV['TERM_PROGRAM'] = 'Echoes'
32
+ ENV['TERM_PROGRAM_VERSION'] = Echoes::VERSION
27
33
  @rows = rows
28
34
  @cols = cols
29
35
  # Persisted font size wins over the config default; both wrappers
@@ -32,17 +38,19 @@ module Echoes
32
38
  @command = command
33
39
  @tabs = []
34
40
  @active_tab = 0
41
+ @active_profile = Echoes.config.default_profile
35
42
  @colors = build_color_table
36
- @default_fg = make_color(*Echoes.config.foreground)
37
- @default_bg = make_color(*Echoes.config.background)
43
+ @default_fg = make_color(*@active_profile.foreground)
44
+ @default_bg = make_color(*@active_profile.background)
38
45
  @tab_bg = make_color(0.15, 0.15, 0.15)
39
- @tab_active_bg = make_color(0.3, 0.3, 0.3)
40
- @tab_fg = make_color(0.8, 0.8, 0.8)
41
- @selection_color = make_color(*Echoes.config.selection_color)
46
+ @tab_fg_active = make_color(0.95, 0.95, 0.95)
47
+ @tab_fg_inactive = make_color(0.55, 0.55, 0.55)
48
+ @selection_color = make_color(*@active_profile.selection_color)
42
49
  @search_match_color = make_color(0.6, 0.5, 0.0)
43
50
  @search_current_color = make_color(0.8, 0.6, 0.0)
44
51
  @selection_anchor = nil
45
52
  @selection_end = nil
53
+ @selection_word_anchor = nil
46
54
  @font_cache = {}
47
55
  @rgb_color_cache = {}
48
56
  @nsstring_cache = {}
@@ -52,6 +60,8 @@ module Echoes
52
60
  @search_query = +""
53
61
  @search_matches = []
54
62
  @search_index = -1
63
+ @search_regex_mode = false
64
+ @search_case_insensitive = false
55
65
  @bell_flash = 0
56
66
  @marked_text = nil
57
67
  @current_event = nil
@@ -76,9 +86,20 @@ module Echoes
76
86
  tab = Tab.new(command: @command, rows: @rows, cols: @cols, cwd: cwd,
77
87
  embedded: embedded_mode?, editor_file: editor_file)
78
88
  tab.title = editor_file ? File.basename(editor_file) : "Tab #{@tabs.size + 1}"
79
- tab.panes.each { |pane| wire_screen_handlers(pane.screen) }
80
- @tabs << tab
81
- @active_tab = @tabs.size - 1
89
+ tab.panes.each { |pane| wire_screen_handlers(pane) }
90
+ # Insert next to the current tab (Cmd+T from the middle of
91
+ # the tab bar lands the new tab immediately to the right of
92
+ # the active one, not at the far end) — matches Safari /
93
+ # Chrome / iTerm2 muscle memory.
94
+ if @tabs.empty?
95
+ @tabs << tab
96
+ @active_tab = 0
97
+ else
98
+ insert_at = @active_tab + 1
99
+ @tabs.insert(insert_at, tab)
100
+ @active_tab = insert_at
101
+ end
102
+ reflow_to_current_view_size
82
103
  end
83
104
 
84
105
  # Convert the active pane's OSC 7 `current_directory` URI into a local
@@ -115,6 +136,7 @@ module Echoes
115
136
  end
116
137
 
117
138
  @active_tab = @active_tab.clamp(0, @tabs.size - 1)
139
+ reflow_to_current_view_size
118
140
  end
119
141
 
120
142
  def current_tab
@@ -218,6 +240,7 @@ module Echoes
218
240
  def setup_app
219
241
  @app = ObjC::MSG_PTR.call(ObjC.cls('NSApplication'), ObjC.sel('sharedApplication'))
220
242
  ObjC::MSG_VOID_I.call(@app, ObjC.sel('setActivationPolicy:'), 0)
243
+ disable_press_and_hold
221
244
  # Disable native NSWindow tabbing so Cmd+N always spawns a real
222
245
  # new window. Default macOS behavior in fullscreen is to fold
223
246
  # additional NSWindows into the same OS-level tabbed window —
@@ -229,6 +252,24 @@ module Echoes
229
252
  setup_menu_bar
230
253
  end
231
254
 
255
+ # macOS's "Press and Hold" feature (ApplePressAndHoldEnabled,
256
+ # default ON) routes every Latin letter through a state machine
257
+ # that waits to see if the user is summoning the accent popup
258
+ # — for vowels and a handful of consonants it shows variants
259
+ # (à á â ä …), for the rest (B, F, J, M, P, Q, V, X — letters
260
+ # with no diacritical variants in the US English layout) it
261
+ # just silently suppresses key-repeat. Terminal users want
262
+ # auto-repeat on every letter, so we register a defaults
263
+ # override scoped to this process: AppKit sees the value on
264
+ # the next keyDown and falls back to plain auto-repeat.
265
+ def disable_press_and_hold
266
+ std = ObjC::MSG_PTR.call(ObjC.cls('NSUserDefaults'), ObjC.sel('standardUserDefaults'))
267
+ dict = ObjC.nsdict({
268
+ ObjC.nsstring('ApplePressAndHoldEnabled') => ObjC.nsnumber_int(0),
269
+ })
270
+ ObjC::MSG_VOID_1.call(std, ObjC.sel('registerDefaults:'), dict)
271
+ end
272
+
232
273
  def setup_menu_bar
233
274
  main_menu = create_menu('')
234
275
 
@@ -252,18 +293,21 @@ module Echoes
252
293
 
253
294
  # View menu
254
295
  view_menu = create_menu('View')
255
- add_menu_item(view_menu, "Bigger", 'increaseFontSize:', '=')
256
- add_menu_item(view_menu, "Bigger", 'increaseFontSize:', '+')
257
- add_menu_item(view_menu, "Smaller", 'decreaseFontSize:', '-')
258
- add_menu_item(view_menu, "Reset Font Size", 'resetFontSize:', '0')
296
+ add_menu_item(view_menu, "Bigger", 'increaseFontSize:', '=', bind: :increase_font_size)
297
+ add_menu_item(view_menu, "Bigger", 'increaseFontSize:', '+', bind: :increase_font_size_plus)
298
+ add_menu_item(view_menu, "Smaller", 'decreaseFontSize:', '-', bind: :decrease_font_size)
299
+ add_menu_item(view_menu, "Reset Font Size", 'resetFontSize:', '0', bind: :reset_font_size)
259
300
  add_separator(view_menu)
260
- add_menu_item(view_menu, "Find", 'toggleFind:', 'f')
261
- add_menu_item(view_menu, "Find Next", 'findNext:', 'g')
301
+ add_menu_item(view_menu, "Find", 'toggleFind:', 'f', bind: :toggle_find)
302
+ add_menu_item(view_menu, "Find Next", 'findNext:', 'g', bind: :find_next)
262
303
  add_menu_item(view_menu, "Find Previous", 'findPrevious:', 'g',
263
- modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)
304
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift,
305
+ bind: :find_previous)
264
306
  add_separator(view_menu)
265
307
  add_menu_item(view_menu, "Hide Mouse Pointer", 'togglePointer:', 'p',
266
- modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)
308
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift,
309
+ bind: :toggle_pointer)
310
+ build_profiles_submenu(view_menu)
267
311
  add_submenu(main_menu, view_menu, 'View')
268
312
 
269
313
  # Window menu
@@ -274,15 +318,18 @@ module Echoes
274
318
  modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagControl)
275
319
  add_separator(window_menu)
276
320
  add_menu_item(window_menu, "Show Previous Tab", 'showPreviousTab:', '{',
277
- modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)
321
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift,
322
+ bind: :show_previous_tab)
278
323
  add_menu_item(window_menu, "Show Next Tab", 'showNextTab:', '}',
279
- modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)
324
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift,
325
+ bind: :show_next_tab)
280
326
  add_separator(window_menu)
281
- add_menu_item(window_menu, "Select Next Pane", 'selectNextPane:', ']')
282
- add_menu_item(window_menu, "Select Previous Pane", 'selectPreviousPane:', '[')
327
+ add_menu_item(window_menu, "Select Next Pane", 'selectNextPane:', ']', bind: :select_next_pane)
328
+ add_menu_item(window_menu, "Select Previous Pane", 'selectPreviousPane:', '[', bind: :select_previous_pane)
283
329
  add_separator(window_menu)
284
330
  add_menu_item(window_menu, "Toggle Copy Mode", 'toggleCopyMode:', 'c',
285
- modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)
331
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift,
332
+ bind: :toggle_copy_mode)
286
333
  add_separator(window_menu)
287
334
  # Register the menu as NSApplication's "windows menu"; AppKit
288
335
  # auto-populates it with one item per NSWindow (using the window's
@@ -292,18 +339,21 @@ module Echoes
292
339
 
293
340
  # Shell menu
294
341
  shell_menu = create_menu('Shell')
295
- add_menu_item(shell_menu, "New Window", 'newWindow:', 'n')
296
- add_menu_item(shell_menu, "New Tab", 'newTab:', 't')
297
- add_menu_item(shell_menu, "Close Tab", 'closeTab:', 'w')
342
+ add_menu_item(shell_menu, "New Window", 'newWindow:', 'n', bind: :new_window)
343
+ add_menu_item(shell_menu, "New Tab", 'newTab:', 't', bind: :new_tab)
344
+ add_menu_item(shell_menu, "Close Tab", 'closeTab:', 'w', bind: :close_tab)
298
345
  add_separator(shell_menu)
299
346
  add_menu_item(shell_menu, "Edit File…", 'editFile:', 'e',
300
- modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)
347
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift,
348
+ bind: :edit_file)
301
349
  add_separator(shell_menu)
302
- add_menu_item(shell_menu, "Split Right", 'splitRight:', 'd')
350
+ add_menu_item(shell_menu, "Split Right", 'splitRight:', 'd', bind: :split_right)
303
351
  add_menu_item(shell_menu, "Split Down", 'splitDown:', 'd',
304
- modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)
352
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift,
353
+ bind: :split_down)
305
354
  add_menu_item(shell_menu, "Close Pane", 'closePane:', 'w',
306
- modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)
355
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift,
356
+ bind: :close_pane)
307
357
  add_submenu(main_menu, shell_menu, 'Shell')
308
358
 
309
359
  ObjC::MSG_VOID_1.call(@app, ObjC.sel('setMainMenu:'), main_menu)
@@ -314,10 +364,20 @@ module Echoes
314
364
  ObjC::MSG_PTR_1.call(m, ObjC.sel('initWithTitle:'), ObjC.nsstring(title))
315
365
  end
316
366
 
317
- private def add_menu_item(menu, title, action, key, modifiers: ObjC::NSEventModifierFlagCommand)
367
+ private def add_menu_item(menu, title, selector, key, modifiers: ObjC::NSEventModifierFlagCommand, bind: nil)
368
+ # When `bind:` is given, allow `~/.config/echoes/echoes.conf`
369
+ # to override the default shortcut via `keybind "…", :sym`.
370
+ # The override fully replaces both the key and the modifier
371
+ # bits — an override of `""` (or `nil`) disables the
372
+ # shortcut entirely (menu item stays, no keyboard binding).
373
+ if bind && (over = Echoes.config.keybind_for(bind))
374
+ key = over[:key].to_s
375
+ modifiers = over[:modifiers]
376
+ end
377
+
318
378
  item = ObjC::MSG_PTR.call(ObjC.cls('NSMenuItem'), ObjC.sel('alloc'))
319
379
  item = ObjC::MSG_PTR_3.call(item, ObjC.sel('initWithTitle:action:keyEquivalent:'),
320
- ObjC.nsstring(title), action.empty? ? Fiddle::Pointer.new(0) : ObjC.sel(action), ObjC.nsstring(key))
380
+ ObjC.nsstring(title), selector.empty? ? Fiddle::Pointer.new(0) : ObjC.sel(selector), ObjC.nsstring(key))
321
381
  if modifiers != ObjC::NSEventModifierFlagCommand && !key.empty?
322
382
  ObjC::MSG_VOID_L.call(item, ObjC.sel('setKeyEquivalentModifierMask:'), modifiers)
323
383
  end
@@ -330,6 +390,40 @@ module Echoes
330
390
  ObjC::MSG_VOID_1.call(menu, ObjC.sel('addItem:'), sep)
331
391
  end
332
392
 
393
+ # Generate `applyProfile_<i>:` selector entries — one per
394
+ # declared profile — for the view-class selector dict. The
395
+ # closures already exist in @profile_closures keyed by name;
396
+ # this just gives them stable selector strings AppKit can
397
+ # dispatch by index. `profile_selector_for` returns the matching
398
+ # selector string for a given profile name so the menu builder
399
+ # can wire the right one to each menu item.
400
+ private def profile_selectors
401
+ out = {}
402
+ Echoes.config.all_profiles.each_key.with_index do |pname, i|
403
+ out[profile_selector_for(pname)] = ['v@:@', @profile_closures[pname]]
404
+ end
405
+ out
406
+ end
407
+
408
+ private def profile_selector_for(name)
409
+ i = Echoes.config.all_profiles.keys.index(name)
410
+ "applyProfile_#{i}:"
411
+ end
412
+
413
+ # Add a "Profile" submenu to `view_menu` listing the synthesized
414
+ # "Default" plus every user-declared profile. Always rendered so
415
+ # the feature is discoverable even with an empty config.
416
+ private def build_profiles_submenu(view_menu)
417
+ profiles = Echoes.config.all_profiles
418
+ return if profiles.empty?
419
+ add_separator(view_menu)
420
+ submenu = create_menu('Profile')
421
+ profiles.each_key do |pname|
422
+ add_menu_item(submenu, pname, profile_selector_for(pname), '')
423
+ end
424
+ add_submenu(view_menu, submenu, 'Profile')
425
+ end
426
+
333
427
  private def add_submenu(parent, submenu, title)
334
428
  item = ObjC::MSG_PTR.call(ObjC.cls('NSMenuItem'), ObjC.sel('alloc'))
335
429
  item = ObjC::MSG_PTR_3.call(item, ObjC.sel('initWithTitle:action:keyEquivalent:'),
@@ -618,6 +712,15 @@ module Echoes
618
712
  }
619
713
 
620
714
  @show_about_closure = menu_action.call(-> { show_about_panel })
715
+
716
+ # One closure per declared profile, indexed by name. The
717
+ # menu builder later wires each into its own
718
+ # `applyProfile_<n>:` selector so AppKit can deliver the
719
+ # right one without us having to dispatch by event payload.
720
+ @profile_closures = {}
721
+ Echoes.config.all_profiles.each_key do |pname|
722
+ @profile_closures[pname] = menu_action.call(-> { apply_profile(pname) })
723
+ end
621
724
  @new_window_closure = menu_action.call(-> { open_new_window })
622
725
  @new_tab_closure = menu_action.call(-> {
623
726
  create_tab
@@ -670,13 +773,13 @@ module Echoes
670
773
  @split_right_closure = menu_action.call(-> {
671
774
  tab = current_tab
672
775
  new_pane = tab.split_vertical
673
- wire_screen_handlers(new_pane.screen)
776
+ wire_screen_handlers(new_pane)
674
777
  ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
675
778
  })
676
779
  @split_down_closure = menu_action.call(-> {
677
780
  tab = current_tab
678
781
  new_pane = tab.split_horizontal
679
- wire_screen_handlers(new_pane.screen)
782
+ wire_screen_handlers(new_pane)
680
783
  ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
681
784
  })
682
785
  @close_pane_closure = menu_action.call(-> {
@@ -783,6 +886,7 @@ module Echoes
783
886
  'windowDidBecomeKey:' => ['v@:@', @focus_gained_closure],
784
887
  'windowDidResignKey:' => ['v@:@', @focus_lost_closure],
785
888
  'showAbout:' => ['v@:@', @show_about_closure],
889
+ **profile_selectors,
786
890
  'newWindow:' => ['v@:@', @new_window_closure],
787
891
  'newTab:' => ['v@:@', @new_tab_closure],
788
892
  'editFile:' => ['v@:@', @edit_file_closure],
@@ -896,6 +1000,7 @@ module Echoes
896
1000
  ObjC::NSRectFill.call(0.0, gy_off, @cols * @cell_width, @rows * @cell_height)
897
1001
  end
898
1002
 
1003
+
899
1004
  # Draw search bar
900
1005
  if @search_mode
901
1006
  bar_h = @cell_height + 4.0
@@ -905,7 +1010,11 @@ module Echoes
905
1010
  ObjC::NSRectFill.call(0.0, bar_y, @cols * @cell_width, bar_h)
906
1011
 
907
1012
  match_info = @search_matches.empty? ? "" : " [#{@search_index + 1}/#{@search_matches.size}]"
908
- label = "Find: #{@search_query}_#{match_info}"
1013
+ mode_flags = []
1014
+ mode_flags << 'regex' if @search_regex_mode
1015
+ mode_flags << 'i' if @search_case_insensitive
1016
+ mode_tag = mode_flags.empty? ? '' : " (#{mode_flags.join(', ')})"
1017
+ label = "Find#{mode_tag}: #{@search_query}_#{match_info}"
909
1018
  ns_str = ObjC.nsstring(label)
910
1019
  ns_attrs = ObjC.nsdict({
911
1020
  ObjC::NSFontAttributeName => @font,
@@ -942,9 +1051,54 @@ module Echoes
942
1051
  end
943
1052
  next unless row
944
1053
 
1054
+ # Cell-background fills snap their left/right (and
1055
+ # top/bottom) to integer pixel boundaries against the
1056
+ # *next* cell's left edge. When @cell_width is fractional
1057
+ # (most monospace fonts at most sizes), independent per-
1058
+ # cell rounding leaves sub-pixel AA seams between adjacent
1059
+ # fills, which visibly stripe solid-bg regions (selection
1060
+ # ranges, search matches, SF_DATALESS-file highlights from
1061
+ # `ls`, …). Snapping with the next-boundary trick
1062
+ # guarantees neighbors share an exact device-pixel edge,
1063
+ # which is what makes the seams disappear.
1064
+ snap_fill = lambda do |fx, fy, fw, fh|
1065
+ x0 = fx.round
1066
+ x1 = (fx + fw).round
1067
+ y0 = fy.round
1068
+ y1 = (fy + fh).round
1069
+ ObjC::NSRectFill.call(x0.to_f, y0.to_f, (x1 - x0).to_f, (y1 - y0).to_f)
1070
+ end
1071
+
1072
+ # Text-run accumulator. We batch consecutive cells with
1073
+ # matching style into one drawAtPoint call so the font
1074
+ # shaper sees adjacent characters and can apply ligatures
1075
+ # (`=>`, `!=`, `<=`, etc.) on fonts that have them.
1076
+ # Anything that can't extend the run (multicell, blank
1077
+ # non-bg cell, style change) flushes it first.
1078
+ run_chars = +''
1079
+ run_start_c = nil
1080
+ run_attrs = nil
1081
+ run_font = nil
1082
+ run_sig = nil
1083
+ flush_run = lambda do
1084
+ next if run_chars.empty?
1085
+ ns_run = ObjC.nsstring(run_chars)
1086
+ run_x = px + run_start_c * @cell_width
1087
+ run_dy = y + y_offset_for_font(run_font)
1088
+ ObjC::MSG_VOID_PT_1.call(ns_run, ObjC.sel('drawAtPoint:withAttributes:'),
1089
+ run_x, run_dy, run_attrs)
1090
+ run_chars = +''
1091
+ run_start_c = nil
1092
+ run_attrs = nil
1093
+ run_font = nil
1094
+ run_sig = nil
1095
+ end
1096
+
945
1097
  row.each_with_index do |cell, c|
946
- next if cell.width == 0
947
- next if cell.multicell == :cont
1098
+ if cell.width == 0 || cell.multicell == :cont
1099
+ flush_run.call
1100
+ next
1101
+ end
948
1102
 
949
1103
  fg_val = cell.fg
950
1104
  bg_val = cell.bg
@@ -987,6 +1141,7 @@ module Echoes
987
1141
  end
988
1142
 
989
1143
  if cell.multicell.is_a?(Hash)
1144
+ flush_run.call
990
1145
  mc = cell.multicell
991
1146
  x = px + c * @cell_width
992
1147
  block_w = mc[:cols] * @cell_width
@@ -994,10 +1149,10 @@ module Echoes
994
1149
 
995
1150
  if selected
996
1151
  ObjC::MSG_VOID.call(@selection_color, ObjC.sel('setFill'))
997
- ObjC::NSRectFill.call(x, y, block_w, block_h)
1152
+ snap_fill.call(x, y, block_w, block_h)
998
1153
  elsif has_bg
999
1154
  ObjC::MSG_VOID.call(bg_color, ObjC.sel('setFill'))
1000
- ObjC::NSRectFill.call(x, y, block_w, block_h)
1155
+ snap_fill.call(x, y, block_w, block_h)
1001
1156
  end
1002
1157
 
1003
1158
  if mc[:sixel]
@@ -1018,6 +1173,13 @@ module Echoes
1018
1173
  effective_scale *= mc[:frac_n].to_f / mc[:frac_d]
1019
1174
  end
1020
1175
  scaled_font = ObjC.retain(create_nsfont(@font_size * effective_scale, family: mc[:family]))
1176
+ # Capture the regular line height *before* possibly swapping
1177
+ # in the bold variant so we can re-align the bold baseline
1178
+ # below — bold fonts often report a larger
1179
+ # defaultLineHeightForFont and otherwise sit visually lower
1180
+ # than adjacent non-bold OSC 66 cells (same fix the cell-loop
1181
+ # path does via y_offset_for_font for same-family bold).
1182
+ regular_scaled_lh = ObjC::MSG_RET_D.call(scaled_font, ObjC.sel('defaultLineHeightForFont'))
1021
1183
  if cell.bold
1022
1184
  regular = scaled_font
1023
1185
  scaled_font = ObjC.retain(create_bold_nsfont(regular))
@@ -1056,7 +1218,37 @@ module Echoes
1056
1218
  else y
1057
1219
  end
1058
1220
 
1059
- ObjC::MSG_VOID_PT_1.call(ns_char, ObjC.sel('drawAtPoint:withAttributes:'), draw_x, draw_y, ns_attrs)
1221
+ # Baseline compensation: same-family bold variants
1222
+ # report a larger defaultLineHeightForFont; shifting
1223
+ # by LH-delta puts the bold baseline back on the same
1224
+ # row as the regular run beside it. (OSC 66 doesn't
1225
+ # mix unrelated families in a single run, so the
1226
+ # ascender-delta fork the cell-loop path needs doesn't
1227
+ # apply here.)
1228
+ drawn_lh = ObjC::MSG_RET_D.call(scaled_font, ObjC.sel('defaultLineHeightForFont'))
1229
+ draw_y += (regular_scaled_lh - drawn_lh)
1230
+
1231
+ if mc[:flip_h] || mc[:flip_v]
1232
+ # Mirror the glyph(s) around the multicell block's
1233
+ # center. Negative-scale the CTM along the requested
1234
+ # axis and translate twice so the flip pivots on the
1235
+ # block midpoint rather than the origin — keeps the
1236
+ # glyph(s) inside the reserved cell rect.
1237
+ ns_ctx = ObjC::MSG_PTR.call(ObjC.cls('NSGraphicsContext'), ObjC.sel('currentContext'))
1238
+ cg_ctx = ObjC::MSG_PTR.call(ns_ctx, ObjC.sel('CGContext'))
1239
+ cx = x + block_w / 2.0
1240
+ cy = y + block_h / 2.0
1241
+ sx = mc[:flip_h] ? -1.0 : 1.0
1242
+ sy = mc[:flip_v] ? -1.0 : 1.0
1243
+ ObjC::CGContextSaveGState.call(cg_ctx)
1244
+ ObjC::CGContextTranslateCTM.call(cg_ctx, cx, cy)
1245
+ ObjC::CGContextScaleCTM.call(cg_ctx, sx, sy)
1246
+ ObjC::CGContextTranslateCTM.call(cg_ctx, -cx, -cy)
1247
+ ObjC::MSG_VOID_PT_1.call(ns_char, ObjC.sel('drawAtPoint:withAttributes:'), draw_x, draw_y, ns_attrs)
1248
+ ObjC::CGContextRestoreGState.call(cg_ctx)
1249
+ else
1250
+ ObjC::MSG_VOID_PT_1.call(ns_char, ObjC.sel('drawAtPoint:withAttributes:'), draw_x, draw_y, ns_attrs)
1251
+ end
1060
1252
  ObjC.release(scaled_font)
1061
1253
  else
1062
1254
  x = px + c * @cell_width
@@ -1064,19 +1256,22 @@ module Echoes
1064
1256
 
1065
1257
  if is_current_match
1066
1258
  ObjC::MSG_VOID.call(@search_current_color, ObjC.sel('setFill'))
1067
- ObjC::NSRectFill.call(x, y, cell_w, @cell_height)
1259
+ snap_fill.call(x, y, cell_w, @cell_height)
1068
1260
  elsif is_match
1069
1261
  ObjC::MSG_VOID.call(@search_match_color, ObjC.sel('setFill'))
1070
- ObjC::NSRectFill.call(x, y, cell_w, @cell_height)
1262
+ snap_fill.call(x, y, cell_w, @cell_height)
1071
1263
  elsif selected
1072
1264
  ObjC::MSG_VOID.call(@selection_color, ObjC.sel('setFill'))
1073
- ObjC::NSRectFill.call(x, y, cell_w, @cell_height)
1265
+ snap_fill.call(x, y, cell_w, @cell_height)
1074
1266
  elsif has_bg
1075
1267
  ObjC::MSG_VOID.call(bg_color, ObjC.sel('setFill'))
1076
- ObjC::NSRectFill.call(x, y, cell_w, @cell_height)
1268
+ snap_fill.call(x, y, cell_w, @cell_height)
1077
1269
  end
1078
1270
 
1079
- next if cell.char == " " && !has_bg && !selected && !is_match
1271
+ if cell.char == " " && !has_bg && !selected && !is_match
1272
+ flush_run.call
1273
+ next
1274
+ end
1080
1275
 
1081
1276
  base_font = cell.bold ? @bold_font : font_for_char(cell.char)
1082
1277
  if cell.italic
@@ -1087,22 +1282,40 @@ module Echoes
1087
1282
  elsif cell.faint
1088
1283
  fg_color = make_color_with_alpha(fg_color, 0.5)
1089
1284
  end
1090
- attrs = {
1091
- ObjC::NSFontAttributeName => base_font,
1092
- ObjC::NSForegroundColorAttributeName => fg_color,
1093
- }
1094
- if cell.underline
1095
- attrs[ObjC::NSUnderlineStyleAttributeName] = ObjC.nsnumber_int(1)
1096
- end
1097
- if cell.strikethrough
1098
- attrs[ObjC::NSStrikethroughStyleAttributeName] = ObjC.nsnumber_int(1)
1285
+
1286
+ sig = [base_font.to_i, fg_color.to_i, cell.underline, cell.strikethrough]
1287
+ if run_sig != sig
1288
+ flush_run.call
1289
+ run_sig = sig
1290
+ run_start_c = c
1291
+ run_font = base_font
1292
+ attrs = {
1293
+ ObjC::NSFontAttributeName => base_font,
1294
+ ObjC::NSForegroundColorAttributeName => fg_color,
1295
+ # Ligature value `2` requests all discretionary
1296
+ # ligatures (`=>`, `!=`, `<=`, `>=`, etc.) on
1297
+ # fonts like Fira Code that ship them; default
1298
+ # `0` only does fi/fl-style essential ones.
1299
+ ObjC::NSLigatureAttributeName => ObjC.nsnumber_int(2),
1300
+ }
1301
+ attrs[ObjC::NSUnderlineStyleAttributeName] = ObjC.nsnumber_int(1) if cell.underline
1302
+ attrs[ObjC::NSStrikethroughStyleAttributeName] = ObjC.nsnumber_int(1) if cell.strikethrough
1303
+ run_attrs = ObjC.nsdict(attrs)
1099
1304
  end
1100
- ns_attrs = ObjC.nsdict(attrs)
1101
- ns_char = cached_nsstring(cell.char)
1102
- dy = y + y_offset_for_font(base_font)
1103
- ObjC::MSG_VOID_PT_1.call(ns_char, ObjC.sel('drawAtPoint:withAttributes:'), x, dy, ns_attrs)
1305
+ run_chars << cell.char
1104
1306
  end
1105
1307
  end
1308
+ flush_run.call
1309
+ end
1310
+
1311
+ # Re-blit kitty graphics placements ON TOP of the rendered
1312
+ # cells. Anchors are stored as logical cell coords on the
1313
+ # Screen; we convert to pixels here using current cell
1314
+ # metrics so font-size and pane-resize changes pick up the
1315
+ # right pixel position automatically — placements need no
1316
+ # bookkeeping when @cell_width / @cell_height shift.
1317
+ screen.placements.each do |pl|
1318
+ blit_kitty_placement(pl, px, py, pane_rows)
1106
1319
  end
1107
1320
 
1108
1321
  # Draw cursor or copy mode cursor
@@ -1124,7 +1337,7 @@ module Echoes
1124
1337
  if @window_focused
1125
1338
  # Active window: filled cursor (blinking if requested)
1126
1339
  if !blink || (is_active ? @cursor_blink_on : true)
1127
- cursor_color = is_active ? make_color(*Echoes.config.cursor_color) : make_color(0.5, 0.5, 0.5, 0.3)
1340
+ cursor_color = is_active ? make_color(*@active_profile.cursor_color) : make_color(0.5, 0.5, 0.5, 0.3)
1128
1341
  ObjC::MSG_VOID.call(cursor_color, ObjC.sel('setFill'))
1129
1342
  case style
1130
1343
  when 3, 4 # underline
@@ -1152,7 +1365,7 @@ module Echoes
1152
1365
  end
1153
1366
  else
1154
1367
  # Inactive window: hollow square outline (no blinking)
1155
- ObjC::MSG_VOID.call(make_color(*Echoes.config.cursor_color), ObjC.sel('setFill'))
1368
+ ObjC::MSG_VOID.call(make_color(*@active_profile.cursor_color), ObjC.sel('setFill'))
1156
1369
  ObjC::NSRectFill.call(cx, cy, @cell_width, 1.0) # top
1157
1370
  ObjC::NSRectFill.call(cx, cy + @cell_height - 1.0, @cell_width, 1.0) # bottom
1158
1371
  ObjC::NSRectFill.call(cx, cy, 1.0, @cell_height) # left
@@ -1247,6 +1460,7 @@ module Echoes
1247
1460
 
1248
1461
  @selection_anchor = nil
1249
1462
  @selection_end = nil
1463
+ @selection_word_anchor = nil
1250
1464
 
1251
1465
  flags = ObjC::MSG_RET_L.call(event_ptr, ObjC.sel('modifierFlags'))
1252
1466
  chars_ns = ObjC::MSG_PTR.call(event_ptr, ObjC.sel('charactersIgnoringModifiers'))
@@ -1481,6 +1695,18 @@ module Echoes
1481
1695
 
1482
1696
  full_redraw = @bell_flash > 0 || blink_toggled
1483
1697
 
1698
+ # DEC private mode 2026 (synchronized output): when a TUI has
1699
+ # opened a sync window with `\e[?2026h`, hold the redraw — even
1700
+ # though we've already mutated the cell grid — until the
1701
+ # matching `\e[?2026l` arrives. This makes vim/bat/helix bulk
1702
+ # repaints land as a single visual frame instead of tearing
1703
+ # mid-batch. Dirty rows accumulate across sync ticks; when sync
1704
+ # ends, the next tick paints them all at once.
1705
+ if tab.panes.any? { |p| p.screen.sync_active }
1706
+ save_window_state
1707
+ return
1708
+ end
1709
+
1484
1710
  if need_redraw
1485
1711
  ObjC::MSG_VOID_1.call(@window, ObjC.sel('setTitle:'), ObjC.nsstring(tab.title))
1486
1712
 
@@ -1587,8 +1813,12 @@ module Echoes
1587
1813
  @selection_anchor = [abs_row, 0]
1588
1814
  @selection_end = [abs_row, @cols - 1]
1589
1815
  end
1816
+ @selection_word_anchor = nil
1590
1817
  elsif click_count == 2
1591
- # Double-click: select word
1818
+ # Double-click: select word, and remember the word's bounds so
1819
+ # a subsequent drag extends from those bounds (keeping the
1820
+ # double-clicked word fully selected) instead of collapsing
1821
+ # to character-level from the click point.
1592
1822
  abs_row, col = pos
1593
1823
  row_data = row_at(tab, abs_row)
1594
1824
  if row_data
@@ -1596,12 +1826,14 @@ module Echoes
1596
1826
  if bounds
1597
1827
  @selection_anchor = [abs_row, bounds[0]]
1598
1828
  @selection_end = [abs_row, bounds[1]]
1829
+ @selection_word_anchor = [abs_row, bounds[0], bounds[1]]
1599
1830
  end
1600
1831
  end
1601
1832
  else
1602
1833
  # Single click: start drag selection
1603
1834
  @selection_anchor = pos
1604
1835
  @selection_end = nil
1836
+ @selection_word_anchor = nil
1605
1837
  end
1606
1838
 
1607
1839
  ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
@@ -1616,12 +1848,46 @@ module Echoes
1616
1848
  if tab.screen.mouse_tracking == :button_event || tab.screen.mouse_tracking == :any_event
1617
1849
  row, col = pos
1618
1850
  send_mouse_event(tab, 32, col, row) # 32 = left drag (button 0 + 32)
1851
+ elsif @selection_word_anchor
1852
+ extend_word_drag_selection(tab, pos)
1619
1853
  else
1620
1854
  @selection_end = pos
1621
1855
  end
1622
1856
  ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
1623
1857
  end
1624
1858
 
1859
+ # When dragging after a double-click, snap each end of the
1860
+ # selection to whole-word boundaries — and never let it shrink
1861
+ # below the originally double-clicked word. Selection start is
1862
+ # min((anchor_word_start), (pointer's word_start)); end is
1863
+ # max((anchor_word_end), (pointer's word_end)). If the pointer
1864
+ # is sitting on whitespace the "word" at the pointer is just
1865
+ # that single cell, so the leading edge extends one char at a
1866
+ # time across gaps.
1867
+ def extend_word_drag_selection(tab, pointer_pos)
1868
+ a_row, a_start, a_end = @selection_word_anchor
1869
+ p_row, p_col = pointer_pos
1870
+ p_row_data = row_at(tab, p_row)
1871
+ p_bounds = p_row_data && word_boundaries_in_row(p_row_data, p_col)
1872
+ p_start = p_bounds ? p_bounds[0] : p_col
1873
+ p_end = p_bounds ? p_bounds[1] : p_col
1874
+
1875
+ sel_start =
1876
+ if p_row < a_row || (p_row == a_row && p_start < a_start)
1877
+ [p_row, p_start]
1878
+ else
1879
+ [a_row, a_start]
1880
+ end
1881
+ sel_end =
1882
+ if p_row > a_row || (p_row == a_row && p_end > a_end)
1883
+ [p_row, p_end]
1884
+ else
1885
+ [a_row, a_end]
1886
+ end
1887
+ @selection_anchor = sel_start
1888
+ @selection_end = sel_end
1889
+ end
1890
+
1625
1891
  def mouse_up(event_ptr)
1626
1892
  tab = current_tab
1627
1893
  return unless tab
@@ -1746,6 +2012,17 @@ module Echoes
1746
2012
  @tabs.each { |tab| tab.resize(@rows, @cols) }
1747
2013
  end
1748
2014
 
2015
+ # Re-run handle_resize against the current view frame. Toggling
2016
+ # tab_bar_height (when @tabs.size crosses 1↔2) changes the grid
2017
+ # area inside an unchanged window, but AppKit's setFrameSize:
2018
+ # hook only fires on real frame changes — so call it ourselves
2019
+ # so @rows reflows to the new available height.
2020
+ def reflow_to_current_view_size
2021
+ return unless @view && @cell_width && @cell_height
2022
+ _, _, w, h = nsrect_via_invocation(@view, 'frame')
2023
+ handle_resize(w, h)
2024
+ end
2025
+
1749
2026
  def window_focus_changed(focused)
1750
2027
  @window_focused = focused
1751
2028
  save_window_state
@@ -1759,6 +2036,34 @@ module Echoes
1759
2036
  pane.write_input(seq)
1760
2037
  end
1761
2038
 
2039
+ # Switch the active profile (color theme) by name. Releases the
2040
+ # current NSColor cache, rebuilds default fg/bg/selection and
2041
+ # the 256-color palette from the new profile, and triggers a
2042
+ # full repaint so every existing cell picks up the new colors.
2043
+ # Per-pane gradient overlays (OSC 7772 bg-* commands) are left
2044
+ # alone — those are user-driven decoration, not theme.
2045
+ def apply_profile(name)
2046
+ profile = Echoes.config.all_profiles[name.to_s]
2047
+ return unless profile
2048
+ @active_profile = profile
2049
+
2050
+ ObjC.release(@default_fg) if @default_fg
2051
+ ObjC.release(@default_bg) if @default_bg
2052
+ ObjC.release(@selection_color) if @selection_color
2053
+ @colors&.each_value { |c| ObjC.release(c) }
2054
+
2055
+ @colors = build_color_table
2056
+ @default_fg = make_color(*@active_profile.foreground)
2057
+ @default_bg = make_color(*@active_profile.background)
2058
+ @selection_color = make_color(*@active_profile.selection_color)
2059
+ @rgb_color_cache = {} # truecolor cache stale after palette swap
2060
+
2061
+ @window_states.each do |ws|
2062
+ ws[:tabs].each { |tab| tab.panes.each { |p| p.screen.mark_all_dirty } } if ws[:tabs]
2063
+ ObjC::MSG_VOID_I.call(ws[:nsview], ObjC.sel('setNeedsDisplay:'), 1) if ws[:nsview]
2064
+ end
2065
+ end
2066
+
1762
2067
  def update_font(new_size, persist: true)
1763
2068
  @font_size = new_size
1764
2069
  Preferences.set_double(:font_size, new_size) if persist
@@ -1771,6 +2076,8 @@ module Echoes
1771
2076
  @font_cache.each_value { |f| ObjC.release(f) unless f.to_i == old_font&.to_i }
1772
2077
  @font_cache = {}
1773
2078
  @font_y_offset_cache = {}
2079
+ @italic_font_cache&.each_value { |f| ObjC.release(f) }
2080
+ @italic_font_cache = {}
1774
2081
  update_cell_metrics
1775
2082
 
1776
2083
  @window_states.each do |ws|
@@ -1826,7 +2133,7 @@ module Echoes
1826
2133
  # Create tab
1827
2134
  tab = Tab.new(command: @command, rows: @rows, cols: @cols, embedded: embedded_mode?)
1828
2135
  tab.title = "Shell"
1829
- tab.panes.each { |pane| wire_screen_handlers(pane.screen) }
2136
+ tab.panes.each { |pane| wire_screen_handlers(pane) }
1830
2137
 
1831
2138
  # Build window and view in locals — DO NOT touch @window / @view yet.
1832
2139
  # makeKeyAndOrderFront: below fires NSWindowDidResignKeyNotification on
@@ -1893,6 +2200,8 @@ module Echoes
1893
2200
  @search_query = +""
1894
2201
  @search_matches = []
1895
2202
  @search_index = -1
2203
+ @search_regex_mode = false
2204
+ @search_case_insensitive = false
1896
2205
  @bell_flash = 0
1897
2206
  @marked_text = nil
1898
2207
  @current_event = nil
@@ -1905,6 +2214,15 @@ module Echoes
1905
2214
  @window_states << ws
1906
2215
  @view_to_ws[@view.to_i] = ws
1907
2216
  save_window_state
2217
+
2218
+ # setFrameAutosaveName: above may have restored a window
2219
+ # frame larger than the config-sized initial frame; our
2220
+ # setFrameSize: hook ran handle_resize at that moment, which
2221
+ # updated @rows/@cols but no-op'd the tab loop because @tabs
2222
+ # was still empty. Sync the just-created tab to the current
2223
+ # window dimensions now that it's in @tabs. Idempotent when
2224
+ # the saved frame matches the config (or doesn't exist).
2225
+ @tabs.each { |tab| tab.resize(@rows, @cols) }
1908
2226
  end
1909
2227
 
1910
2228
  def select_all
@@ -2108,6 +2426,48 @@ module Echoes
2108
2426
  rescue Errno::EIO, IOError
2109
2427
  end
2110
2428
 
2429
+ # Re-blit one Screen#placements entry. Pixel coords are
2430
+ # recomputed every frame from anchor row/col × current cell
2431
+ # metrics, so changing the font or resizing the pane needs no
2432
+ # placement edit — the next draw picks up the new coords. The
2433
+ # decoded CGImage is cached on the shared image hash so
2434
+ # repeated frames (60Hz tick, dirty-rect refreshes) don't
2435
+ # rebuild the bitmap context.
2436
+ def blit_kitty_placement(pl, px, py, pane_rows)
2437
+ img = pl[:image]
2438
+ return unless img && img[:rgba] && img[:width].to_i > 0 && img[:height].to_i > 0
2439
+ return if pl[:anchor_row] + pl[:cell_rows] <= 0 # fully scrolled off above
2440
+ return if pl[:anchor_row] >= pane_rows # below visible
2441
+
2442
+ unless img[:cg_image]
2443
+ rgba_ptr = Fiddle::Pointer.to_ptr(img[:rgba])
2444
+ cs = ObjC::CGColorSpaceCreateDeviceRGB.call
2445
+ ctx = ObjC::CGBitmapContextCreate.call(
2446
+ rgba_ptr, img[:width], img[:height], 8, img[:width] * 4, cs,
2447
+ ObjC::KCGImageAlphaPremultipliedLast
2448
+ )
2449
+ img[:cg_image] = ObjC::CGBitmapContextCreateImage.call(ctx)
2450
+ ObjC::CGContextRelease.call(ctx)
2451
+ ObjC::CGColorSpaceRelease.call(cs)
2452
+ end
2453
+ cg_image = img[:cg_image]
2454
+ return if cg_image.null?
2455
+
2456
+ x = px + pl[:anchor_col] * @cell_width + pl[:x_off].to_i
2457
+ y = py + pl[:anchor_row] * @cell_height + pl[:y_off].to_i
2458
+ draw_w = pl[:cell_cols] * @cell_width
2459
+ draw_h = pl[:cell_rows] * @cell_height
2460
+
2461
+ ns_ctx = ObjC::MSG_PTR.call(ObjC.cls('NSGraphicsContext'), ObjC.sel('currentContext'))
2462
+ cg_ctx = ObjC::MSG_PTR.call(ns_ctx, ObjC.sel('CGContext'))
2463
+
2464
+ ObjC::CGContextSaveGState.call(cg_ctx)
2465
+ ObjC::CGContextTranslateCTM.call(cg_ctx, x, y + draw_h)
2466
+ ObjC::CGContextScaleCTM.call(cg_ctx, 1.0, -1.0)
2467
+ ObjC::CGContextDrawImage.call(cg_ctx, 0.0, 0.0, draw_w, draw_h, cg_image)
2468
+ ObjC::CGContextRestoreGState.call(cg_ctx)
2469
+ end
2470
+
2111
2471
  def draw_sixel_image(sixel, x, y, draw_w, draw_h)
2112
2472
  # Cache CGImage on first render
2113
2473
  unless sixel[:cg_image]
@@ -2133,9 +2493,15 @@ module Echoes
2133
2493
  ns_ctx = ObjC::MSG_PTR.call(ObjC.cls('NSGraphicsContext'), ObjC.sel('currentContext'))
2134
2494
  cg_ctx = ObjC::MSG_PTR.call(ns_ctx, ObjC.sel('CGContext'))
2135
2495
 
2496
+ # Sub-cell pixel offsets (kitty X= / Y=). Both default to 0
2497
+ # so legacy callers pay nothing; non-zero values shift the
2498
+ # image within its anchor cell for fine alignment.
2499
+ ox = sixel[:px_x_offset].to_i
2500
+ oy = sixel[:px_y_offset].to_i
2501
+
2136
2502
  # Draw with flipping (view is flipped, but CGContext draws bottom-up)
2137
2503
  ObjC::CGContextSaveGState.call(cg_ctx)
2138
- ObjC::CGContextTranslateCTM.call(cg_ctx, x, y + draw_h)
2504
+ ObjC::CGContextTranslateCTM.call(cg_ctx, x + ox, y + oy + draw_h)
2139
2505
  ObjC::CGContextScaleCTM.call(cg_ctx, 1.0, -1.0)
2140
2506
  ObjC::CGContextDrawImage.call(cg_ctx, 0.0, 0.0, draw_w, draw_h, cg_image)
2141
2507
  ObjC::CGContextRestoreGState.call(cg_ctx)
@@ -2149,31 +2515,34 @@ module Echoes
2149
2515
  ObjC::MSG_VOID.call(@tab_bg, ObjC.sel('setFill'))
2150
2516
  ObjC::NSRectFill.call(0.0, ty, total_w + @cell_width, tbh)
2151
2517
 
2152
- @tabs.each_with_index do |tab, i|
2153
- x = i * tab_w
2518
+ # Vertically center titles in the bar.
2519
+ title_y = ty + (tbh - @font_default_line_height) / 2.0
2154
2520
 
2155
- # Active tab highlight
2156
- if i == @active_tab
2157
- ObjC::MSG_VOID.call(@tab_active_bg, ObjC.sel('setFill'))
2158
- ObjC::NSRectFill.call(x, ty, tab_w, tbh)
2159
- end
2521
+ accent_color = make_color(*@active_profile.cursor_color)
2522
+ accent_h = 2.0
2523
+ accent_inset = @cell_width * 0.5
2524
+
2525
+ @tabs.each_with_index do |tab, i|
2526
+ x = i * tab_w
2527
+ is_active = (i == @active_tab)
2160
2528
 
2161
- # Tab title
2162
2529
  label = tab.title
2163
- label = "#{label} " if label.length < 12
2164
2530
  ns_label = ObjC.nsstring(label)
2165
2531
  ns_attrs = ObjC.nsdict({
2166
2532
  ObjC::NSFontAttributeName => @font,
2167
- ObjC::NSForegroundColorAttributeName => @tab_fg,
2533
+ ObjC::NSForegroundColorAttributeName => is_active ? @tab_fg_active : @tab_fg_inactive,
2168
2534
  })
2169
2535
  text_x = x + @cell_width * 0.5
2170
- ObjC::MSG_VOID_PT_1.call(ns_label, ObjC.sel('drawAtPoint:withAttributes:'), text_x, ty, ns_attrs)
2171
-
2172
- # Separator line between tabs
2173
- if i < @tabs.size - 1
2174
- sep_color = make_color(0.4, 0.4, 0.4)
2175
- ObjC::MSG_VOID.call(sep_color, ObjC.sel('setFill'))
2176
- ObjC::NSRectFill.call(x + tab_w - 0.5, ty + 2.0, 1.0, tbh - 4.0)
2536
+ ObjC::MSG_VOID_PT_1.call(ns_label, ObjC.sel('drawAtPoint:withAttributes:'), text_x, title_y, ns_attrs)
2537
+
2538
+ # Thin accent strip flush to the bottom of the bar marks
2539
+ # the active tab — replaces the previous heavier full-cell
2540
+ # background fill. Inset on each side so it reads as the
2541
+ # tab's own marker rather than a continuous line.
2542
+ if is_active
2543
+ ObjC::MSG_VOID.call(accent_color, ObjC.sel('setFill'))
2544
+ ObjC::NSRectFill.call(x + accent_inset, ty + tbh - accent_h,
2545
+ tab_w - 2 * accent_inset, accent_h)
2177
2546
  end
2178
2547
  end
2179
2548
  end
@@ -2222,6 +2591,8 @@ module Echoes
2222
2591
  key_code = ObjC::MSG_RET_L.call(event_ptr, ObjC.sel('keyCode'))
2223
2592
  flags = ObjC::MSG_RET_L.call(event_ptr, ObjC.sel('modifierFlags'))
2224
2593
 
2594
+ cmd_held = (flags & ObjC::NSEventModifierFlagCommand) != 0
2595
+
2225
2596
  case key_code
2226
2597
  when 53 # Escape
2227
2598
  @search_mode = false
@@ -2236,7 +2607,19 @@ module Echoes
2236
2607
  @search_query.chop!
2237
2608
  perform_search
2238
2609
  else
2239
- unless chars.empty? || chars[0].ord < 0x20
2610
+ # Cmd+R toggles regex; Cmd+I toggles case-insensitive.
2611
+ # Re-run the search live so the user sees results update
2612
+ # without having to retype.
2613
+ if cmd_held && chars.length == 1
2614
+ case chars.downcase
2615
+ when 'r'
2616
+ @search_regex_mode = !@search_regex_mode
2617
+ perform_search
2618
+ when 'i'
2619
+ @search_case_insensitive = !@search_case_insensitive
2620
+ perform_search
2621
+ end
2622
+ elsif !chars.empty? && chars[0].ord >= 0x20
2240
2623
  @search_query << chars
2241
2624
  perform_search
2242
2625
  end
@@ -2253,31 +2636,72 @@ module Echoes
2253
2636
  screen = tab.screen
2254
2637
  scrollback = screen.scrollback
2255
2638
 
2639
+ matcher = build_search_matcher(@search_query)
2640
+ return unless matcher # invalid regex → no matches, no crash
2641
+
2256
2642
  # Search scrollback
2257
2643
  scrollback.each_with_index do |row, abs_row|
2258
- text = row.map(&:char).join
2259
- pos = 0
2260
- while (idx = text.index(@search_query, pos))
2261
- @search_matches << [abs_row, idx, @search_query.length]
2262
- pos = idx + 1
2263
- end
2644
+ scan_row_for_matches(row, abs_row, matcher)
2264
2645
  end
2265
2646
 
2266
2647
  # Search grid
2267
2648
  screen.grid.each_with_index do |row, grid_row|
2268
2649
  abs_row = scrollback.size + grid_row
2269
- text = row.map(&:char).join
2270
- pos = 0
2271
- while (idx = text.index(@search_query, pos))
2272
- @search_matches << [abs_row, idx, @search_query.length]
2273
- pos = idx + 1
2274
- end
2650
+ scan_row_for_matches(row, abs_row, matcher)
2275
2651
  end
2276
2652
 
2277
2653
  @search_index = @search_matches.size - 1 if @search_matches.any?
2278
2654
  scroll_to_match if @search_index >= 0
2279
2655
  end
2280
2656
 
2657
+ # Returns an object that responds to `find_in(text, pos)` →
2658
+ # `[start_idx, length]` or nil. Encapsulates the two axes —
2659
+ # regex vs substring × case-sensitive vs case-insensitive —
2660
+ # so the row scan stays loop-shaped regardless of mode.
2661
+ # Returns nil if `query` is an invalid regex while in regex mode.
2662
+ def build_search_matcher(query)
2663
+ if @search_regex_mode
2664
+ flags = @search_case_insensitive ? Regexp::IGNORECASE : 0
2665
+ re = Regexp.new(query, flags) rescue nil
2666
+ return nil unless re
2667
+ ->(text, pos) {
2668
+ m = re.match(text, pos)
2669
+ m && [m.begin(0), m.end(0) - m.begin(0)]
2670
+ }
2671
+ elsif @search_case_insensitive
2672
+ needle = query.downcase
2673
+ len = needle.length
2674
+ ->(text, pos) {
2675
+ idx = text.downcase.index(needle, pos)
2676
+ idx && [idx, len]
2677
+ }
2678
+ else
2679
+ len = query.length
2680
+ ->(text, pos) {
2681
+ idx = text.index(query, pos)
2682
+ idx && [idx, len]
2683
+ }
2684
+ end
2685
+ end
2686
+
2687
+ def scan_row_for_matches(row, abs_row, matcher)
2688
+ text = row.map(&:char).join
2689
+ pos = 0
2690
+ while pos <= text.length && (hit = matcher.call(text, pos))
2691
+ idx, len = hit
2692
+ # Ruby's `Regexp#match(s, pos)` past `s.length` still
2693
+ # returns the trailing zero-width match, so any
2694
+ # zero-width regex (`\b`, `(?=…)`, `^`, `$`) would
2695
+ # spin forever without these two guards: bail if the
2696
+ # match doesn't start at or after `pos`, and forcibly
2697
+ # advance by 1 cell when the match is zero-width.
2698
+ break if idx < pos
2699
+ step = [len, 1].max
2700
+ @search_matches << [abs_row, idx, step]
2701
+ pos = idx + step
2702
+ end
2703
+ end
2704
+
2281
2705
  def search_next
2282
2706
  return if @search_matches.empty?
2283
2707
  @search_index = (@search_index + 1) % @search_matches.size
@@ -2442,8 +2866,20 @@ module Echoes
2442
2866
  end
2443
2867
 
2444
2868
  def create_italic_nsfont(font)
2869
+ # NSFontManager's convertFont:toHaveTrait: returns an
2870
+ # autoreleased font whose pointer can shift call-to-call.
2871
+ # Without this cache the same italic cell churns through a
2872
+ # new pointer every frame, which busts y_offset_for_font's
2873
+ # (pointer-keyed) cache, breaks the run-merging signature,
2874
+ # and re-asks defaultLineHeightForFont — which on some
2875
+ # fonts returns a subtly different float each time and
2876
+ # makes italics jitter vertically in neovim et al.
2877
+ @italic_font_cache ||= {}
2878
+ cached = @italic_font_cache[font.to_i]
2879
+ return cached if cached
2445
2880
  fm = ObjC::MSG_PTR.call(ObjC.cls('NSFontManager'), ObjC.sel('sharedFontManager'))
2446
- ObjC::MSG_PTR_1L.call(fm, ObjC.sel('convertFont:toHaveTrait:'), font, 0x1) # NSItalicFontMask
2881
+ italic = ObjC::MSG_PTR_1L.call(fm, ObjC.sel('convertFont:toHaveTrait:'), font, 0x1) # NSItalicFontMask
2882
+ @italic_font_cache[font.to_i] = ObjC.retain(italic)
2447
2883
  end
2448
2884
 
2449
2885
  # Used by Screen#put_multicell when an OSC 66 cell carries a
@@ -2465,15 +2901,408 @@ module Echoes
2465
2901
  end
2466
2902
 
2467
2903
  # Single point that wires every host-callback a Screen needs
2468
- # (clipboard, palette, glyph measurement, cell metrics). Called
2469
- # everywhere a new Screen comes into existence — initial setup,
2470
- # create_tab, split_horizontal/vertical, and the post-config
2471
- # update path.
2472
- def wire_screen_handlers(screen)
2904
+ # (clipboard, palette, glyph measurement, cell metrics, OSC 7772
2905
+ # capture). Called everywhere a new Screen comes into existence —
2906
+ # initial setup, create_tab, split_horizontal/vertical, and the
2907
+ # post-config update path.
2908
+ def wire_screen_handlers(pane)
2909
+ screen = pane.screen
2473
2910
  screen.clipboard_handler = method(:handle_clipboard)
2474
2911
  screen.glyph_measurer = method(:measure_glyph)
2475
2912
  screen.cell_pixel_width = @cell_width if @cell_width
2476
2913
  screen.cell_pixel_height = @cell_height if @cell_height
2914
+ # Now that the Screen knows its real cell metrics, re-send
2915
+ # winsize so the pty's TIOCGWINSZ pixel fields carry the
2916
+ # measured dims — kitten icat and similar tools read those
2917
+ # via ioctl before falling back to CSI 14 t.
2918
+ pane.refresh_pty_pixel_size
2919
+ # Capture closure remembers which pane the OSC arrived for so
2920
+ # the renderer grabs the right sub-rect (split layouts have
2921
+ # several panes per view).
2922
+ screen.capture_handler = ->(path) { capture_pane_to_png(pane, path) }
2923
+ screen.notification_handler = ->(title, message) { post_notification(pane, title, message) }
2924
+ screen.display_info_handler = -> { display_info_json(pane) }
2925
+ screen.open_window_handler = ->(args) { open_window_from_osc(pane, args) }
2926
+ end
2927
+
2928
+ # Post a macOS notification for an in-pane OSC 9 / OSC 777
2929
+ # request. Uses `osascript -e 'display notification …'` — the
2930
+ # one notification path that works without a properly-bundled
2931
+ # native launcher.
2932
+ #
2933
+ # macOS gates notifications by the calling app's bundle. UN
2934
+ # (UNUserNotificationCenter, the modern API) requires
2935
+ # `bundleProxyForCurrentProcess` to resolve, which only happens
2936
+ # when the process's `_NSGetExecutablePath` points inside a
2937
+ # `.app` bundle. Echoes' launcher is a bash script that `exec`s
2938
+ # into `ruby`, so the running process's main bundle is the
2939
+ # ruby install — UN throws on startup. NSUserNotification has
2940
+ # the same problem and is silently dropped on macOS 14+.
2941
+ #
2942
+ # osascript runs the AppleScript primitive `display notification`
2943
+ # which posts through the Script Editor host bundle. **If banners
2944
+ # don't appear, open System Settings → Notifications → Script
2945
+ # Editor and enable "Banners" or "Alerts"** — the notification
2946
+ # is being filed in Notification Center either way, but macOS's
2947
+ # display gating is per-host-bundle and Script Editor defaults
2948
+ # to "no banner alert". A proper fix is to ship Echoes with a
2949
+ # compiled native launcher so the .app's bundle context is
2950
+ # preserved across the exec into ruby; tracked as a follow-up.
2951
+ # Post a macOS notification for an in-pane OSC 9 / OSC 777
2952
+ # request. Prefers `terminal-notifier` when it's on $PATH —
2953
+ # it ships as its own .app with its own bundle ID, so the
2954
+ # notification registers under "terminal-notifier" in System
2955
+ # Settings → Notifications, where the user can toggle banner
2956
+ # permission and have it stick.
2957
+ #
2958
+ # Falls back to `osascript display notification` if
2959
+ # terminal-notifier isn't installed. The osascript path goes
2960
+ # through Script Editor's notification slot; whether a banner
2961
+ # actually shows depends on Script Editor's "Alert style"
2962
+ # setting in System Settings → Notifications (which is "None"
2963
+ # by default).
2964
+ #
2965
+ # The real fix for Echoes-attributed notifications needs a
2966
+ # compiled native launcher in Contents/MacOS/ so
2967
+ # NSBundle.mainBundle survives the exec into ruby; until then
2968
+ # `brew install terminal-notifier` is the recommended setup.
2969
+ def post_notification(pane, title, message)
2970
+ effective_title = (title && !title.empty? && title) || pane&.title || 'Echoes'
2971
+ if (tn = terminal_notifier_path)
2972
+ pid = Process.spawn(tn, '-title', effective_title.to_s, '-message', message.to_s,
2973
+ in: '/dev/null', out: '/dev/null', err: '/dev/null')
2974
+ else
2975
+ script = "display notification #{applescript_quote(message)} " \
2976
+ "with title #{applescript_quote(effective_title)}"
2977
+ pid = Process.spawn('osascript', '-e', script,
2978
+ in: '/dev/null', out: '/dev/null', err: '/dev/null')
2979
+ end
2980
+ Process.detach(pid)
2981
+ rescue StandardError => e
2982
+ warn "echoes notification: #{e.class}: #{e.message}"
2983
+ end
2984
+
2985
+ # Memoize the discovered path so we don't `which` on every call.
2986
+ def terminal_notifier_path
2987
+ return @terminal_notifier_path if defined?(@terminal_notifier_path)
2988
+ candidates = %w[
2989
+ /opt/homebrew/bin/terminal-notifier
2990
+ /usr/local/bin/terminal-notifier
2991
+ ]
2992
+ path = candidates.find { |p| File.executable?(p) }
2993
+ path ||= begin
2994
+ which = `command -v terminal-notifier 2>/dev/null`.strip
2995
+ which.empty? ? nil : which
2996
+ end
2997
+ @terminal_notifier_path = path
2998
+ end
2999
+
3000
+ # AppleScript string literal: double-quoted, with `\` and `"`
3001
+ # escaped. Newlines pass through as `\n` which AppleScript
3002
+ # interprets as the literal two characters, which is fine for
3003
+ # the "build done" use case.
3004
+ def applescript_quote(str)
3005
+ escaped = str.to_s.gsub('\\', '\\\\\\\\').gsub('"', '\\"')
3006
+ %("#{escaped}")
3007
+ end
3008
+
3009
+ # OSC 7772 ;capture handler. Writes the given pane's pixel buffer
3010
+ # to `path`, dispatching format from the file extension:
3011
+ # .png → rasterized PNG via NSBitmapImageRep (matches the
3012
+ # view's backing scale; 2× pixel dims on Retina).
3013
+ # else → vector PDF via [NSView dataWithPDFInsideRect:]
3014
+ # (default; typical terminal screenshots are 5-20×
3015
+ # smaller than the PNG equivalent because text and
3016
+ # rects survive as drawing ops, not raster pixels).
3017
+ # No reply on the wire — the caller polls the filesystem.
3018
+ NS_BITMAP_IMAGE_FILE_TYPE_PNG = 4
3019
+
3020
+ # Pick raster vs vector by file extension. PDF is the default
3021
+ # for anything other than `.png` — it's both the cheaper and
3022
+ # more useful format for terminal content.
3023
+ def self.capture_format_for(path)
3024
+ File.extname(path).downcase == '.png' ? :png : :pdf
3025
+ end
3026
+
3027
+ # OSC 7772 ;display-info handler. Returns a JSON string of
3028
+ # one entry per NSScreen with pixel dimensions and two flags:
3029
+ # `primary` (the screen that owns the menu bar) and `current`
3030
+ # (the screen the requesting Echoes window is on). The caller
3031
+ # uses `current` to pick "anywhere but here" for a second-screen
3032
+ # presentation window. Empty `[]` on any AppKit failure.
3033
+ def display_info_json(pane)
3034
+ screens = ObjC::MSG_PTR.call(ObjC.cls('NSScreen'), ObjC.sel('screens'))
3035
+ return '[]' if screens.nil? || screens.null?
3036
+ count = ObjC::MSG_RET_L.call(screens, ObjC.sel('count'))
3037
+
3038
+ main_screen = ObjC::MSG_PTR.call(ObjC.cls('NSScreen'), ObjC.sel('mainScreen'))
3039
+ win_screen = nsscreen_for_pane(pane)
3040
+
3041
+ entries = []
3042
+ count.times do |i|
3043
+ s = ObjC::MSG_PTR_L.call(screens, ObjC.sel('objectAtIndex:'), i)
3044
+ _, _, w, h = nsrect_via_invocation(s, 'frame')
3045
+ entries << {
3046
+ 'index' => i,
3047
+ 'w' => w.to_i,
3048
+ 'h' => h.to_i,
3049
+ 'primary' => s.to_i == main_screen.to_i,
3050
+ 'current' => win_screen && s.to_i == win_screen.to_i,
3051
+ }
3052
+ end
3053
+ JSON.generate(entries)
3054
+ rescue StandardError => e
3055
+ warn "echoes display-info: #{e.class}: #{e.message}"
3056
+ '[]'
3057
+ end
3058
+
3059
+ # Return the NSScreen of the NSWindow that owns `pane`, or nil
3060
+ # if the pane isn't currently parented to any window state.
3061
+ def nsscreen_for_pane(pane)
3062
+ ws = @window_states.find { |w| w[:tabs]&.any? { |t| t.panes.include?(pane) } }
3063
+ return nil unless ws && ws[:nswindow]
3064
+ ObjC::MSG_PTR.call(ws[:nswindow], ObjC.sel('screen'))
3065
+ end
3066
+
3067
+ # Invoke a zero-arg method on `target` that returns an NSRect
3068
+ # (4 doubles). Fiddle can't model "return 4 doubles" directly,
3069
+ # so we route through NSInvocation — same pattern as
3070
+ # event_location does for NSPoint.
3071
+ def nsrect_via_invocation(target, sel_name)
3072
+ target_class = ObjC::MSG_PTR.call(target, ObjC.sel('class'))
3073
+ sig = ObjC::MSG_PTR_1.call(
3074
+ target_class, ObjC.sel('instanceMethodSignatureForSelector:'),
3075
+ ObjC.sel(sel_name)
3076
+ )
3077
+ inv = ObjC::MSG_PTR_1.call(
3078
+ ObjC.cls('NSInvocation'), ObjC.sel('invocationWithMethodSignature:'), sig
3079
+ )
3080
+ ObjC::MSG_VOID_1.call(inv, ObjC.sel('setSelector:'), ObjC.sel(sel_name))
3081
+ ObjC::MSG_VOID_1.call(inv, ObjC.sel('invokeWithTarget:'), target)
3082
+ buf = Fiddle::Pointer.malloc(32, Fiddle::RUBY_FREE)
3083
+ ObjC::MSG_VOID_1.call(inv, ObjC.sel('getReturnValue:'), buf)
3084
+ buf[0, 32].unpack('dddd') # x, y, w, h
3085
+ end
3086
+
3087
+ # Standard macOS / Homebrew search dirs we want a child to be
3088
+ # able to find even when Echoes.app was launched by Finder
3089
+ # (which strips PATH down to /usr/bin:/bin:/usr/sbin:/sbin).
3090
+ DEFAULT_PATH_DIRS = %w[
3091
+ /opt/homebrew/bin
3092
+ /usr/local/bin
3093
+ /usr/bin
3094
+ /bin
3095
+ /usr/sbin
3096
+ /sbin
3097
+ ].freeze
3098
+
3099
+ # Build the env Hash for a child program spawned via OSC 7772
3100
+ # ;open-window. Starts from Echoes' own NSProcessInfo-equivalent
3101
+ # env (Ruby's ENV) so PATH / HOME / USER / LANG / TERM / any
3102
+ # ECHOES_* vars naturally propagate, then patches in defaults
3103
+ # for the basics — Echoes.app launched by launchd from Finder
3104
+ # inherits a minimal env, so a child that came in via a
3105
+ # presentation tool can otherwise wind up without a usable
3106
+ # PATH or LANG.
3107
+ def child_env_for_open_window
3108
+ env = ENV.to_h
3109
+ env['PATH'] = merge_path(env['PATH'])
3110
+ env['HOME'] = ENV['HOME'] || Dir.home
3111
+ env['USER'] ||= (ENV['LOGNAME'] || `id -un 2>/dev/null`.chomp)
3112
+ env['LANG'] ||= 'en_US.UTF-8'
3113
+ env['TERM'] ||= Echoes.config.term
3114
+ env
3115
+ end
3116
+
3117
+ # Union the parent's PATH with DEFAULT_PATH_DIRS, preserving
3118
+ # parent order so the parent's preferences win, and dedup so we
3119
+ # don't double up entries the parent already has. Adding rather
3120
+ # than replacing means a user who already exported a tuned PATH
3121
+ # from their shell config keeps it intact.
3122
+ def merge_path(parent_path)
3123
+ seen = {}
3124
+ out = []
3125
+ (parent_path.to_s.split(':') + DEFAULT_PATH_DIRS).each do |dir|
3126
+ next if dir.empty? || seen[dir]
3127
+ seen[dir] = true
3128
+ out << dir
3129
+ end
3130
+ out.join(':')
3131
+ end
3132
+
3133
+ # OSC 7772 ;open-window handler. Parses
3134
+ # `display=N:program=<base64-argv>:fullscreen=yes|no`, decodes
3135
+ # the base64 JSON-encoded argv, and opens a new window on
3136
+ # NSScreen.screens[N] running that argv via PTY. fullscreen=yes
3137
+ # uses the screen's full frame with a borderless, above-menu-bar
3138
+ # window; otherwise visibleFrame with the default style mask.
3139
+ # Fire-and-forget: no reply. The caller polls for whatever
3140
+ # signal the launched program emits (e.g. a unix socket).
3141
+ def open_window_from_osc(pane, args_str)
3142
+ params = {}
3143
+ args_str.to_s.split(':').each do |pair|
3144
+ k, v = pair.split('=', 2)
3145
+ next if k.nil? || k.empty? || v.nil?
3146
+ params[k] = v
3147
+ end
3148
+
3149
+ display_index = (params['display'] || '0').to_i
3150
+ fullscreen = params['fullscreen'] == 'yes'
3151
+ program_b64 = params['program']
3152
+ return unless program_b64
3153
+
3154
+ json_str = program_b64.delete("\r\n\t ").unpack1('m0')
3155
+ argv = JSON.parse(json_str)
3156
+ return unless argv.is_a?(Array) && !argv.empty?
3157
+
3158
+ open_external_window(argv: argv, display_index: display_index, fullscreen: fullscreen)
3159
+ rescue StandardError => e
3160
+ warn "echoes open-window: #{e.class}: #{e.message}"
3161
+ end
3162
+
3163
+ # Open a new window running `argv` (e.g. ["/usr/local/bin/przn",
3164
+ # "--audience", …]) on a specific display. Sizes the content
3165
+ # area to the screen's frame (fullscreen) or visibleFrame, picks
3166
+ # cell rows/cols that fit, and routes lifecycle through the
3167
+ # standard tab/pane/window-state pipeline so the window
3168
+ # auto-closes when the child program exits.
3169
+ def open_external_window(argv:, display_index:, fullscreen:)
3170
+ save_window_state
3171
+
3172
+ screens = ObjC::MSG_PTR.call(ObjC.cls('NSScreen'), ObjC.sel('screens'))
3173
+ count = ObjC::MSG_RET_L.call(screens, ObjC.sel('count'))
3174
+ return if display_index < 0 || display_index >= count
3175
+ target = ObjC::MSG_PTR_L.call(screens, ObjC.sel('objectAtIndex:'), display_index)
3176
+
3177
+ rect_sel = fullscreen ? 'frame' : 'visibleFrame'
3178
+ sx, sy, sw, sh = nsrect_via_invocation(target, rect_sel)
3179
+
3180
+ cols = (sw / @cell_width).floor
3181
+ rows = (sh / @cell_height).floor
3182
+ cols = [cols, 20].max
3183
+ rows = [rows, 5].max
3184
+
3185
+ tab = Tab.new(command: argv, rows: rows, cols: cols, embedded: false,
3186
+ env: child_env_for_open_window)
3187
+ tab.title = File.basename(argv.first.to_s)
3188
+ tab.panes.each { |pn| wire_screen_handlers(pn) }
3189
+
3190
+ # Borderless mask (0) for fullscreen; default chrome otherwise.
3191
+ style_mask = fullscreen ? 0 : ObjC::NSWindowStyleMaskDefault
3192
+ new_window = ObjC::MSG_PTR.call(ObjC.cls('NSWindow'), ObjC.sel('alloc'))
3193
+ new_window = ObjC::MSG_PTR_RECT_L_L_I.call(
3194
+ new_window, ObjC.sel('initWithContentRect:styleMask:backing:defer:'),
3195
+ sx, sy, sw, sh, style_mask, ObjC::NSBackingStoreBuffered, 0
3196
+ )
3197
+ ObjC::MSG_VOID_1.call(new_window, ObjC.sel('setTitle:'), ObjC.nsstring(tab.title))
3198
+ ObjC::MSG_VOID_L.call(new_window, ObjC.sel('setCollectionBehavior:'), 1 << 7)
3199
+ ObjC::MSG_VOID_I.call(new_window, ObjC.sel('setAcceptsMouseMovedEvents:'), 1)
3200
+ if fullscreen
3201
+ # NSMainMenuWindowLevel + 1 = 25 = NSStatusWindowLevel; floats
3202
+ # above the menu bar so the presentation truly fills the
3203
+ # screen even without going through AppKit's fullscreen
3204
+ # transition.
3205
+ ObjC::MSG_VOID_L.call(new_window, ObjC.sel('setLevel:'), 25)
3206
+ end
3207
+
3208
+ new_view = ObjC::MSG_PTR.call(@view_class, ObjC.sel('alloc'))
3209
+ new_view = ObjC::MSG_PTR_RECT.call(new_view, ObjC.sel('initWithFrame:'),
3210
+ 0.0, 0.0, sw, sh)
3211
+ drag_types = ObjC::MSG_PTR_1.call(ObjC.cls('NSArray'), ObjC.sel('arrayWithObject:'),
3212
+ ObjC::NSPasteboardTypeFileURL)
3213
+ ObjC::MSG_VOID_1.call(new_view, ObjC.sel('registerForDraggedTypes:'), drag_types)
3214
+
3215
+ ObjC::MSG_VOID_1.call(new_window, ObjC.sel('setContentView:'), new_view)
3216
+ ObjC::MSG_VOID_1.call(new_window, ObjC.sel('makeKeyAndOrderFront:'), @app)
3217
+ ObjC::MSG_VOID_1.call(new_window, ObjC.sel('makeFirstResponder:'), new_view)
3218
+ ObjC::MSG_VOID_I.call(@app, ObjC.sel('activateIgnoringOtherApps:'), 1)
3219
+
3220
+ nc = ObjC::MSG_PTR.call(ObjC.cls('NSNotificationCenter'), ObjC.sel('defaultCenter'))
3221
+ ObjC::MSG_VOID_4.call(nc, ObjC.sel('addObserver:selector:name:object:'),
3222
+ new_view, ObjC.sel('windowDidBecomeKey:'),
3223
+ ObjC.nsstring('NSWindowDidBecomeKeyNotification'), new_window)
3224
+ ObjC::MSG_VOID_4.call(nc, ObjC.sel('addObserver:selector:name:object:'),
3225
+ new_view, ObjC.sel('windowDidResignKey:'),
3226
+ ObjC.nsstring('NSWindowDidResignKeyNotification'), new_window)
3227
+
3228
+ @window = new_window
3229
+ @view = new_view
3230
+ @tabs = [tab]
3231
+ @active_tab = 0
3232
+ @search_mode = false
3233
+ @search_query = +""
3234
+ @search_matches = []
3235
+ @search_index = -1
3236
+ @search_regex_mode = false
3237
+ @search_case_insensitive = false
3238
+ @bell_flash = 0
3239
+ @marked_text = nil
3240
+ @current_event = nil
3241
+ @selection_anchor = nil
3242
+ @selection_end = nil
3243
+ @selection_word_anchor = nil
3244
+ @window_focused = true
3245
+
3246
+ ws = {}
3247
+ @window_states << ws
3248
+ @view_to_ws[@view.to_i] = ws
3249
+ save_window_state
3250
+ end
3251
+
3252
+ def capture_pane_to_png(pane, path)
3253
+ return unless @view
3254
+ tab = current_tab
3255
+ return unless tab
3256
+ rect_info = tab.pane_tree.layout(0, 0, @cols, @rows).find { |r| r[:pane] == pane }
3257
+ return unless rect_info
3258
+
3259
+ gy = grid_y_offset
3260
+ px = rect_info[:x] * @cell_width
3261
+ py = gy + rect_info[:y] * @cell_height
3262
+ pw = rect_info[:w] * @cell_width
3263
+ ph = rect_info[:h] * @cell_height
3264
+
3265
+ bytes =
3266
+ case self.class.capture_format_for(path)
3267
+ when :png then png_bytes_for_view_rect(px, py, pw, ph)
3268
+ else pdf_bytes_for_view_rect(px, py, pw, ph)
3269
+ end
3270
+ return unless bytes
3271
+ File.binwrite(path, bytes)
3272
+ rescue => e
3273
+ warn "echoes capture: #{e.class}: #{e.message}"
3274
+ end
3275
+
3276
+ def pdf_bytes_for_view_rect(px, py, pw, ph)
3277
+ data = ObjC::MSG_PTR_RECT.call(
3278
+ @view, ObjC.sel('dataWithPDFInsideRect:'),
3279
+ px, py, pw, ph
3280
+ )
3281
+ return nil if data.nil? || data.null?
3282
+ length = ObjC::MSG_RET_L.call(data, ObjC.sel('length'))
3283
+ bytes_ptr = ObjC::MSG_PTR.call(data, ObjC.sel('bytes'))
3284
+ bytes_ptr.to_str(length)
3285
+ end
3286
+
3287
+ def png_bytes_for_view_rect(px, py, pw, ph)
3288
+ rep = ObjC::MSG_PTR_RECT.call(
3289
+ @view, ObjC.sel('bitmapImageRepForCachingDisplayInRect:'),
3290
+ px, py, pw, ph
3291
+ )
3292
+ return nil if rep.nil? || rep.null?
3293
+ ObjC::MSG_VOID_RECT_1.call(
3294
+ @view, ObjC.sel('cacheDisplayInRect:toBitmapImageRep:'),
3295
+ px, py, pw, ph, rep
3296
+ )
3297
+ empty_dict = ObjC.nsdict({})
3298
+ data = ObjC::MSG_PTR_L_1.call(
3299
+ rep, ObjC.sel('representationUsingType:properties:'),
3300
+ NS_BITMAP_IMAGE_FILE_TYPE_PNG, empty_dict
3301
+ )
3302
+ return nil if data.nil? || data.null?
3303
+ length = ObjC::MSG_RET_L.call(data, ObjC.sel('length'))
3304
+ bytes_ptr = ObjC::MSG_PTR.call(data, ObjC.sel('bytes'))
3305
+ bytes_ptr.to_str(length)
2477
3306
  end
2478
3307
 
2479
3308
  def create_nsfont(size, family: nil)
@@ -2507,6 +3336,8 @@ module Echoes
2507
3336
  leading = ObjC::MSG_RET_D.call(@font, ObjC.sel('leading'))
2508
3337
  @cell_height = ascender - descender + leading
2509
3338
  @font_default_line_height = ObjC::MSG_RET_D.call(@font, ObjC.sel('defaultLineHeightForFont'))
3339
+ @font_default_ascender = ascender
3340
+ @font_default_family = ObjC.to_ruby_string(ObjC::MSG_PTR.call(@font, ObjC.sel('familyName')))
2510
3341
  @font_y_offset_cache = {}
2511
3342
 
2512
3343
  # Propagate cell metrics to all pane screens (sixel sizing,
@@ -2514,7 +3345,7 @@ module Echoes
2514
3345
  # idempotent so re-wiring on every font change is fine.
2515
3346
  @window_states.each do |ws|
2516
3347
  ws[:tabs]&.each do |tab|
2517
- tab.panes.each { |pane| wire_screen_handlers(pane.screen) }
3348
+ tab.panes.each { |pane| wire_screen_handlers(pane) }
2518
3349
  end
2519
3350
  end
2520
3351
  end
@@ -2536,18 +3367,39 @@ module Echoes
2536
3367
  @font_cache[char]
2537
3368
  end
2538
3369
 
2539
- # AppKit's NSString drawing positions the line box using
2540
- # defaultLineHeightForFont, which can differ between regular and bold
2541
- # variants of the same font (e.g. PlemolJP35 Console NF: regular=24, bold=29).
2542
- # That difference shifts the bold baseline downward versus regular by
2543
- # `bold_lh - regular_lh` points. Compensate by shifting the draw origin
2544
- # by the negative of that difference so all baselines coincide.
3370
+ # Aligns a non-@font run's baseline with the @font baseline.
3371
+ # AppKit's drawAtPoint pins the line box top to (x, y), but
3372
+ # the formula it uses to derive the baseline from y differs
3373
+ # between same-family variants and unrelated families:
3374
+ #
3375
+ # - Same-family bold/italic siblings: AppKit lays out using
3376
+ # `defaultLineHeightForFont`, and a wider bold LH pushes
3377
+ # the bold baseline downward (e.g. PlemolJP35 Console NF:
3378
+ # regular LH=24, bold LH=29). Shifting by LH-delta puts
3379
+ # them back on the same row.
3380
+ # - Unrelated fallbacks (CJK, emoji, symbol fonts pulled in
3381
+ # by CTFontCreateForString): LH-delta tracks total line
3382
+ # box height but not where the *glyph* sits inside it. A
3383
+ # geometric-symbol font with a smaller ascender ends up
3384
+ # rendering ▶ visibly too high alongside ASCII text. The
3385
+ # ascender delta is the better metric here.
3386
+ #
3387
+ # So pick the metric based on whether the font shares
3388
+ # @font's family.
2545
3389
  def y_offset_for_font(font)
2546
3390
  return 0.0 if font.to_i == @font.to_i
2547
3391
  cached = @font_y_offset_cache[font.to_i]
2548
3392
  return cached if cached
2549
- font_lh = ObjC::MSG_RET_D.call(font, ObjC.sel('defaultLineHeightForFont'))
2550
- @font_y_offset_cache[font.to_i] = @font_default_line_height - font_lh
3393
+ font_family = ObjC.to_ruby_string(ObjC::MSG_PTR.call(font, ObjC.sel('familyName')))
3394
+ offset =
3395
+ if font_family == @font_default_family
3396
+ font_lh = ObjC::MSG_RET_D.call(font, ObjC.sel('defaultLineHeightForFont'))
3397
+ @font_default_line_height - font_lh
3398
+ else
3399
+ font_ascender = ObjC::MSG_RET_D.call(font, ObjC.sel('ascender'))
3400
+ @font_default_ascender - font_ascender
3401
+ end
3402
+ @font_y_offset_cache[font.to_i] = offset
2551
3403
  end
2552
3404
 
2553
3405
  MODIFIED_KEYS = {
@@ -2649,8 +3501,10 @@ module Echoes
2649
3501
  [1.0, 1.0, 1.0], # 15: bright white
2650
3502
  ]
2651
3503
 
2652
- # Override with user-configured palette
2653
- if (palette = Echoes.config.color_palette)
3504
+ # Override with active-profile palette (falls through to the
3505
+ # legacy top-level Echoes.config.color_palette when the user's
3506
+ # config doesn't declare any profiles).
3507
+ if (palette = @active_profile&.color_palette)
2654
3508
  palette.each_with_index do |rgb, i|
2655
3509
  ansi_rgb[i] = rgb if i < 16 && rgb
2656
3510
  end