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/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(
|
|
37
|
-
@default_bg = make_color(
|
|
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
|
-
@
|
|
40
|
-
@
|
|
41
|
-
@selection_color = make_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
|
|
80
|
-
|
|
81
|
-
|
|
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,
|
|
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),
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
947
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1268
|
+
snap_fill.call(x, y, cell_w, @cell_height)
|
|
1077
1269
|
end
|
|
1078
1270
|
|
|
1079
|
-
|
|
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
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
2153
|
-
|
|
2518
|
+
# Vertically center titles in the bar.
|
|
2519
|
+
title_y = ty + (tbh - @font_default_line_height) / 2.0
|
|
2154
2520
|
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
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 => @
|
|
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,
|
|
2171
|
-
|
|
2172
|
-
#
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2469
|
-
# everywhere a new Screen comes into existence —
|
|
2470
|
-
# create_tab, split_horizontal/vertical, and the
|
|
2471
|
-
# update path.
|
|
2472
|
-
def wire_screen_handlers(
|
|
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
|
|
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
|
-
#
|
|
2540
|
-
#
|
|
2541
|
-
#
|
|
2542
|
-
#
|
|
2543
|
-
#
|
|
2544
|
-
#
|
|
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
|
-
|
|
2550
|
-
|
|
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
|
|
2653
|
-
|
|
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
|