echoes 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/echoes/gui.rb ADDED
@@ -0,0 +1,2861 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pty'
4
+ require 'shellwords'
5
+ require 'socket'
6
+ require 'uri'
7
+
8
+ module Echoes
9
+ class GUI
10
+ CRASH_LOG = File.join(Dir.home, '.local', 'share', 'echoes', 'crash.log')
11
+
12
+ def log_crash(exception, context: nil)
13
+ dir = File.dirname(CRASH_LOG)
14
+ Dir.mkdir(dir) unless Dir.exist?(dir)
15
+ File.open(CRASH_LOG, 'a') do |f|
16
+ f.puts "--- #{Time.now} ---"
17
+ f.puts "Context: #{context}" if context
18
+ f.puts "#{exception.class}: #{exception.message}"
19
+ exception.backtrace&.each { |line| f.puts " #{line}" }
20
+ f.puts
21
+ end
22
+ STDERR.puts "echoes: #{exception.class}: #{exception.message} (logged to #{CRASH_LOG})"
23
+ rescue # log_crash itself must never raise
24
+ end
25
+
26
+ def initialize(command: Echoes.config.shell, rows: Echoes.config.rows, cols: Echoes.config.cols, font_size: nil)
27
+ @rows = rows
28
+ @cols = cols
29
+ # Persisted font size wins over the config default; both wrappers
30
+ # gracefully fall through if NSUserDefaults isn't reachable.
31
+ @font_size = font_size || Preferences.fetch_double(:font_size, default: Echoes.config.font_size)
32
+ @command = command
33
+ @tabs = []
34
+ @active_tab = 0
35
+ @colors = build_color_table
36
+ @default_fg = make_color(*Echoes.config.foreground)
37
+ @default_bg = make_color(*Echoes.config.background)
38
+ @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)
42
+ @search_match_color = make_color(0.6, 0.5, 0.0)
43
+ @search_current_color = make_color(0.8, 0.6, 0.0)
44
+ @selection_anchor = nil
45
+ @selection_end = nil
46
+ @font_cache = {}
47
+ @rgb_color_cache = {}
48
+ @nsstring_cache = {}
49
+ @cursor_blink_on = true
50
+ @cursor_blink_counter = 0
51
+ @search_mode = false
52
+ @search_query = +""
53
+ @search_matches = []
54
+ @search_index = -1
55
+ @bell_flash = 0
56
+ @marked_text = nil
57
+ @current_event = nil
58
+ @pane_divider_color = make_color(*Echoes.config.pane_divider_color)
59
+ @active_pane_border_color = make_color(*Echoes.config.active_pane_border_color)
60
+ @copy_mode_cursor_color = make_color(*Echoes.config.copy_mode_cursor_color)
61
+ @window_states = []
62
+ @view_to_ws = {}
63
+ end
64
+
65
+ def run
66
+ setup_app
67
+ create_fonts
68
+ create_view_class
69
+ open_new_window
70
+ setup_timer
71
+ start_app
72
+ end
73
+
74
+ def create_tab(editor_file: nil)
75
+ cwd = self.class.pane_local_cwd(current_tab&.active_pane)
76
+ tab = Tab.new(command: @command, rows: @rows, cols: @cols, cwd: cwd,
77
+ embedded: embedded_mode?, editor_file: editor_file)
78
+ 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
82
+ end
83
+
84
+ # Convert the active pane's OSC 7 `current_directory` URI into a local
85
+ # filesystem path, or return nil if it isn't a usable local path
86
+ # (missing, malformed, points at a remote host, or doesn't exist).
87
+ def self.pane_local_cwd(pane)
88
+ uri_str = pane&.screen&.current_directory
89
+ cwd_from_osc7_uri(uri_str)
90
+ end
91
+
92
+ def self.cwd_from_osc7_uri(uri_str)
93
+ return nil if uri_str.nil? || uri_str.empty?
94
+ uri = URI.parse(uri_str) rescue nil
95
+ return nil unless uri && uri.scheme == 'file'
96
+ host = uri.host.to_s
97
+ local_host = Socket.gethostname
98
+ unless host.empty? || host == 'localhost' ||
99
+ host == local_host || host == local_host.split('.').first
100
+ return nil
101
+ end
102
+ path = URI.decode_www_form_component(uri.path) rescue nil
103
+ path if path && !path.empty? && Dir.exist?(path)
104
+ end
105
+
106
+ def close_tab(index)
107
+ return if index < 0 || index >= @tabs.size
108
+
109
+ @tabs[index].close
110
+ @tabs.delete_at(index)
111
+
112
+ if @tabs.empty?
113
+ close_current_window
114
+ return
115
+ end
116
+
117
+ @active_tab = @active_tab.clamp(0, @tabs.size - 1)
118
+ end
119
+
120
+ def current_tab
121
+ @tabs[@active_tab]
122
+ end
123
+
124
+ # Phase 1 launch flag for the in-process Rubish embedding. Setting
125
+ # ECHOES_EMBED=1 in the environment routes new Tab/Pane creation
126
+ # through Echoes::EmbeddedShell instead of PTY.spawn.
127
+ def embedded_mode?
128
+ ENV['ECHOES_EMBED'] == '1'
129
+ end
130
+
131
+ def activate_for_view(view_ptr)
132
+ ws = @view_to_ws[view_ptr.to_i]
133
+ return unless ws
134
+ save_window_state
135
+ load_window_state(ws)
136
+ end
137
+
138
+ private def save_window_state
139
+ return unless @window
140
+ ws = @view_to_ws[@view.to_i]
141
+ return unless ws
142
+ ws[:nswindow] = @window
143
+ ws[:nsview] = @view
144
+ ws[:tabs] = @tabs
145
+ ws[:active_tab] = @active_tab
146
+ ws[:search_mode] = @search_mode
147
+ ws[:search_query] = @search_query
148
+ ws[:search_matches] = @search_matches
149
+ ws[:search_index] = @search_index
150
+ ws[:bell_flash] = @bell_flash
151
+ ws[:marked_text] = @marked_text
152
+ ws[:current_event] = @current_event
153
+ ws[:selection_anchor] = @selection_anchor
154
+ ws[:selection_end] = @selection_end
155
+ ws[:rows] = @rows
156
+ ws[:cols] = @cols
157
+ ws[:focused] = @window_focused
158
+ end
159
+
160
+ private def load_window_state(ws)
161
+ @window = ws[:nswindow]
162
+ @view = ws[:nsview]
163
+ @tabs = ws[:tabs]
164
+ @active_tab = ws[:active_tab]
165
+ @search_mode = ws[:search_mode]
166
+ @search_query = ws[:search_query]
167
+ @search_matches = ws[:search_matches]
168
+ @search_index = ws[:search_index]
169
+ @bell_flash = ws[:bell_flash]
170
+ @marked_text = ws[:marked_text]
171
+ @current_event = ws[:current_event]
172
+ @selection_anchor = ws[:selection_anchor]
173
+ @selection_end = ws[:selection_end]
174
+ @rows = ws[:rows]
175
+ @cols = ws[:cols]
176
+ @window_focused = ws.fetch(:focused, true)
177
+ end
178
+
179
+ private def close_current_window
180
+ closing_view = @view
181
+ ws = @view_to_ws[closing_view.to_i]
182
+ @view_to_ws.delete(closing_view.to_i)
183
+ @window_states.delete(ws)
184
+ ObjC::MSG_VOID_1.call(@window, ObjC.sel('orderOut:'), Fiddle::Pointer.new(0))
185
+
186
+ if @window_states.empty?
187
+ ObjC::MSG_VOID_1.call(@app, ObjC.sel('terminate:'), Fiddle::Pointer.new(0))
188
+ return
189
+ end
190
+
191
+ load_window_state(@window_states.last)
192
+
193
+ # If the timer targeted the closed view, retarget it
194
+ if @timer && closing_view.to_i == @timer_view_id
195
+ ObjC::MSG_VOID.call(@timer, ObjC.sel('invalidate'))
196
+ @timer_view_id = @view.to_i
197
+ @timer = ObjC::MSG_PTR_D_P_P_P_I.call(
198
+ ObjC.cls('NSTimer'),
199
+ ObjC.sel('scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:'),
200
+ 1.0 / 60.0, @view, ObjC.sel('timerFired:'),
201
+ Fiddle::Pointer.new(0), 1
202
+ )
203
+ end
204
+ end
205
+
206
+ def tab_bar_height
207
+ @tabs.size > 1 ? @cell_height : 0.0
208
+ end
209
+
210
+ def grid_y_offset
211
+ Echoes.config.tab_position == :bottom ? 0.0 : tab_bar_height
212
+ end
213
+
214
+ def tab_bar_y
215
+ Echoes.config.tab_position == :bottom ? @cell_height * @rows : 0.0
216
+ end
217
+
218
+ def setup_app
219
+ @app = ObjC::MSG_PTR.call(ObjC.cls('NSApplication'), ObjC.sel('sharedApplication'))
220
+ ObjC::MSG_VOID_I.call(@app, ObjC.sel('setActivationPolicy:'), 0)
221
+ # Disable native NSWindow tabbing so Cmd+N always spawns a real
222
+ # new window. Default macOS behavior in fullscreen is to fold
223
+ # additional NSWindows into the same OS-level tabbed window —
224
+ # but Echoes already has its own tab abstraction (with its own
225
+ # tab bar and @tabs array per window), so that promotion creates
226
+ # a phantom OS tab the Echoes side has no record of, leaving a
227
+ # second clickable tab that switches to nothing.
228
+ ObjC::MSG_VOID_I.call(ObjC.cls('NSWindow'), ObjC.sel('setAllowsAutomaticWindowTabbing:'), 0)
229
+ setup_menu_bar
230
+ end
231
+
232
+ def setup_menu_bar
233
+ main_menu = create_menu('')
234
+
235
+ # Application menu
236
+ app_menu = create_menu('Echoes')
237
+ add_menu_item(app_menu, "About Echoes", 'showAbout:', '')
238
+ add_separator(app_menu)
239
+ add_menu_item(app_menu, "Hide Echoes", 'hide:', 'h')
240
+ add_menu_item(app_menu, "Hide Others", 'hideOtherApplications:', '')
241
+ add_menu_item(app_menu, "Show All", 'unhideAllApplications:', '')
242
+ add_separator(app_menu)
243
+ add_menu_item(app_menu, "Quit Echoes", 'terminate:', 'q')
244
+ add_submenu(main_menu, app_menu, 'Echoes')
245
+
246
+ # Edit menu
247
+ edit_menu = create_menu('Edit')
248
+ add_menu_item(edit_menu, "Copy", 'copy:', 'c')
249
+ add_menu_item(edit_menu, "Paste", 'paste:', 'v')
250
+ add_menu_item(edit_menu, "Select All", 'selectAll:', 'a')
251
+ add_submenu(main_menu, edit_menu, 'Edit')
252
+
253
+ # View menu
254
+ 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')
259
+ add_separator(view_menu)
260
+ add_menu_item(view_menu, "Find", 'toggleFind:', 'f')
261
+ add_menu_item(view_menu, "Find Next", 'findNext:', 'g')
262
+ add_menu_item(view_menu, "Find Previous", 'findPrevious:', 'g',
263
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)
264
+ add_separator(view_menu)
265
+ add_menu_item(view_menu, "Hide Mouse Pointer", 'togglePointer:', 'p',
266
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)
267
+ add_submenu(main_menu, view_menu, 'View')
268
+
269
+ # Window menu
270
+ window_menu = create_menu('Window')
271
+ add_menu_item(window_menu, "Minimize", 'miniaturize:', 'm')
272
+ add_menu_item(window_menu, "Zoom", 'zoom:', '')
273
+ add_menu_item(window_menu, "Enter Full Screen", 'toggleFullScreen:', 'f',
274
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagControl)
275
+ add_separator(window_menu)
276
+ add_menu_item(window_menu, "Show Previous Tab", 'showPreviousTab:', '{',
277
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)
278
+ add_menu_item(window_menu, "Show Next Tab", 'showNextTab:', '}',
279
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)
280
+ add_separator(window_menu)
281
+ add_menu_item(window_menu, "Select Next Pane", 'selectNextPane:', ']')
282
+ add_menu_item(window_menu, "Select Previous Pane", 'selectPreviousPane:', '[')
283
+ add_separator(window_menu)
284
+ add_menu_item(window_menu, "Toggle Copy Mode", 'toggleCopyMode:', 'c',
285
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)
286
+ add_separator(window_menu)
287
+ # Register the menu as NSApplication's "windows menu"; AppKit
288
+ # auto-populates it with one item per NSWindow (using the window's
289
+ # title) and handles activation when an item is selected.
290
+ ObjC::MSG_VOID_1.call(@app, ObjC.sel('setWindowsMenu:'), window_menu)
291
+ add_submenu(main_menu, window_menu, 'Window')
292
+
293
+ # Shell menu
294
+ 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')
298
+ add_separator(shell_menu)
299
+ add_menu_item(shell_menu, "Edit File…", 'editFile:', 'e',
300
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)
301
+ add_separator(shell_menu)
302
+ add_menu_item(shell_menu, "Split Right", 'splitRight:', 'd')
303
+ add_menu_item(shell_menu, "Split Down", 'splitDown:', 'd',
304
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)
305
+ add_menu_item(shell_menu, "Close Pane", 'closePane:', 'w',
306
+ modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)
307
+ add_submenu(main_menu, shell_menu, 'Shell')
308
+
309
+ ObjC::MSG_VOID_1.call(@app, ObjC.sel('setMainMenu:'), main_menu)
310
+ end
311
+
312
+ private def create_menu(title)
313
+ m = ObjC::MSG_PTR.call(ObjC.cls('NSMenu'), ObjC.sel('alloc'))
314
+ ObjC::MSG_PTR_1.call(m, ObjC.sel('initWithTitle:'), ObjC.nsstring(title))
315
+ end
316
+
317
+ private def add_menu_item(menu, title, action, key, modifiers: ObjC::NSEventModifierFlagCommand)
318
+ item = ObjC::MSG_PTR.call(ObjC.cls('NSMenuItem'), ObjC.sel('alloc'))
319
+ 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))
321
+ if modifiers != ObjC::NSEventModifierFlagCommand && !key.empty?
322
+ ObjC::MSG_VOID_L.call(item, ObjC.sel('setKeyEquivalentModifierMask:'), modifiers)
323
+ end
324
+ ObjC::MSG_VOID_1.call(menu, ObjC.sel('addItem:'), item)
325
+ item
326
+ end
327
+
328
+ private def add_separator(menu)
329
+ sep = ObjC::MSG_PTR.call(ObjC.cls('NSMenuItem'), ObjC.sel('separatorItem'))
330
+ ObjC::MSG_VOID_1.call(menu, ObjC.sel('addItem:'), sep)
331
+ end
332
+
333
+ private def add_submenu(parent, submenu, title)
334
+ item = ObjC::MSG_PTR.call(ObjC.cls('NSMenuItem'), ObjC.sel('alloc'))
335
+ item = ObjC::MSG_PTR_3.call(item, ObjC.sel('initWithTitle:action:keyEquivalent:'),
336
+ ObjC.nsstring(title), Fiddle::Pointer.new(0), ObjC.nsstring(''))
337
+ ObjC::MSG_VOID_1.call(item, ObjC.sel('setSubmenu:'), submenu)
338
+ ObjC::MSG_VOID_1.call(parent, ObjC.sel('addItem:'), item)
339
+ end
340
+
341
+ def create_fonts
342
+ @font = ObjC.retain(create_nsfont(@font_size))
343
+ @bold_font = ObjC.retain(create_bold_nsfont(@font))
344
+ @font_y_offset_cache = {}
345
+ update_cell_metrics
346
+ end
347
+
348
+ def create_view_class
349
+ gui = self
350
+
351
+ @draw_rect_closure = Fiddle::Closure::BlockCaller.new(
352
+ Fiddle::TYPE_VOID,
353
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP,
354
+ Fiddle::TYPE_DOUBLE, Fiddle::TYPE_DOUBLE, Fiddle::TYPE_DOUBLE, Fiddle::TYPE_DOUBLE]
355
+ ) do |_self, _cmd, x, y, w, h|
356
+ gui.activate_for_view(_self); gui.draw_rect(y, y + h)
357
+ rescue => e
358
+ gui.log_crash(e, context: 'draw_rect')
359
+ end
360
+
361
+ @key_down_closure = Fiddle::Closure::BlockCaller.new(
362
+ Fiddle::TYPE_VOID,
363
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
364
+ ) do |_self, _cmd, event|
365
+ gui.activate_for_view(_self); gui.key_down(event)
366
+ rescue => e
367
+ gui.log_crash(e, context: 'key_down')
368
+ end
369
+
370
+ @accepts_fr_closure = Fiddle::Closure::BlockCaller.new(
371
+ Fiddle::TYPE_INT,
372
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
373
+ ) { |_self, _cmd| 1 }
374
+
375
+ @timer_fired_closure = Fiddle::Closure::BlockCaller.new(
376
+ Fiddle::TYPE_VOID,
377
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
378
+ ) do |_self, _cmd, _timer|
379
+ gui.timer_fired
380
+ rescue => e
381
+ gui.log_crash(e, context: 'timer_fired')
382
+ end
383
+
384
+ @is_flipped_closure = Fiddle::Closure::BlockCaller.new(
385
+ Fiddle::TYPE_INT,
386
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
387
+ ) { |_self, _cmd| 1 }
388
+
389
+ # AppKit calls this when it (re)builds cursor rects for the
390
+ # view — on key-window changes, view-resize, and explicit
391
+ # `[window invalidateCursorRectsForView:view]` calls. Register
392
+ # an I-beam over the entire content area so the mouse cursor
393
+ # turns into a text-selection bar when hovering over the grid.
394
+ @reset_cursor_rects_closure = Fiddle::Closure::BlockCaller.new(
395
+ Fiddle::TYPE_VOID,
396
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
397
+ ) do |_self, _cmd|
398
+ ibeam = ObjC::MSG_PTR.call(ObjC.cls('NSCursor'), ObjC.sel('IBeamCursor'))
399
+ w = (@cell_width || 8.0) * ((@cols || 80) + 1)
400
+ h = (@cell_height || 16.0) * ((@rows || 24) + 1) + tab_bar_height
401
+ ObjC::MSG_VOID_RECT_1.call(_self, ObjC.sel('addCursorRect:cursor:'),
402
+ 0.0, 0.0, w, h, ibeam)
403
+ rescue => e
404
+ gui.log_crash(e, context: 'resetCursorRects')
405
+ end
406
+
407
+ @scroll_wheel_closure = Fiddle::Closure::BlockCaller.new(
408
+ Fiddle::TYPE_VOID,
409
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
410
+ ) do |_self, _cmd, event|
411
+ gui.activate_for_view(_self); gui.scroll_wheel(event)
412
+ rescue => e
413
+ gui.log_crash(e, context: 'scroll_wheel')
414
+ end
415
+
416
+ @mouse_down_closure = Fiddle::Closure::BlockCaller.new(
417
+ Fiddle::TYPE_VOID,
418
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
419
+ ) do |_self, _cmd, event|
420
+ gui.activate_for_view(_self); gui.mouse_down(event)
421
+ rescue => e
422
+ gui.log_crash(e, context: 'mouse_down')
423
+ end
424
+
425
+ @mouse_dragged_closure = Fiddle::Closure::BlockCaller.new(
426
+ Fiddle::TYPE_VOID,
427
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
428
+ ) do |_self, _cmd, event|
429
+ gui.activate_for_view(_self); gui.mouse_dragged(event)
430
+ rescue => e
431
+ gui.log_crash(e, context: 'mouse_dragged')
432
+ end
433
+
434
+ @mouse_moved_closure = Fiddle::Closure::BlockCaller.new(
435
+ Fiddle::TYPE_VOID,
436
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
437
+ ) do |_self, _cmd, event|
438
+ gui.activate_for_view(_self); gui.mouse_moved(event)
439
+ rescue => e
440
+ gui.log_crash(e, context: 'mouse_moved')
441
+ end
442
+
443
+ @mouse_up_closure = Fiddle::Closure::BlockCaller.new(
444
+ Fiddle::TYPE_VOID,
445
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
446
+ ) do |_self, _cmd, event|
447
+ gui.activate_for_view(_self); gui.mouse_up(event)
448
+ rescue => e
449
+ gui.log_crash(e, context: 'mouse_up')
450
+ end
451
+
452
+ @right_mouse_down_closure = Fiddle::Closure::BlockCaller.new(
453
+ Fiddle::TYPE_VOID,
454
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
455
+ ) do |_self, _cmd, event|
456
+ gui.activate_for_view(_self); gui.right_mouse_down(event)
457
+ rescue => e
458
+ gui.log_crash(e, context: 'right_mouse_down')
459
+ end
460
+
461
+ @right_mouse_dragged_closure = Fiddle::Closure::BlockCaller.new(
462
+ Fiddle::TYPE_VOID,
463
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
464
+ ) do |_self, _cmd, event|
465
+ gui.activate_for_view(_self); gui.right_mouse_dragged(event)
466
+ rescue => e
467
+ gui.log_crash(e, context: 'right_mouse_dragged')
468
+ end
469
+
470
+ @right_mouse_up_closure = Fiddle::Closure::BlockCaller.new(
471
+ Fiddle::TYPE_VOID,
472
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
473
+ ) do |_self, _cmd, event|
474
+ gui.activate_for_view(_self); gui.right_mouse_up(event)
475
+ rescue => e
476
+ gui.log_crash(e, context: 'right_mouse_up')
477
+ end
478
+
479
+ @other_mouse_down_closure = Fiddle::Closure::BlockCaller.new(
480
+ Fiddle::TYPE_VOID,
481
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
482
+ ) do |_self, _cmd, event|
483
+ gui.activate_for_view(_self); gui.other_mouse_down(event)
484
+ rescue => e
485
+ gui.log_crash(e, context: 'other_mouse_down')
486
+ end
487
+
488
+ @other_mouse_dragged_closure = Fiddle::Closure::BlockCaller.new(
489
+ Fiddle::TYPE_VOID,
490
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
491
+ ) do |_self, _cmd, event|
492
+ gui.activate_for_view(_self); gui.other_mouse_dragged(event)
493
+ rescue => e
494
+ gui.log_crash(e, context: 'other_mouse_dragged')
495
+ end
496
+
497
+ @other_mouse_up_closure = Fiddle::Closure::BlockCaller.new(
498
+ Fiddle::TYPE_VOID,
499
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
500
+ ) do |_self, _cmd, event|
501
+ gui.activate_for_view(_self); gui.other_mouse_up(event)
502
+ rescue => e
503
+ gui.log_crash(e, context: 'other_mouse_up')
504
+ end
505
+
506
+ @perform_key_equiv_closure = Fiddle::Closure::BlockCaller.new(
507
+ Fiddle::TYPE_INT,
508
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
509
+ ) { |_self, _cmd, event| gui.activate_for_view(_self); gui.perform_key_equivalent(event) }
510
+
511
+ # Get NSView's original setFrameSize: IMP so we can call super
512
+ nsview_cls = ObjC.cls('NSView')
513
+ super_imp = ObjC::GetMethodImpl.call(nsview_cls, ObjC.sel('setFrameSize:'))
514
+ @super_set_frame_size = Fiddle::Function.new(super_imp, [ObjC::P, ObjC::P, ObjC::D, ObjC::D], ObjC::V)
515
+
516
+ @set_frame_size_closure = Fiddle::Closure::BlockCaller.new(
517
+ Fiddle::TYPE_VOID,
518
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_DOUBLE, Fiddle::TYPE_DOUBLE]
519
+ ) do |_self, _cmd, w, h|
520
+ @super_set_frame_size.call(_self, _cmd, w, h)
521
+ gui.activate_for_view(_self)
522
+ gui.handle_resize(w, h)
523
+ rescue => e
524
+ gui.log_crash(e, context: 'set_frame_size')
525
+ end
526
+
527
+ # NSTextInputClient protocol closures for IME support
528
+ @insert_text_closure = Fiddle::Closure::BlockCaller.new(
529
+ Fiddle::TYPE_VOID,
530
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG]
531
+ ) do |_self, _cmd, text, _rep_loc, _rep_len|
532
+ gui.activate_for_view(_self); gui.ime_insert_text(text)
533
+ rescue => e
534
+ gui.log_crash(e, context: 'insert_text')
535
+ end
536
+
537
+ @insert_text_simple_closure = Fiddle::Closure::BlockCaller.new(
538
+ Fiddle::TYPE_VOID,
539
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
540
+ ) do |_self, _cmd, text|
541
+ gui.activate_for_view(_self); gui.ime_insert_text(text)
542
+ rescue => e
543
+ gui.log_crash(e, context: 'insert_text_simple')
544
+ end
545
+
546
+ @do_command_closure = Fiddle::Closure::BlockCaller.new(
547
+ Fiddle::TYPE_VOID,
548
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
549
+ ) do |_self, _cmd, _selector|
550
+ gui.activate_for_view(_self); gui.ime_do_command
551
+ rescue => e
552
+ gui.log_crash(e, context: 'do_command')
553
+ end
554
+
555
+ @set_marked_text_closure = Fiddle::Closure::BlockCaller.new(
556
+ Fiddle::TYPE_VOID,
557
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP,
558
+ Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG]
559
+ ) do |_self, _cmd, text, sel_loc, sel_len, _rep_loc, _rep_len|
560
+ gui.activate_for_view(_self); gui.ime_set_marked_text(text, sel_loc, sel_len)
561
+ rescue => e
562
+ gui.log_crash(e, context: 'set_marked_text')
563
+ end
564
+
565
+ @unmark_text_closure = Fiddle::Closure::BlockCaller.new(
566
+ Fiddle::TYPE_VOID,
567
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
568
+ ) do |_self, _cmd|
569
+ gui.activate_for_view(_self); gui.ime_unmark_text
570
+ rescue => e
571
+ gui.log_crash(e, context: 'unmark_text')
572
+ end
573
+
574
+ @has_marked_text_closure = Fiddle::Closure::BlockCaller.new(
575
+ Fiddle::TYPE_INT,
576
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
577
+ ) { |_self, _cmd| gui.ime_has_marked_text }
578
+
579
+ @marked_range_closure = Fiddle::Closure::BlockCaller.new(
580
+ Fiddle::TYPE_LONG,
581
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
582
+ ) { |_self, _cmd| gui.ime_marked_range_location }
583
+
584
+ @selected_range_closure = Fiddle::Closure::BlockCaller.new(
585
+ Fiddle::TYPE_LONG,
586
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
587
+ ) { |_self, _cmd| 0x7FFFFFFFFFFFFFFF } # NSNotFound
588
+
589
+ @valid_attrs_closure = Fiddle::Closure::BlockCaller.new(
590
+ Fiddle::TYPE_VOIDP,
591
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
592
+ ) { |_self, _cmd| ObjC::MSG_PTR.call(ObjC.cls('NSArray'), ObjC.sel('array')) }
593
+
594
+ @attr_substring_closure = Fiddle::Closure::BlockCaller.new(
595
+ Fiddle::TYPE_VOIDP,
596
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP]
597
+ ) { |_self, _cmd, _loc, _len, _actual| Fiddle::Pointer.new(0) }
598
+
599
+ @first_rect_closure = Fiddle::Closure::BlockCaller.new(
600
+ Fiddle::TYPE_DOUBLE,
601
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP]
602
+ ) { |_self, _cmd, _loc, _len, _actual| 0.0 }
603
+
604
+ @char_index_closure = Fiddle::Closure::BlockCaller.new(
605
+ Fiddle::TYPE_LONG,
606
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_DOUBLE, Fiddle::TYPE_DOUBLE]
607
+ ) { |_self, _cmd, _x, _y| 0x7FFFFFFFFFFFFFFF } # NSNotFound
608
+
609
+ menu_action = proc { |action_block|
610
+ Fiddle::Closure::BlockCaller.new(
611
+ Fiddle::TYPE_VOID,
612
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
613
+ ) do |_self, _cmd, _sender|
614
+ gui.activate_for_view(_self); action_block.call
615
+ rescue => e
616
+ gui.log_crash(e, context: 'menu_action')
617
+ end
618
+ }
619
+
620
+ @show_about_closure = menu_action.call(-> { show_about_panel })
621
+ @new_window_closure = menu_action.call(-> { open_new_window })
622
+ @new_tab_closure = menu_action.call(-> {
623
+ create_tab
624
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
625
+ })
626
+ @edit_file_closure = menu_action.call(-> {
627
+ path = prompt_for_file_to_edit
628
+ if path
629
+ create_tab(editor_file: path)
630
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
631
+ end
632
+ })
633
+ @close_tab_closure = menu_action.call(-> {
634
+ close_tab(@active_tab)
635
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
636
+ })
637
+ @copy_closure = menu_action.call(-> { copy_to_clipboard })
638
+ @paste_closure = menu_action.call(-> { paste_from_clipboard })
639
+ @select_all_closure = menu_action.call(-> { select_all })
640
+ @increase_font_closure = menu_action.call(-> { update_font(@font_size + 1.0) })
641
+ @decrease_font_closure = menu_action.call(-> { update_font(@font_size - 1.0) if @font_size > 4.0 })
642
+ @reset_font_closure = menu_action.call(-> {
643
+ Preferences.delete(:font_size)
644
+ update_font(Echoes.config.font_size, persist: false)
645
+ })
646
+ @toggle_find_closure = menu_action.call(-> {
647
+ toggle_search
648
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
649
+ })
650
+ @find_next_closure = menu_action.call(-> {
651
+ if @search_mode && !@search_matches.empty?
652
+ search_next
653
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
654
+ end
655
+ })
656
+ @find_prev_closure = menu_action.call(-> {
657
+ if @search_mode && !@search_matches.empty?
658
+ search_prev
659
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
660
+ end
661
+ })
662
+ @prev_tab_closure = menu_action.call(-> {
663
+ @active_tab = (@active_tab - 1) % @tabs.size
664
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
665
+ })
666
+ @next_tab_closure = menu_action.call(-> {
667
+ @active_tab = (@active_tab + 1) % @tabs.size
668
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
669
+ })
670
+ @split_right_closure = menu_action.call(-> {
671
+ tab = current_tab
672
+ new_pane = tab.split_vertical
673
+ wire_screen_handlers(new_pane.screen)
674
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
675
+ })
676
+ @split_down_closure = menu_action.call(-> {
677
+ tab = current_tab
678
+ new_pane = tab.split_horizontal
679
+ wire_screen_handlers(new_pane.screen)
680
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
681
+ })
682
+ @close_pane_closure = menu_action.call(-> {
683
+ tab = current_tab
684
+ if tab.pane_tree.single_pane?
685
+ close_tab(@active_tab)
686
+ else
687
+ tab.close_active_pane
688
+ end
689
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
690
+ })
691
+ @select_next_pane_closure = menu_action.call(-> {
692
+ current_tab.next_pane
693
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
694
+ })
695
+ @select_prev_pane_closure = menu_action.call(-> {
696
+ current_tab.prev_pane
697
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
698
+ })
699
+ # NSMenu items dispatched from the tab-completion popup. Unlike the
700
+ # main-menu actions, the completion picker needs the sender so we
701
+ # can read its tag (= candidate index); menu_action's no-arg
702
+ # convention isn't a fit, so wire it manually.
703
+ @completion_picked_closure = Fiddle::Closure::BlockCaller.new(
704
+ Fiddle::TYPE_VOID,
705
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
706
+ ) do |_self, _cmd, sender|
707
+ gui.activate_for_view(_self); gui.completion_picked(sender)
708
+ rescue => e
709
+ gui.log_crash(e, context: 'completionPicked')
710
+ end
711
+
712
+ @toggle_pointer_closure = menu_action.call(-> {
713
+ # NSCursor's hide/unhide are reference-counted; track state so
714
+ # repeated invocations toggle cleanly. We don't use
715
+ # `setHiddenUntilMouseMoves:` because the user wants explicit
716
+ # control (mouse-move shouldn't undo a deliberate hide).
717
+ if @pointer_hidden
718
+ ObjC::MSG_VOID.call(ObjC.cls('NSCursor'), ObjC.sel('unhide'))
719
+ @pointer_hidden = false
720
+ else
721
+ ObjC::MSG_VOID.call(ObjC.cls('NSCursor'), ObjC.sel('hide'))
722
+ @pointer_hidden = true
723
+ end
724
+ })
725
+
726
+ @toggle_copy_mode_closure = menu_action.call(-> {
727
+ pane = current_tab.active_pane
728
+ if pane.copy_mode&.active
729
+ pane.copy_mode.exit
730
+ pane.copy_mode = nil
731
+ else
732
+ pane.copy_mode = CopyMode.new(pane.screen)
733
+ pane.copy_mode.enter
734
+ end
735
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
736
+ })
737
+
738
+ @dragging_entered_closure = Fiddle::Closure::BlockCaller.new(
739
+ Fiddle::TYPE_LONG,
740
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
741
+ ) { |_self, _cmd, _sender| 1 } # NSDragOperationCopy
742
+
743
+ @perform_drag_closure = Fiddle::Closure::BlockCaller.new(
744
+ Fiddle::TYPE_INT,
745
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
746
+ ) do |_self, _cmd, sender|
747
+ gui.activate_for_view(_self); gui.perform_drag_operation(sender) ? 1 : 0
748
+ rescue => e
749
+ gui.log_crash(e, context: 'performDragOperation')
750
+ 0
751
+ end
752
+
753
+ @focus_gained_closure = Fiddle::Closure::BlockCaller.new(
754
+ Fiddle::TYPE_VOID,
755
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
756
+ ) { |_self, _cmd, _notification| gui.activate_for_view(_self); gui.window_focus_changed(true) }
757
+
758
+ @focus_lost_closure = Fiddle::Closure::BlockCaller.new(
759
+ Fiddle::TYPE_VOID,
760
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
761
+ ) { |_self, _cmd, _notification| gui.activate_for_view(_self); gui.window_focus_changed(false) }
762
+
763
+ @view_class = ObjC.define_class('EchoesTerminalView', 'NSView', {
764
+ 'drawRect:' => ['v@:{CGRect=dddd}', @draw_rect_closure],
765
+ 'keyDown:' => ['v@:@', @key_down_closure],
766
+ 'acceptsFirstResponder' => ['c@:', @accepts_fr_closure],
767
+ 'timerFired:' => ['v@:@', @timer_fired_closure],
768
+ 'isFlipped' => ['c@:', @is_flipped_closure],
769
+ 'resetCursorRects' => ['v@:', @reset_cursor_rects_closure],
770
+ 'scrollWheel:' => ['v@:@', @scroll_wheel_closure],
771
+ 'mouseDown:' => ['v@:@', @mouse_down_closure],
772
+ 'mouseDragged:' => ['v@:@', @mouse_dragged_closure],
773
+ 'mouseMoved:' => ['v@:@', @mouse_moved_closure],
774
+ 'mouseUp:' => ['v@:@', @mouse_up_closure],
775
+ 'rightMouseDown:' => ['v@:@', @right_mouse_down_closure],
776
+ 'rightMouseDragged:' => ['v@:@', @right_mouse_dragged_closure],
777
+ 'rightMouseUp:' => ['v@:@', @right_mouse_up_closure],
778
+ 'otherMouseDown:' => ['v@:@', @other_mouse_down_closure],
779
+ 'otherMouseDragged:' => ['v@:@', @other_mouse_dragged_closure],
780
+ 'otherMouseUp:' => ['v@:@', @other_mouse_up_closure],
781
+ 'performKeyEquivalent:' => ['c@:@', @perform_key_equiv_closure],
782
+ 'setFrameSize:' => ['v@:{CGSize=dd}', @set_frame_size_closure],
783
+ 'windowDidBecomeKey:' => ['v@:@', @focus_gained_closure],
784
+ 'windowDidResignKey:' => ['v@:@', @focus_lost_closure],
785
+ 'showAbout:' => ['v@:@', @show_about_closure],
786
+ 'newWindow:' => ['v@:@', @new_window_closure],
787
+ 'newTab:' => ['v@:@', @new_tab_closure],
788
+ 'editFile:' => ['v@:@', @edit_file_closure],
789
+ 'closeTab:' => ['v@:@', @close_tab_closure],
790
+ 'copy:' => ['v@:@', @copy_closure],
791
+ 'paste:' => ['v@:@', @paste_closure],
792
+ 'selectAll:' => ['v@:@', @select_all_closure],
793
+ 'increaseFontSize:' => ['v@:@', @increase_font_closure],
794
+ 'decreaseFontSize:' => ['v@:@', @decrease_font_closure],
795
+ 'resetFontSize:' => ['v@:@', @reset_font_closure],
796
+ 'toggleFind:' => ['v@:@', @toggle_find_closure],
797
+ 'findNext:' => ['v@:@', @find_next_closure],
798
+ 'findPrevious:' => ['v@:@', @find_prev_closure],
799
+ 'showPreviousTab:' => ['v@:@', @prev_tab_closure],
800
+ 'showNextTab:' => ['v@:@', @next_tab_closure],
801
+ 'splitRight:' => ['v@:@', @split_right_closure],
802
+ 'splitDown:' => ['v@:@', @split_down_closure],
803
+ 'closePane:' => ['v@:@', @close_pane_closure],
804
+ 'selectNextPane:' => ['v@:@', @select_next_pane_closure],
805
+ 'selectPreviousPane:' => ['v@:@', @select_prev_pane_closure],
806
+ 'toggleCopyMode:' => ['v@:@', @toggle_copy_mode_closure],
807
+ 'togglePointer:' => ['v@:@', @toggle_pointer_closure],
808
+ 'completionPicked:' => ['v@:@', @completion_picked_closure],
809
+ # NSTextInputClient protocol methods for IME
810
+ 'insertText:replacementRange:' => ['v@:@{_NSRange=QQ}', @insert_text_closure],
811
+ 'insertText:' => ['v@:@', @insert_text_simple_closure],
812
+ 'doCommandBySelector:' => ['v@::', @do_command_closure],
813
+ 'setMarkedText:selectedRange:replacementRange:' => ['v@:@{_NSRange=QQ}{_NSRange=QQ}', @set_marked_text_closure],
814
+ 'unmarkText' => ['v@:', @unmark_text_closure],
815
+ 'hasMarkedText' => ['c@:', @has_marked_text_closure],
816
+ 'markedRange' => ['{_NSRange=QQ}@:', @marked_range_closure],
817
+ 'selectedRange' => ['{_NSRange=QQ}@:', @selected_range_closure],
818
+ 'validAttributesForMarkedText' => ['@@:', @valid_attrs_closure],
819
+ 'attributedSubstringForProposedRange:actualRange:' => ['@@:{_NSRange=QQ}^{_NSRange=QQ}', @attr_substring_closure],
820
+ 'firstRectForCharacterRange:actualRange:' => ['{CGRect={CGPoint=dd}{CGSize=dd}}@:{_NSRange=QQ}^{_NSRange=QQ}', @first_rect_closure],
821
+ 'characterIndexForPoint:' => ['Q@:{CGPoint=dd}', @char_index_closure],
822
+ 'draggingEntered:' => ['Q@:@', @dragging_entered_closure],
823
+ 'performDragOperation:' => ['c@:@', @perform_drag_closure],
824
+ })
825
+
826
+ # Add NSTextInputClient protocol conformance for IME
827
+ protocol = ObjC::GetProtocol.call('NSTextInputClient')
828
+ ObjC::AddProtocol.call(@view_class, protocol) unless protocol.null?
829
+ end
830
+
831
+ def setup_timer
832
+ @timer_view_id = @view.to_i
833
+ @timer = ObjC::MSG_PTR_D_P_P_P_I.call(
834
+ ObjC.cls('NSTimer'),
835
+ ObjC.sel('scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:'),
836
+ 1.0 / 60.0,
837
+ @view,
838
+ ObjC.sel('timerFired:'),
839
+ Fiddle::Pointer.new(0),
840
+ 1
841
+ )
842
+ end
843
+
844
+ def start_app
845
+ ObjC::MSG_VOID.call(@app, ObjC.sel('run'))
846
+ end
847
+
848
+ # --- Callbacks ---
849
+
850
+ def draw_rect(dirty_min_y = 0.0, dirty_max_y = Float::INFINITY)
851
+ # Autorelease pool to prevent temporary object accumulation
852
+ pool = ObjC::MSG_PTR.call(ObjC.cls('NSAutoreleasePool'), ObjC.sel('alloc'))
853
+ pool = ObjC::MSG_PTR.call(pool, ObjC.sel('init'))
854
+
855
+ tab = current_tab
856
+ unless tab
857
+ ObjC::MSG_VOID.call(pool, ObjC.sel('drain'))
858
+ return
859
+ end
860
+ tbh = tab_bar_height
861
+ gy_off = grid_y_offset
862
+
863
+ # Fill dirty region background
864
+ ObjC::MSG_VOID.call(@default_bg, ObjC.sel('setFill'))
865
+ ObjC::NSRectFill.call(0.0, dirty_min_y, @cell_width * (@cols + 1), dirty_max_y - dirty_min_y)
866
+
867
+ # Draw tab bar if it intersects the dirty region
868
+ if tbh > 0
869
+ tby = tab_bar_y
870
+ if dirty_min_y < tby + tbh && dirty_max_y > tby
871
+ draw_tab_bar(tbh, tby)
872
+ end
873
+ end
874
+
875
+ # Draw all panes
876
+ pane_rects = tab.pane_tree.layout(0, 0, @cols, @rows)
877
+ pane_rects.each do |rect|
878
+ pane = rect[:pane]
879
+ px = rect[:x] * @cell_width
880
+ py = gy_off + rect[:y] * @cell_height
881
+ is_active = (pane == tab.active_pane)
882
+
883
+ draw_pane_content(pane, px, py, dirty_min_y, dirty_max_y, is_active)
884
+ end
885
+
886
+ # Draw pane dividers and active pane border
887
+ if !tab.pane_tree.single_pane?
888
+ draw_pane_dividers(pane_rects, gy_off)
889
+ draw_active_pane_border(tab, pane_rects, gy_off)
890
+ end
891
+
892
+ # Visual bell flash
893
+ if @bell_flash > 0
894
+ flash_color = make_color_with_alpha(make_color(1.0, 1.0, 1.0), 0.15)
895
+ ObjC::MSG_VOID.call(flash_color, ObjC.sel('setFill'))
896
+ ObjC::NSRectFill.call(0.0, gy_off, @cols * @cell_width, @rows * @cell_height)
897
+ end
898
+
899
+ # Draw search bar
900
+ if @search_mode
901
+ bar_h = @cell_height + 4.0
902
+ bar_y = gy_off + @rows * @cell_height
903
+ bar_bg = make_color(0.2, 0.2, 0.2)
904
+ ObjC::MSG_VOID.call(bar_bg, ObjC.sel('setFill'))
905
+ ObjC::NSRectFill.call(0.0, bar_y, @cols * @cell_width, bar_h)
906
+
907
+ match_info = @search_matches.empty? ? "" : " [#{@search_index + 1}/#{@search_matches.size}]"
908
+ label = "Find: #{@search_query}_#{match_info}"
909
+ ns_str = ObjC.nsstring(label)
910
+ ns_attrs = ObjC.nsdict({
911
+ ObjC::NSFontAttributeName => @font,
912
+ ObjC::NSForegroundColorAttributeName => make_color(1.0, 1.0, 1.0),
913
+ })
914
+ ObjC::MSG_VOID_PT_1.call(ns_str, ObjC.sel('drawAtPoint:withAttributes:'), 4.0, bar_y + 2.0, ns_attrs)
915
+ end
916
+
917
+ ObjC::MSG_VOID.call(pool, ObjC.sel('drain'))
918
+ end
919
+
920
+ def draw_pane_content(pane, px, py, dirty_min_y, dirty_max_y, is_active)
921
+ screen = pane.screen
922
+ scrollback = screen.scrollback
923
+ visible_start = scrollback.size - pane.scroll_offset
924
+ pane_rows = screen.rows
925
+ pane_cols = screen.cols
926
+
927
+ copy_mode = pane.copy_mode
928
+
929
+ # Per-pane gradient background (set via OSC 7772 ;bg-gradient).
930
+ # Drawn before any cells so cell-level bg colors paint on top.
931
+ draw_pane_background(screen.background, px, py, pane_cols, pane_rows) if screen.background
932
+ draw_pane_fills(screen.bg_fills, px, py, pane_cols, pane_rows) if screen.bg_fills && !screen.bg_fills.empty?
933
+
934
+ pane_rows.times do |r|
935
+ y = py + r * @cell_height
936
+ next if y + @cell_height < dirty_min_y || y > dirty_max_y
937
+ src = visible_start + r
938
+ row = if src < scrollback.size
939
+ scrollback[src]
940
+ elsif src - scrollback.size < screen.grid.size
941
+ screen.grid[src - scrollback.size]
942
+ end
943
+ next unless row
944
+
945
+ row.each_with_index do |cell, c|
946
+ next if cell.width == 0
947
+ next if cell.multicell == :cont
948
+
949
+ fg_val = cell.fg
950
+ bg_val = cell.bg
951
+ default_fg = @default_fg
952
+ default_bg = @default_bg
953
+ if cell.inverse
954
+ fg_val, bg_val = bg_val, fg_val
955
+ default_fg, default_bg = default_bg, default_fg
956
+ end
957
+
958
+ fg_color = resolve_color(fg_val, default_fg)
959
+ bg_color = resolve_color(bg_val, default_bg)
960
+
961
+ if cell.bold && fg_val.is_a?(Integer) && fg_val < 8
962
+ fg_color = @colors[fg_val + 8]
963
+ end
964
+
965
+ has_bg = !bg_val.nil? || cell.inverse
966
+
967
+ selected = is_active && cell_selected?(src, c)
968
+ is_match = is_active && @search_mode && search_match_at?(src, c)
969
+ is_current_match = is_active && @search_mode && current_search_match_at?(src, c)
970
+
971
+ # Copy mode selection highlight
972
+ if copy_mode&.active && copy_mode.selecting?
973
+ sel_start, sel_end = [copy_mode.selection_start, copy_mode.selection_end].sort_by { |p| [p[0], p[1]] }
974
+ cm_abs_row = scrollback.size + r - pane.scroll_offset
975
+ if cm_abs_row >= scrollback.size + sel_start[0] && cm_abs_row <= scrollback.size + sel_end[0]
976
+ cm_row = cm_abs_row - scrollback.size
977
+ if cm_row == sel_start[0] && cm_row == sel_end[0]
978
+ selected = c >= sel_start[1] && c <= sel_end[1]
979
+ elsif cm_row == sel_start[0]
980
+ selected = c >= sel_start[1]
981
+ elsif cm_row == sel_end[0]
982
+ selected = c <= sel_end[1]
983
+ else
984
+ selected = true
985
+ end
986
+ end
987
+ end
988
+
989
+ if cell.multicell.is_a?(Hash)
990
+ mc = cell.multicell
991
+ x = px + c * @cell_width
992
+ block_w = mc[:cols] * @cell_width
993
+ block_h = mc[:rows] * @cell_height
994
+
995
+ if selected
996
+ ObjC::MSG_VOID.call(@selection_color, ObjC.sel('setFill'))
997
+ ObjC::NSRectFill.call(x, y, block_w, block_h)
998
+ elsif has_bg
999
+ ObjC::MSG_VOID.call(bg_color, ObjC.sel('setFill'))
1000
+ ObjC::NSRectFill.call(x, y, block_w, block_h)
1001
+ end
1002
+
1003
+ if mc[:sixel]
1004
+ draw_sixel_image(mc[:sixel], x, y, block_w, block_h)
1005
+ next
1006
+ end
1007
+
1008
+ next if cell.char == " " && !has_bg
1009
+
1010
+ # Per OSC 66 spec, n=/d= define a fraction *of* the scale.
1011
+ # Effective glyph size is `s × n/d`. The reserved block
1012
+ # stays at the full s×s*width — n=/d= shrink (or grow) the
1013
+ # glyph within that block, paired with v=/h= for placement.
1014
+ # Example: `s=2:n=1:d=2:v=2;●` reserves 2×2 cells and draws
1015
+ # a 1-cell ● centered vertically inside it.
1016
+ effective_scale = mc[:scale].to_f
1017
+ if mc[:frac_d] > 0 && mc[:frac_n] > 0
1018
+ effective_scale *= mc[:frac_n].to_f / mc[:frac_d]
1019
+ end
1020
+ scaled_font = ObjC.retain(create_nsfont(@font_size * effective_scale, family: mc[:family]))
1021
+ if cell.bold
1022
+ regular = scaled_font
1023
+ scaled_font = ObjC.retain(create_bold_nsfont(regular))
1024
+ ObjC.release(regular)
1025
+ end
1026
+
1027
+ draw_attrs = {
1028
+ ObjC::NSFontAttributeName => scaled_font,
1029
+ ObjC::NSForegroundColorAttributeName => fg_color,
1030
+ }
1031
+ if cell.underline
1032
+ draw_attrs[ObjC::NSUnderlineStyleAttributeName] = ObjC.nsnumber_int(1)
1033
+ end
1034
+ if cell.strikethrough
1035
+ draw_attrs[ObjC::NSStrikethroughStyleAttributeName] = ObjC.nsnumber_int(1)
1036
+ end
1037
+ ns_attrs = ObjC.nsdict(draw_attrs)
1038
+ ns_char = cached_nsstring(cell.char)
1039
+
1040
+ text_w = ObjC::MSG_RET_D_1.call(ns_char, ObjC.sel('sizeWithAttributes:'), ns_attrs)
1041
+
1042
+ draw_x = case mc[:halign]
1043
+ when 1 then x + block_w - text_w
1044
+ when 2 then x + (block_w - text_w) / 2.0
1045
+ else x
1046
+ end
1047
+
1048
+ scaled_ascender = ObjC::MSG_RET_D.call(scaled_font, ObjC.sel('ascender'))
1049
+ scaled_descender = ObjC::MSG_RET_D.call(scaled_font, ObjC.sel('descender'))
1050
+ scaled_leading = ObjC::MSG_RET_D.call(scaled_font, ObjC.sel('leading'))
1051
+ text_h = scaled_ascender - scaled_descender + scaled_leading
1052
+
1053
+ draw_y = case mc[:valign]
1054
+ when 1 then y + block_h - text_h
1055
+ when 2 then y + (block_h - text_h) / 2.0
1056
+ else y
1057
+ end
1058
+
1059
+ ObjC::MSG_VOID_PT_1.call(ns_char, ObjC.sel('drawAtPoint:withAttributes:'), draw_x, draw_y, ns_attrs)
1060
+ ObjC.release(scaled_font)
1061
+ else
1062
+ x = px + c * @cell_width
1063
+ cell_w = cell.width == 2 ? @cell_width * 2 : @cell_width
1064
+
1065
+ if is_current_match
1066
+ ObjC::MSG_VOID.call(@search_current_color, ObjC.sel('setFill'))
1067
+ ObjC::NSRectFill.call(x, y, cell_w, @cell_height)
1068
+ elsif is_match
1069
+ ObjC::MSG_VOID.call(@search_match_color, ObjC.sel('setFill'))
1070
+ ObjC::NSRectFill.call(x, y, cell_w, @cell_height)
1071
+ elsif selected
1072
+ ObjC::MSG_VOID.call(@selection_color, ObjC.sel('setFill'))
1073
+ ObjC::NSRectFill.call(x, y, cell_w, @cell_height)
1074
+ elsif has_bg
1075
+ ObjC::MSG_VOID.call(bg_color, ObjC.sel('setFill'))
1076
+ ObjC::NSRectFill.call(x, y, cell_w, @cell_height)
1077
+ end
1078
+
1079
+ next if cell.char == " " && !has_bg && !selected && !is_match
1080
+
1081
+ base_font = cell.bold ? @bold_font : font_for_char(cell.char)
1082
+ if cell.italic
1083
+ base_font = create_italic_nsfont(base_font)
1084
+ end
1085
+ if cell.concealed || (cell.blink && !@cursor_blink_on)
1086
+ fg_color = bg_color
1087
+ elsif cell.faint
1088
+ fg_color = make_color_with_alpha(fg_color, 0.5)
1089
+ 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)
1099
+ 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)
1104
+ end
1105
+ end
1106
+ end
1107
+
1108
+ # Draw cursor or copy mode cursor
1109
+ if copy_mode&.active
1110
+ # Copy mode cursor (inverse block)
1111
+ cm_row = copy_mode.cursor_row
1112
+ if cm_row >= 0 && cm_row < pane_rows
1113
+ cx = px + copy_mode.cursor_col * @cell_width
1114
+ cy = py + cm_row * @cell_height
1115
+ ObjC::MSG_VOID.call(@copy_mode_cursor_color, ObjC.sel('setFill'))
1116
+ ObjC::NSRectFill.call(cx, cy, @cell_width, @cell_height)
1117
+ end
1118
+ elsif pane.scroll_offset == 0 && screen.cursor.visible
1119
+ style = screen.cursor_style
1120
+ blink = style.odd? || style == 0
1121
+ cx = px + screen.cursor.col * @cell_width
1122
+ cy = py + screen.cursor.row * @cell_height
1123
+
1124
+ if @window_focused
1125
+ # Active window: filled cursor (blinking if requested)
1126
+ 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)
1128
+ ObjC::MSG_VOID.call(cursor_color, ObjC.sel('setFill'))
1129
+ case style
1130
+ when 3, 4 # underline
1131
+ ObjC::NSRectFill.call(cx, cy + @cell_height - 2.0, @cell_width, 2.0)
1132
+ when 5, 6 # bar
1133
+ ObjC::NSRectFill.call(cx, cy, 2.0, @cell_height)
1134
+ else # block (0, 1, 2)
1135
+ ObjC::NSRectFill.call(cx, cy, @cell_width, @cell_height)
1136
+ # Draw character under cursor with inverted colors
1137
+ if screen.cursor.row < pane_rows && screen.cursor.col < pane_cols
1138
+ cell = screen.grid[screen.cursor.row][screen.cursor.col]
1139
+ if cell.char != ' '
1140
+ inv_fg = @default_bg
1141
+ cursor_font = cell.bold ? @bold_font : font_for_char(cell.char)
1142
+ ns_attrs = ObjC.nsdict({
1143
+ ObjC::NSFontAttributeName => cursor_font,
1144
+ ObjC::NSForegroundColorAttributeName => inv_fg,
1145
+ })
1146
+ ns_char = cached_nsstring(cell.char)
1147
+ dy = cy + y_offset_for_font(cursor_font)
1148
+ ObjC::MSG_VOID_PT_1.call(ns_char, ObjC.sel('drawAtPoint:withAttributes:'), cx, dy, ns_attrs)
1149
+ end
1150
+ end
1151
+ end
1152
+ end
1153
+ else
1154
+ # Inactive window: hollow square outline (no blinking)
1155
+ ObjC::MSG_VOID.call(make_color(*Echoes.config.cursor_color), ObjC.sel('setFill'))
1156
+ ObjC::NSRectFill.call(cx, cy, @cell_width, 1.0) # top
1157
+ ObjC::NSRectFill.call(cx, cy + @cell_height - 1.0, @cell_width, 1.0) # bottom
1158
+ ObjC::NSRectFill.call(cx, cy, 1.0, @cell_height) # left
1159
+ ObjC::NSRectFill.call(cx + @cell_width - 1.0, cy, 1.0, @cell_height) # right
1160
+ end
1161
+ end
1162
+
1163
+ # Draw marked text (IME composition) at cursor position (active pane only)
1164
+ if is_active && @marked_text && pane.scroll_offset == 0
1165
+ mx = px + screen.cursor.col * @cell_width
1166
+ my = py + screen.cursor.row * @cell_height
1167
+ marked_width = @marked_text.each_char.sum { |c| c.ord > 0x7F ? @cell_width * 2 : @cell_width }
1168
+
1169
+ ime_bg = make_color(0.2, 0.2, 0.35)
1170
+ ObjC::MSG_VOID.call(ime_bg, ObjC.sel('setFill'))
1171
+ ObjC::NSRectFill.call(mx, my, marked_width, @cell_height)
1172
+
1173
+ ns_str = ObjC.nsstring(@marked_text)
1174
+ ns_attrs = ObjC.nsdict({
1175
+ ObjC::NSFontAttributeName => @font,
1176
+ ObjC::NSForegroundColorAttributeName => make_color(1.0, 1.0, 1.0),
1177
+ ObjC::NSUnderlineStyleAttributeName => ObjC.nsnumber_int(1),
1178
+ })
1179
+ ObjC::MSG_VOID_PT_1.call(ns_str, ObjC.sel('drawAtPoint:withAttributes:'), mx, my, ns_attrs)
1180
+ end
1181
+ end
1182
+
1183
+ def draw_pane_dividers(pane_rects, gy_off)
1184
+ return if pane_rects.size <= 1
1185
+
1186
+ ObjC::MSG_VOID.call(@pane_divider_color, ObjC.sel('setFill'))
1187
+
1188
+ pane_rects.each do |rect|
1189
+ px = rect[:x] * @cell_width
1190
+ py = gy_off + rect[:y] * @cell_height
1191
+ pw = rect[:w] * @cell_width
1192
+ ph = rect[:h] * @cell_height
1193
+
1194
+ # Draw right edge divider (if not at the far right)
1195
+ if rect[:x] + rect[:w] < @cols
1196
+ ObjC::NSRectFill.call(px + pw - 0.5, py, 1.0, ph)
1197
+ end
1198
+
1199
+ # Draw bottom edge divider (if not at the very bottom)
1200
+ if rect[:y] + rect[:h] < @rows
1201
+ ObjC::NSRectFill.call(px, py + ph - 0.5, pw, 1.0)
1202
+ end
1203
+ end
1204
+ end
1205
+
1206
+ def draw_active_pane_border(tab, pane_rects, gy_off)
1207
+ active_rect = pane_rects.find { |r| r[:pane] == tab.active_pane }
1208
+ return unless active_rect
1209
+
1210
+ ObjC::MSG_VOID.call(@active_pane_border_color, ObjC.sel('setFill'))
1211
+
1212
+ px = active_rect[:x] * @cell_width
1213
+ py = gy_off + active_rect[:y] * @cell_height
1214
+ pw = active_rect[:w] * @cell_width
1215
+ ph = active_rect[:h] * @cell_height
1216
+
1217
+ # Top border
1218
+ ObjC::NSRectFill.call(px, py, pw, 2.0)
1219
+ # Bottom border
1220
+ ObjC::NSRectFill.call(px, py + ph - 2.0, pw, 2.0)
1221
+ # Left border
1222
+ ObjC::NSRectFill.call(px, py, 2.0, ph)
1223
+ # Right border
1224
+ ObjC::NSRectFill.call(px + pw - 2.0, py, 2.0, ph)
1225
+ end
1226
+
1227
+ def perform_key_equivalent(event_ptr)
1228
+ 0
1229
+ end
1230
+
1231
+ def key_down(event_ptr)
1232
+ if @search_mode
1233
+ search_key_down(event_ptr)
1234
+ return
1235
+ end
1236
+
1237
+ tab = current_tab
1238
+ return unless tab
1239
+ pane = tab.active_pane
1240
+ return unless pane
1241
+
1242
+ # Copy mode intercepts all keys
1243
+ if pane.copy_mode&.active
1244
+ copy_mode_key_down(event_ptr, pane)
1245
+ return
1246
+ end
1247
+
1248
+ @selection_anchor = nil
1249
+ @selection_end = nil
1250
+
1251
+ flags = ObjC::MSG_RET_L.call(event_ptr, ObjC.sel('modifierFlags'))
1252
+ chars_ns = ObjC::MSG_PTR.call(event_ptr, ObjC.sel('charactersIgnoringModifiers'))
1253
+ chars = ObjC.to_ruby_string(chars_ns)
1254
+ return if chars.empty?
1255
+
1256
+ # Snap-to-bottom on most key events. Exception: Cmd+Shift+Up/Down
1257
+ # is the OSC 133 jump-to-prompt nav, which sets @scroll_offset
1258
+ # itself — clobbering it here would defeat repeated presses.
1259
+ is_prompt_nav = (flags & (ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift)) ==
1260
+ (ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift) &&
1261
+ (chars == "\u{F700}" || chars == "\u{F701}")
1262
+ unless is_prompt_nav
1263
+ pane.scroll_offset = 0
1264
+ pane.scroll_accum = 0.0
1265
+ end
1266
+
1267
+ # Viewer panes (rvim-backed) consume keys directly via
1268
+ # `pane.handle_key`, not via a pty. Without this branch the
1269
+ # legacy PTY path below tries to write keystrokes to a
1270
+ # non-existent pty fd, so vim never sees the input.
1271
+ if pane.editor?
1272
+ pane.handle_key(chars: chars, flags: flags)
1273
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
1274
+ return
1275
+ end
1276
+
1277
+ # Embedded-shell panes don't speak the byte-stream / escape-code
1278
+ # protocol; they have an in-Echoes line editor that submits
1279
+ # finished lines to a Rubish::REPL via direct method calls.
1280
+ if pane.embedded?
1281
+ # Tab with multiple completion candidates: show a native NSMenu
1282
+ # popup at the cursor cell instead of letting Pane print the
1283
+ # candidates inline. Single-candidate completion stays inline.
1284
+ if chars == "\t" && !pane.embedded_shell.running?
1285
+ req = pane.completion_request
1286
+ if req && req[:candidates].size > 1
1287
+ show_completion_popup(pane, req)
1288
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
1289
+ return
1290
+ end
1291
+ end
1292
+ pane.handle_key(chars: chars, flags: flags)
1293
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
1294
+ return
1295
+ end
1296
+
1297
+ mod = modifier_param(flags)
1298
+
1299
+ if chars == "\u{19}" # NSBackTabCharacter (Shift+Tab on macOS)
1300
+ pane.write_input("\e[Z")
1301
+ elsif mod > 1 && (seq = map_modified_key(chars, mod))
1302
+ pane.write_input(seq)
1303
+ elsif (flags & ObjC::NSEventModifierFlagControl) != 0
1304
+ ctrl_char = (chars[0].ord & 0x1F).chr
1305
+ pane.write_input(ctrl_char)
1306
+ elsif (flags & ObjC::NSEventModifierFlagOption) != 0
1307
+ pane.write_input("\e#{chars}")
1308
+ else
1309
+ # Route through input method for IME support
1310
+ @current_event = event_ptr
1311
+ arr = ObjC::MSG_PTR_1.call(ObjC.cls('NSArray'), ObjC.sel('arrayWithObject:'), event_ptr)
1312
+ ObjC::MSG_VOID_1.call(@view, ObjC.sel('interpretKeyEvents:'), arr)
1313
+ end
1314
+ rescue Errno::EIO, IOError
1315
+ close_tab(@active_tab)
1316
+ end
1317
+
1318
+ def copy_mode_key_down(event_ptr, pane)
1319
+ chars_ns = ObjC::MSG_PTR.call(event_ptr, ObjC.sel('characters'))
1320
+ chars = ObjC.to_ruby_string(chars_ns)
1321
+ flags = ObjC::MSG_RET_L.call(event_ptr, ObjC.sel('modifierFlags'))
1322
+
1323
+ key = if (flags & ObjC::NSEventModifierFlagControl) != 0
1324
+ (chars[0].ord & 0x1F).chr
1325
+ else
1326
+ chars
1327
+ end
1328
+
1329
+ result = pane.copy_mode.handle_key(key)
1330
+ case result
1331
+ when :exit
1332
+ pane.copy_mode = nil
1333
+ when :yank
1334
+ text = pane.copy_mode.selected_text
1335
+ unless text.empty?
1336
+ pb = ObjC::MSG_PTR.call(ObjC.cls('NSPasteboard'), ObjC.sel('generalPasteboard'))
1337
+ ObjC::MSG_PTR.call(pb, ObjC.sel('clearContents'))
1338
+ ObjC::MSG_PTR_2.call(pb, ObjC.sel('setString:forType:'), ObjC.nsstring(text), ObjC::NSPasteboardTypeString)
1339
+ end
1340
+ pane.copy_mode.exit
1341
+ pane.copy_mode = nil
1342
+ end
1343
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
1344
+ end
1345
+
1346
+ # --- IME (Input Method Editor) callbacks ---
1347
+
1348
+ def ime_insert_text(text_ptr)
1349
+ text = nsstring_from_input(text_ptr)
1350
+ @marked_text = nil
1351
+ return if text.empty?
1352
+
1353
+ tab = current_tab
1354
+ return unless tab
1355
+ pane = tab.active_pane
1356
+ return unless pane
1357
+ pane.write_input(text)
1358
+ rescue Errno::EIO, IOError
1359
+ close_tab(@active_tab)
1360
+ end
1361
+
1362
+ def ime_do_command
1363
+ return unless @current_event
1364
+
1365
+ tab = current_tab
1366
+ return unless tab
1367
+ pane = tab.active_pane
1368
+ return unless pane
1369
+ event_ptr = @current_event
1370
+ flags = ObjC::MSG_RET_L.call(event_ptr, ObjC.sel('modifierFlags'))
1371
+ chars_ns = ObjC::MSG_PTR.call(event_ptr, ObjC.sel('characters'))
1372
+ chars = ObjC.to_ruby_string(chars_ns)
1373
+ chars_ns2 = ObjC::MSG_PTR.call(event_ptr, ObjC.sel('charactersIgnoringModifiers'))
1374
+ chars2 = ObjC.to_ruby_string(chars_ns2)
1375
+
1376
+ numpad = (flags & ObjC::NSEventModifierFlagNumericPad) != 0
1377
+ actual = chars.empty? ? chars2 : chars
1378
+ pane.write_input(map_special_keys(actual, pane.screen.application_cursor_keys?, app_keypad: numpad && pane.screen.application_keypad))
1379
+ rescue Errno::EIO, IOError
1380
+ close_tab(@active_tab)
1381
+ end
1382
+
1383
+ def ime_set_marked_text(text_ptr, _sel_loc, _sel_len)
1384
+ text = nsstring_from_input(text_ptr)
1385
+
1386
+ if text.empty?
1387
+ @marked_text = nil
1388
+ else
1389
+ @marked_text = text
1390
+ end
1391
+
1392
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
1393
+ end
1394
+
1395
+ def ime_unmark_text
1396
+ @marked_text = nil
1397
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
1398
+ end
1399
+
1400
+ def ime_has_marked_text
1401
+ @marked_text ? 1 : 0
1402
+ end
1403
+
1404
+ def ime_marked_range_location
1405
+ @marked_text ? 0 : 0x7FFFFFFFFFFFFFFF # NSNotFound
1406
+ end
1407
+
1408
+ def timer_fired
1409
+ save_window_state
1410
+
1411
+ @cursor_blink_counter += 1
1412
+ blink_toggled = false
1413
+ if @cursor_blink_counter >= 30
1414
+ @cursor_blink_counter = 0
1415
+ @cursor_blink_on = !@cursor_blink_on
1416
+ blink_toggled = true
1417
+ end
1418
+
1419
+ @window_states.each do |ws|
1420
+ load_window_state(ws)
1421
+ timer_fired_for_window(ws, blink_toggled)
1422
+ end
1423
+ end
1424
+
1425
+ private def timer_fired_for_window(ws, blink_toggled)
1426
+ need_redraw = false
1427
+
1428
+ @tabs.each do |tab|
1429
+ tab.panes.each do |pane|
1430
+ loop do
1431
+ data = pane.read_available_output(16384)
1432
+ break if data.empty?
1433
+ pane.process_output(data)
1434
+ need_redraw = true
1435
+ end
1436
+ if need_redraw && pane.screen.title
1437
+ tab.title = pane.screen.title if pane == tab.active_pane
1438
+ pane.screen.title = nil
1439
+ end
1440
+ end
1441
+
1442
+ # Clean up dead panes within the tab
1443
+ dead_panes = tab.panes.reject(&:alive?)
1444
+ dead_panes.each do |dp|
1445
+ next if tab.pane_tree.single_pane?
1446
+ tab.pane_tree.remove(dp)
1447
+ dp.close
1448
+ need_redraw = true
1449
+ end
1450
+ end
1451
+
1452
+ # Clean up dead tabs (all panes dead)
1453
+ dead = @tabs.reject(&:alive?)
1454
+ if dead.any?
1455
+ dead.each { |t| t.close }
1456
+ @tabs -= dead
1457
+ if @tabs.empty?
1458
+ save_window_state
1459
+ close_current_window
1460
+ return
1461
+ end
1462
+ @active_tab = @active_tab.clamp(0, @tabs.size - 1)
1463
+ need_redraw = true
1464
+ end
1465
+
1466
+ tab = current_tab
1467
+ return unless tab
1468
+
1469
+ # Check bell on active pane
1470
+ active_pane = tab.active_pane
1471
+ if active_pane&.screen&.bell
1472
+ active_pane.screen.bell = false
1473
+ @bell_flash = 3
1474
+ need_redraw = true
1475
+ elsif @bell_flash > 0
1476
+ @bell_flash -= 1
1477
+ need_redraw = true
1478
+ end
1479
+
1480
+ need_redraw = true if blink_toggled
1481
+
1482
+ full_redraw = @bell_flash > 0 || blink_toggled
1483
+
1484
+ if need_redraw
1485
+ ObjC::MSG_VOID_1.call(@window, ObjC.sel('setTitle:'), ObjC.nsstring(tab.title))
1486
+
1487
+ if full_redraw || dead&.any? || !tab.pane_tree.single_pane?
1488
+ tab.panes.each { |p| p.screen.clear_dirty }
1489
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
1490
+ else
1491
+ # Single pane optimization: collect dirty rows before clearing
1492
+ screen = active_pane.screen
1493
+ dirty = screen.dirty_rows
1494
+ screen.clear_dirty
1495
+ dirty << screen.cursor.row
1496
+ invalidate_dirty_rows(dirty)
1497
+ end
1498
+ elsif full_redraw
1499
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
1500
+ end
1501
+
1502
+ save_window_state
1503
+ end
1504
+
1505
+ def invalidate_dirty_rows(dirty_rows)
1506
+ gy_off = grid_y_offset
1507
+ width = @cell_width * @cols
1508
+ dirty_rows.each do |r|
1509
+ next if r < 0 || r >= @rows
1510
+ y = gy_off + r * @cell_height
1511
+ ObjC::MSG_VOID_RECT.call(@view, ObjC.sel('setNeedsDisplayInRect:'), 0.0, y, width, @cell_height)
1512
+ end
1513
+ end
1514
+
1515
+ def scroll_wheel(event_ptr)
1516
+ tab = current_tab
1517
+ return unless tab
1518
+ screen = tab.screen
1519
+
1520
+ if screen.mouse_tracking != :off
1521
+ delta = ObjC::MSG_RET_D.call(event_ptr, ObjC.sel('deltaY'))
1522
+ pos = grid_position(event_ptr)
1523
+ return unless pos
1524
+ row, col = pos
1525
+ button = delta > 0 ? 64 : 65 # 64=scroll up, 65=scroll down
1526
+ send_mouse_event(tab, button, col, row)
1527
+ return
1528
+ end
1529
+
1530
+ delta = ObjC::MSG_RET_D.call(event_ptr, ObjC.sel('deltaY'))
1531
+ tab.scroll_accum += delta
1532
+
1533
+ if tab.scroll_accum.abs >= 1.0
1534
+ lines = tab.scroll_accum.to_i
1535
+ tab.scroll_offset += lines
1536
+ tab.scroll_offset = tab.scroll_offset.clamp(0, tab.screen.scrollback.size)
1537
+ tab.scroll_accum -= lines
1538
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
1539
+ end
1540
+ end
1541
+
1542
+ def mouse_down(event_ptr)
1543
+ tab = current_tab
1544
+ return unless tab
1545
+ pos = grid_position(event_ptr)
1546
+ click_count = ObjC::MSG_RET_L.call(event_ptr, ObjC.sel('clickCount'))
1547
+
1548
+ flags = ObjC::MSG_RET_L.call(event_ptr, ObjC.sel('modifierFlags'))
1549
+
1550
+ if pos.nil?
1551
+ # Click in tab bar
1552
+ click_x, = event_location(event_ptr)
1553
+ tab_w = (@cell_width * @cols) / @tabs.size
1554
+ clicked_tab = (click_x / tab_w).to_i.clamp(0, @tabs.size - 1)
1555
+ @active_tab = clicked_tab
1556
+ elsif (flags & ObjC::NSEventModifierFlagCommand) != 0 && pos
1557
+ # Cmd+click: open hyperlink/URL if the cell has one; otherwise
1558
+ # in an embedded pane, recall the command at this prompt row
1559
+ # into the input buffer (using OSC 133 marks as the index).
1560
+ abs_row, col = pos
1561
+ url = hyperlink_at(tab, abs_row, col)
1562
+ if url
1563
+ open_url(url)
1564
+ else
1565
+ pane = tab.active_pane
1566
+ if pane.embedded?
1567
+ mark = pane.screen.find_command_mark_at_row(abs_row)
1568
+ if mark && mark[:command_text] && !pane.embedded_shell.running?
1569
+ pane.recall_command(mark[:command_text])
1570
+ end
1571
+ end
1572
+ end
1573
+ elsif tab.screen.mouse_tracking != :off
1574
+ row, col = pos
1575
+ send_mouse_event(tab, 0, col, row) # button 0 = left press
1576
+ elsif click_count >= 3
1577
+ # Triple-click: if the clicked row falls inside a command's
1578
+ # OSC 133 output region, select the whole region (semantic
1579
+ # copy). Otherwise fall back to selecting just this one line.
1580
+ abs_row, = pos
1581
+ region = tab.active_pane&.screen&.output_region_for_row(abs_row)
1582
+ if region
1583
+ start_row, end_row = region
1584
+ @selection_anchor = [start_row, 0]
1585
+ @selection_end = [end_row, @cols - 1]
1586
+ else
1587
+ @selection_anchor = [abs_row, 0]
1588
+ @selection_end = [abs_row, @cols - 1]
1589
+ end
1590
+ elsif click_count == 2
1591
+ # Double-click: select word
1592
+ abs_row, col = pos
1593
+ row_data = row_at(tab, abs_row)
1594
+ if row_data
1595
+ bounds = word_boundaries_in_row(row_data, col)
1596
+ if bounds
1597
+ @selection_anchor = [abs_row, bounds[0]]
1598
+ @selection_end = [abs_row, bounds[1]]
1599
+ end
1600
+ end
1601
+ else
1602
+ # Single click: start drag selection
1603
+ @selection_anchor = pos
1604
+ @selection_end = nil
1605
+ end
1606
+
1607
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
1608
+ end
1609
+
1610
+ def mouse_dragged(event_ptr)
1611
+ tab = current_tab
1612
+ return unless tab
1613
+ pos = grid_position(event_ptr)
1614
+ return unless pos
1615
+
1616
+ if tab.screen.mouse_tracking == :button_event || tab.screen.mouse_tracking == :any_event
1617
+ row, col = pos
1618
+ send_mouse_event(tab, 32, col, row) # 32 = left drag (button 0 + 32)
1619
+ else
1620
+ @selection_end = pos
1621
+ end
1622
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
1623
+ end
1624
+
1625
+ def mouse_up(event_ptr)
1626
+ tab = current_tab
1627
+ return unless tab
1628
+ return if tab.screen.mouse_tracking == :off || tab.screen.mouse_tracking == :x10
1629
+
1630
+ pos = grid_position(event_ptr)
1631
+ return unless pos
1632
+ row, col = pos
1633
+ send_mouse_event(tab, 3, col, row, release: true) # 3 = release
1634
+ end
1635
+
1636
+ # Pointer-was-hidden + user-shakes-the-mouse path: when the user
1637
+ # has hidden the cursor (Cmd+Shift+P) and then can't find it, the
1638
+ # OS's own "shake to locate" feature briefly enlarges the system
1639
+ # cursor — but ours is hidden, so there's nothing to enlarge. We
1640
+ # detect the shake ourselves and unhide so the user gets a
1641
+ # cursor to look at. After this fires, @pointer_hidden is reset
1642
+ # so re-hiding requires another deliberate Cmd+Shift+P.
1643
+ def mouse_moved(event_ptr)
1644
+ return unless @pointer_hidden
1645
+ @shake_detector ||= ShakeDetector.new
1646
+ x, y = event_location(event_ptr)
1647
+ t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1648
+ if @shake_detector.observe(t, x, y)
1649
+ ObjC::MSG_VOID.call(ObjC.cls('NSCursor'), ObjC.sel('unhide'))
1650
+ @pointer_hidden = false
1651
+ @shake_detector.reset
1652
+ end
1653
+ end
1654
+
1655
+ def right_mouse_down(event_ptr)
1656
+ tab = current_tab
1657
+ return unless tab
1658
+ return if tab.screen.mouse_tracking == :off
1659
+
1660
+ pos = grid_position(event_ptr)
1661
+ return unless pos
1662
+ row, col = pos
1663
+ send_mouse_event(tab, 2, col, row) # button 2 = right press
1664
+ rescue Errno::EIO, IOError
1665
+ close_tab(@active_tab)
1666
+ end
1667
+
1668
+ def right_mouse_dragged(event_ptr)
1669
+ tab = current_tab
1670
+ return unless tab
1671
+ return unless tab.screen.mouse_tracking == :button_event || tab.screen.mouse_tracking == :any_event
1672
+
1673
+ pos = grid_position(event_ptr)
1674
+ return unless pos
1675
+ row, col = pos
1676
+ send_mouse_event(tab, 34, col, row) # 34 = right drag (button 2 + 32)
1677
+ rescue Errno::EIO, IOError
1678
+ close_tab(@active_tab)
1679
+ end
1680
+
1681
+ def right_mouse_up(event_ptr)
1682
+ tab = current_tab
1683
+ return unless tab
1684
+ return if tab.screen.mouse_tracking == :off || tab.screen.mouse_tracking == :x10
1685
+
1686
+ pos = grid_position(event_ptr)
1687
+ return unless pos
1688
+ row, col = pos
1689
+ send_mouse_event(tab, 3, col, row, release: true)
1690
+ rescue Errno::EIO, IOError
1691
+ close_tab(@active_tab)
1692
+ end
1693
+
1694
+ def other_mouse_down(event_ptr)
1695
+ tab = current_tab
1696
+ return unless tab
1697
+ return if tab.screen.mouse_tracking == :off
1698
+
1699
+ pos = grid_position(event_ptr)
1700
+ return unless pos
1701
+ row, col = pos
1702
+ send_mouse_event(tab, 1, col, row) # button 1 = middle press
1703
+ rescue Errno::EIO, IOError
1704
+ close_tab(@active_tab)
1705
+ end
1706
+
1707
+ def other_mouse_dragged(event_ptr)
1708
+ tab = current_tab
1709
+ return unless tab
1710
+ return unless tab.screen.mouse_tracking == :button_event || tab.screen.mouse_tracking == :any_event
1711
+
1712
+ pos = grid_position(event_ptr)
1713
+ return unless pos
1714
+ row, col = pos
1715
+ send_mouse_event(tab, 33, col, row) # 33 = middle drag (button 1 + 32)
1716
+ rescue Errno::EIO, IOError
1717
+ close_tab(@active_tab)
1718
+ end
1719
+
1720
+ def other_mouse_up(event_ptr)
1721
+ tab = current_tab
1722
+ return unless tab
1723
+ return if tab.screen.mouse_tracking == :off || tab.screen.mouse_tracking == :x10
1724
+
1725
+ pos = grid_position(event_ptr)
1726
+ return unless pos
1727
+ row, col = pos
1728
+ send_mouse_event(tab, 3, col, row, release: true)
1729
+ rescue Errno::EIO, IOError
1730
+ close_tab(@active_tab)
1731
+ end
1732
+
1733
+ def handle_resize(w, h)
1734
+ tbh = tab_bar_height
1735
+ grid_height = h - tbh
1736
+
1737
+ new_cols = (w / @cell_width).to_i
1738
+ new_rows = (grid_height / @cell_height).to_i
1739
+ new_cols = 1 if new_cols < 1
1740
+ new_rows = 1 if new_rows < 1
1741
+
1742
+ return if new_rows == @rows && new_cols == @cols
1743
+
1744
+ @rows = new_rows
1745
+ @cols = new_cols
1746
+ @tabs.each { |tab| tab.resize(@rows, @cols) }
1747
+ end
1748
+
1749
+ def window_focus_changed(focused)
1750
+ @window_focused = focused
1751
+ save_window_state
1752
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1) if @view
1753
+
1754
+ tab = current_tab
1755
+ pane = tab&.active_pane
1756
+ return unless pane&.screen&.focus_reporting?
1757
+
1758
+ seq = focused ? "\e[I" : "\e[O"
1759
+ pane.write_input(seq)
1760
+ end
1761
+
1762
+ def update_font(new_size, persist: true)
1763
+ @font_size = new_size
1764
+ Preferences.set_double(:font_size, new_size) if persist
1765
+ old_font = @font
1766
+ old_bold = @bold_font
1767
+ @font = ObjC.retain(create_nsfont(@font_size))
1768
+ @bold_font = ObjC.retain(create_bold_nsfont(@font))
1769
+ ObjC.release(old_font) if old_font
1770
+ ObjC.release(old_bold) if old_bold
1771
+ @font_cache.each_value { |f| ObjC.release(f) unless f.to_i == old_font&.to_i }
1772
+ @font_cache = {}
1773
+ @font_y_offset_cache = {}
1774
+ update_cell_metrics
1775
+
1776
+ @window_states.each do |ws|
1777
+ load_window_state(ws)
1778
+ win_width = @cell_width * @cols
1779
+ win_height = tab_bar_height + @cell_height * @rows
1780
+ ObjC::MSG_VOID_2D.call(@window, ObjC.sel('setContentSize:'), win_width, win_height)
1781
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
1782
+ save_window_state
1783
+ end
1784
+ end
1785
+
1786
+ def perform_drag_operation(sender)
1787
+ pb = ObjC::MSG_PTR.call(sender, ObjC.sel('draggingPasteboard'))
1788
+ str = self.class.file_paths_from_pasteboard(pb)
1789
+ return false if str.nil?
1790
+
1791
+ pane = current_tab.active_pane
1792
+ if pane.screen.bracketed_paste_mode?
1793
+ pane.write_input("\e[200~")
1794
+ pane.write_input(str)
1795
+ pane.write_input("\e[201~")
1796
+ else
1797
+ pane.write_input(str)
1798
+ end
1799
+ true
1800
+ rescue Errno::EIO, IOError
1801
+ false
1802
+ end
1803
+
1804
+ def self.file_paths_from_pasteboard(pb)
1805
+ nsurl_class = ObjC.cls('NSURL')
1806
+ class_array = ObjC::MSG_PTR_1.call(ObjC.cls('NSArray'), ObjC.sel('arrayWithObject:'), nsurl_class)
1807
+ urls = ObjC::MSG_PTR_2.call(pb, ObjC.sel('readObjectsForClasses:options:'), class_array, Fiddle::Pointer.new(0))
1808
+ return nil if urls.null?
1809
+
1810
+ count = ObjC::MSG_RET_L.call(urls, ObjC.sel('count'))
1811
+ return nil if count == 0
1812
+
1813
+ paths = count.times.map do |i|
1814
+ url = ObjC::MSG_PTR_L.call(urls, ObjC.sel('objectAtIndex:'), i)
1815
+ ns_path = ObjC::MSG_PTR.call(url, ObjC.sel('path'))
1816
+ ObjC.to_ruby_string(ns_path).shellescape
1817
+ end
1818
+ paths.join(' ')
1819
+ end
1820
+
1821
+ private
1822
+
1823
+ def open_new_window
1824
+ save_window_state
1825
+
1826
+ # Create tab
1827
+ tab = Tab.new(command: @command, rows: @rows, cols: @cols, embedded: embedded_mode?)
1828
+ tab.title = "Shell"
1829
+ tab.panes.each { |pane| wire_screen_handlers(pane.screen) }
1830
+
1831
+ # Build window and view in locals — DO NOT touch @window / @view yet.
1832
+ # makeKeyAndOrderFront: below fires NSWindowDidResignKeyNotification on
1833
+ # the previously-key window synchronously; that handler calls
1834
+ # activate_for_view, which would mutate @view mid-construction and
1835
+ # corrupt the window-state mapping. Keeping the new pointers in locals
1836
+ # lets the focus handler operate on the OLD window's state safely.
1837
+ win_width = @cell_width * @cols
1838
+ win_height = @cell_height * @rows
1839
+ new_window = ObjC::MSG_PTR.call(ObjC.cls('NSWindow'), ObjC.sel('alloc'))
1840
+ new_window = ObjC::MSG_PTR_RECT_L_L_I.call(
1841
+ new_window, ObjC.sel('initWithContentRect:styleMask:backing:defer:'),
1842
+ 0.0, 0.0, win_width, win_height,
1843
+ ObjC::NSWindowStyleMaskDefault,
1844
+ ObjC::NSBackingStoreBuffered,
1845
+ 0
1846
+ )
1847
+ ObjC::MSG_VOID_1.call(new_window, ObjC.sel('setTitle:'), ObjC.nsstring(Echoes.config.window_title))
1848
+ ObjC::MSG_VOID_L.call(new_window, ObjC.sel('setCollectionBehavior:'), 1 << 7)
1849
+ # Required for mouseMoved: to fire on the content view. Used by
1850
+ # the shake-to-find-pointer detector (see #mouse_moved); without
1851
+ # this, AppKit only delivers move events while a button is held.
1852
+ ObjC::MSG_VOID_I.call(new_window, ObjC.sel('setAcceptsMouseMovedEvents:'), 1)
1853
+
1854
+ new_view = ObjC::MSG_PTR.call(@view_class, ObjC.sel('alloc'))
1855
+ new_view = ObjC::MSG_PTR_RECT.call(
1856
+ new_view, ObjC.sel('initWithFrame:'),
1857
+ 0.0, 0.0, win_width, win_height
1858
+ )
1859
+
1860
+ # Register for file drag-and-drop
1861
+ drag_types = ObjC::MSG_PTR_1.call(ObjC.cls('NSArray'), ObjC.sel('arrayWithObject:'), ObjC::NSPasteboardTypeFileURL)
1862
+ ObjC::MSG_VOID_1.call(new_view, ObjC.sel('registerForDraggedTypes:'), drag_types)
1863
+
1864
+ # Connect view to window and show it. makeKeyAndOrderFront: triggers a
1865
+ # focus_lost handler on the prior key window that may call
1866
+ # activate_for_view; using locals here keeps it from mutating @view.
1867
+ ObjC::MSG_VOID_1.call(new_window, ObjC.sel('setContentView:'), new_view)
1868
+ ObjC::MSG_VOID_1.call(new_window, ObjC.sel('makeKeyAndOrderFront:'), @app)
1869
+ ObjC::MSG_VOID_1.call(new_window, ObjC.sel('makeFirstResponder:'), new_view)
1870
+ ObjC::MSG_VOID_I.call(@app, ObjC.sel('activateIgnoringOtherApps:'), 1)
1871
+ ObjC::MSG_VOID.call(new_window, ObjC.sel('center'))
1872
+ # Cocoa-managed cross-launch frame persistence: if a saved frame
1873
+ # exists for this name, AppKit moves the window to it (overriding
1874
+ # the `center` we just did) and auto-saves on later resize/move.
1875
+ ObjC::MSG_VOID_1.call(new_window, ObjC.sel('setFrameAutosaveName:'),
1876
+ ObjC.nsstring('echoes.main'))
1877
+
1878
+ # Focus notification observers (target the new view + new window)
1879
+ nc = ObjC::MSG_PTR.call(ObjC.cls('NSNotificationCenter'), ObjC.sel('defaultCenter'))
1880
+ ObjC::MSG_VOID_4.call(nc, ObjC.sel('addObserver:selector:name:object:'),
1881
+ new_view, ObjC.sel('windowDidBecomeKey:'),
1882
+ ObjC.nsstring('NSWindowDidBecomeKeyNotification'), new_window)
1883
+ ObjC::MSG_VOID_4.call(nc, ObjC.sel('addObserver:selector:name:object:'),
1884
+ new_view, ObjC.sel('windowDidResignKey:'),
1885
+ ObjC.nsstring('NSWindowDidResignKeyNotification'), new_window)
1886
+
1887
+ # Now adopt the new window/view as the active state
1888
+ @window = new_window
1889
+ @view = new_view
1890
+ @tabs = [tab]
1891
+ @active_tab = 0
1892
+ @search_mode = false
1893
+ @search_query = +""
1894
+ @search_matches = []
1895
+ @search_index = -1
1896
+ @bell_flash = 0
1897
+ @marked_text = nil
1898
+ @current_event = nil
1899
+ @selection_anchor = nil
1900
+ @selection_end = nil
1901
+ @window_focused = true
1902
+
1903
+ # Register window state
1904
+ ws = {}
1905
+ @window_states << ws
1906
+ @view_to_ws[@view.to_i] = ws
1907
+ save_window_state
1908
+ end
1909
+
1910
+ def select_all
1911
+ tab = current_tab
1912
+ return unless tab
1913
+ screen = tab.screen
1914
+ total = screen.scrollback.size + screen.rows
1915
+ @selection_anchor = [0, 0]
1916
+ @selection_end = [total - 1, screen.cols - 1]
1917
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
1918
+ end
1919
+
1920
+ def copy_to_clipboard
1921
+ sr, sc, er, ec = selection_range
1922
+ return unless sr
1923
+
1924
+ text = selected_text_from_buffer(sr, sc, er, ec)
1925
+ return if text.empty?
1926
+
1927
+ pb = ObjC::MSG_PTR.call(ObjC.cls('NSPasteboard'), ObjC.sel('generalPasteboard'))
1928
+ ObjC::MSG_PTR.call(pb, ObjC.sel('clearContents'))
1929
+ ObjC::MSG_PTR_2.call(pb, ObjC.sel('setString:forType:'), ObjC.nsstring(text), ObjC::NSPasteboardTypeString)
1930
+ end
1931
+
1932
+ # Native macOS completion popup. Called from `key_down` when Tab
1933
+ # is pressed in an embedded pane and there are 2+ candidates. Builds
1934
+ # an NSMenu of items (one per candidate, tagged with the index),
1935
+ # anchors it under the cursor cell, and presents it. Selection
1936
+ # fires `completionPicked:` on the view, which routes to
1937
+ # `completion_picked` below.
1938
+ def show_completion_popup(pane, req)
1939
+ candidates = req[:candidates]
1940
+ menu = ObjC::MSG_PTR.call(ObjC.cls('NSMenu'), ObjC.sel('alloc'))
1941
+ menu = ObjC::MSG_PTR_1.call(menu, ObjC.sel('initWithTitle:'), ObjC.nsstring('completion'))
1942
+ candidates.each_with_index do |cand, i|
1943
+ item = ObjC::MSG_PTR.call(ObjC.cls('NSMenuItem'), ObjC.sel('alloc'))
1944
+ item = ObjC::MSG_PTR_3.call(item, ObjC.sel('initWithTitle:action:keyEquivalent:'),
1945
+ ObjC.nsstring(cand), ObjC.sel('completionPicked:'), ObjC.nsstring(''))
1946
+ ObjC::MSG_VOID_L.call(item, ObjC.sel('setTag:'), i)
1947
+ ObjC::MSG_VOID_1.call(menu, ObjC.sel('addItem:'), item)
1948
+ end
1949
+
1950
+ @completion_state = {pane: pane, word_start: req[:word_start], candidates: candidates}
1951
+ x, y = completion_anchor_point(pane)
1952
+ ObjC::MSG_VOID_1_PT_1.call(menu, ObjC.sel('popUpMenuPositioningItem:atLocation:inView:'),
1953
+ Fiddle::Pointer.new(0), x, y, @view)
1954
+ end
1955
+
1956
+ # NSPoint (in flipped view coords) of the cell *just below* the
1957
+ # current cursor row of the active pane, so the popup appears under
1958
+ # the cursor without occluding it. The pane's own (x,y) within the
1959
+ # tabbed view layout is included so splits land in the right spot.
1960
+ def completion_anchor_point(pane)
1961
+ tab = current_tab
1962
+ gy_off = grid_y_offset
1963
+ pane_rects = tab.pane_tree.layout(0, 0, @cols, @rows)
1964
+ rect = pane_rects.find { |r| r[:pane] == pane }
1965
+ return [0.0, gy_off] unless rect
1966
+
1967
+ cursor = pane.screen.cursor
1968
+ x = (rect[:x] + cursor.col) * @cell_width
1969
+ y = gy_off + (rect[:y] + cursor.row + 1) * @cell_height
1970
+ [x, y]
1971
+ end
1972
+
1973
+ # Action callback for an NSMenuItem in the completion popup. Reads
1974
+ # the sender's tag, looks up the chosen candidate, and asks the
1975
+ # pane to splice it into the input buffer.
1976
+ # Public — invoked from the @completion_picked_closure with an
1977
+ # explicit receiver (`gui.completion_picked(sender)`); the rest of
1978
+ # this section's helpers are private (called from inside the class).
1979
+ public def completion_picked(sender)
1980
+ state = @completion_state
1981
+ return unless state
1982
+ tag = ObjC::MSG_RET_L.call(sender, ObjC.sel('tag'))
1983
+ cand = state[:candidates][tag]
1984
+ return unless cand
1985
+ state[:pane].apply_completion(word_start: state[:word_start], completion: cand)
1986
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
1987
+ ensure
1988
+ @completion_state = nil
1989
+ end
1990
+
1991
+ # Show an NSOpenPanel for the user to pick a file to load into a
1992
+ # Editor pane. Returns the chosen path as a String, or nil if
1993
+ # the user canceled. Only single-file selection; directories not
1994
+ # accepted.
1995
+ def prompt_for_file_to_edit
1996
+ panel = ObjC::MSG_PTR.call(ObjC.cls('NSOpenPanel'), ObjC.sel('openPanel'))
1997
+ ObjC::MSG_VOID_I.call(panel, ObjC.sel('setCanChooseFiles:'), 1)
1998
+ ObjC::MSG_VOID_I.call(panel, ObjC.sel('setCanChooseDirectories:'), 0)
1999
+ ObjC::MSG_VOID_I.call(panel, ObjC.sel('setAllowsMultipleSelection:'), 0)
2000
+
2001
+ # Open the dialog at the active pane's pwd (from OSC 7) so the
2002
+ # user lands at the directory they're shelling in. Fall back to
2003
+ # Echoes' own pwd when the pane hasn't announced a cwd or it
2004
+ # doesn't resolve locally.
2005
+ start_dir = self.class.pane_local_cwd(current_tab&.active_pane) || Dir.pwd
2006
+ if start_dir
2007
+ url = ObjC::MSG_PTR_1.call(ObjC.cls('NSURL'), ObjC.sel('fileURLWithPath:'),
2008
+ ObjC.nsstring(start_dir))
2009
+ ObjC::MSG_VOID_1.call(panel, ObjC.sel('setDirectoryURL:'), url)
2010
+ end
2011
+
2012
+ result = ObjC::MSG_RET_L.call(panel, ObjC.sel('runModal'))
2013
+ return nil unless result == 1 # NSModalResponseOK
2014
+ url = ObjC::MSG_PTR.call(panel, ObjC.sel('URL'))
2015
+ return nil if url.null?
2016
+ path_ns = ObjC::MSG_PTR.call(url, ObjC.sel('path'))
2017
+ ObjC.to_ruby_string(path_ns)
2018
+ end
2019
+
2020
+ # Curated allowlist of env vars to surface in the About panel —
2021
+ # locale, paths, and Echoes/Ruby-runtime knobs. Deliberately
2022
+ # NOT a full `ENV` dump: that would leak API keys, tokens,
2023
+ # AWS creds, etc. into anything the user screenshots.
2024
+ ABOUT_PANEL_ENV_KEYS = %w[
2025
+ LANG LC_ALL LC_CTYPE
2026
+ TERM SHELL HOME USER PWD
2027
+ PATH
2028
+ RBENV_VERSION RBENV_ROOT
2029
+ BUNDLE_GEMFILE GEM_HOME GEM_PATH
2030
+ ECHOES_EMBED ECHOES_HELPER_NO_RC
2031
+ ].freeze
2032
+
2033
+ # Custom About panel: Cocoa's standard panel pulls name/version/icon
2034
+ # from Info.plist; we extend it with a Credits string showing the
2035
+ # Ruby runtime info (version, executable, platform), the
2036
+ # bundled-sibling versions (Echoes / rubish / rvim), and a
2037
+ # curated set of env vars (see ABOUT_PANEL_ENV_KEYS) so a user
2038
+ # can tell at a glance which interpreter and which environment
2039
+ # the running .app is wired to.
2040
+ def show_about_panel
2041
+ lines = [
2042
+ "Ruby #{RUBY_VERSION}p#{RUBY_PATCHLEVEL} (#{RUBY_PLATFORM})",
2043
+ RbConfig.ruby,
2044
+ '',
2045
+ "Echoes #{Echoes::VERSION}",
2046
+ ]
2047
+ lines << "rubish #{Rubish::VERSION}" if defined?(Rubish::VERSION)
2048
+ lines << "rvim #{Rvim::VERSION}" if defined?(Rvim::VERSION)
2049
+
2050
+ env_lines = ABOUT_PANEL_ENV_KEYS.filter_map do |k|
2051
+ v = ENV[k]
2052
+ v && !v.empty? ? "#{k}=#{v}" : nil
2053
+ end
2054
+ unless env_lines.empty?
2055
+ lines << ''
2056
+ lines << 'Environment:'
2057
+ lines.concat(env_lines)
2058
+ end
2059
+
2060
+ credits_str = lines.join("\n")
2061
+
2062
+ ns_credits = ObjC.nsstring(credits_str)
2063
+ attr_alloc = ObjC::MSG_PTR.call(ObjC.cls('NSAttributedString'), ObjC.sel('alloc'))
2064
+ attr_str = ObjC::MSG_PTR_1.call(attr_alloc, ObjC.sel('initWithString:'), ns_credits)
2065
+
2066
+ # Without ApplicationName/Version, Cocoa picks up the running
2067
+ # process name ("ruby" — the interpreter the launcher exec'd
2068
+ # into) and the generic Ruby folder icon. Override with our
2069
+ # bundle values so the panel reads "Echoes 0.1.0".
2070
+ options = ObjC.nsdict(
2071
+ ObjC.nsstring('ApplicationName') => ObjC.nsstring('Echoes'),
2072
+ ObjC.nsstring('ApplicationVersion') => ObjC.nsstring(Echoes::VERSION),
2073
+ ObjC.nsstring('Credits') => attr_str,
2074
+ )
2075
+ ObjC::MSG_VOID_1.call(@app, ObjC.sel('orderFrontStandardAboutPanelWithOptions:'), options)
2076
+ end
2077
+
2078
+ def handle_clipboard(action, text)
2079
+ pb = ObjC::MSG_PTR.call(ObjC.cls('NSPasteboard'), ObjC.sel('generalPasteboard'))
2080
+ case action
2081
+ when :set
2082
+ ObjC::MSG_PTR.call(pb, ObjC.sel('clearContents'))
2083
+ ObjC::MSG_PTR_2.call(pb, ObjC.sel('setString:forType:'), ObjC.nsstring(text), ObjC::NSPasteboardTypeString)
2084
+ nil
2085
+ when :get
2086
+ ns_str = ObjC::MSG_PTR_1.call(pb, ObjC.sel('stringForType:'), ObjC::NSPasteboardTypeString)
2087
+ return nil if ns_str.null?
2088
+ ObjC.to_ruby_string(ns_str)
2089
+ end
2090
+ end
2091
+
2092
+ def paste_from_clipboard
2093
+ pb = ObjC::MSG_PTR.call(ObjC.cls('NSPasteboard'), ObjC.sel('generalPasteboard'))
2094
+ ns_str = ObjC::MSG_PTR_1.call(pb, ObjC.sel('stringForType:'), ObjC::NSPasteboardTypeString)
2095
+ return if ns_str.null?
2096
+
2097
+ str = ObjC.to_ruby_string(ns_str)
2098
+ return if str.empty?
2099
+
2100
+ pane = current_tab.active_pane
2101
+ if pane.screen.bracketed_paste_mode?
2102
+ pane.write_input("\e[200~")
2103
+ pane.write_input(str)
2104
+ pane.write_input("\e[201~")
2105
+ else
2106
+ pane.write_input(str)
2107
+ end
2108
+ rescue Errno::EIO, IOError
2109
+ end
2110
+
2111
+ def draw_sixel_image(sixel, x, y, draw_w, draw_h)
2112
+ # Cache CGImage on first render
2113
+ unless sixel[:cg_image]
2114
+ rgba = sixel[:rgba]
2115
+ w = sixel[:width]
2116
+ h = sixel[:height]
2117
+
2118
+ rgba_ptr = Fiddle::Pointer.to_ptr(rgba)
2119
+ color_space = ObjC::CGColorSpaceCreateDeviceRGB.call
2120
+ ctx = ObjC::CGBitmapContextCreate.call(
2121
+ rgba_ptr, w, h, 8, w * 4, color_space,
2122
+ ObjC::KCGImageAlphaPremultipliedLast
2123
+ )
2124
+ sixel[:cg_image] = ObjC::CGBitmapContextCreateImage.call(ctx)
2125
+ ObjC::CGContextRelease.call(ctx)
2126
+ ObjC::CGColorSpaceRelease.call(color_space)
2127
+ end
2128
+
2129
+ cg_image = sixel[:cg_image]
2130
+ return if cg_image.null?
2131
+
2132
+ # Get current CGContext
2133
+ ns_ctx = ObjC::MSG_PTR.call(ObjC.cls('NSGraphicsContext'), ObjC.sel('currentContext'))
2134
+ cg_ctx = ObjC::MSG_PTR.call(ns_ctx, ObjC.sel('CGContext'))
2135
+
2136
+ # Draw with flipping (view is flipped, but CGContext draws bottom-up)
2137
+ ObjC::CGContextSaveGState.call(cg_ctx)
2138
+ ObjC::CGContextTranslateCTM.call(cg_ctx, x, y + draw_h)
2139
+ ObjC::CGContextScaleCTM.call(cg_ctx, 1.0, -1.0)
2140
+ ObjC::CGContextDrawImage.call(cg_ctx, 0.0, 0.0, draw_w, draw_h, cg_image)
2141
+ ObjC::CGContextRestoreGState.call(cg_ctx)
2142
+ end
2143
+
2144
+ def draw_tab_bar(tbh, ty)
2145
+ total_w = @cell_width * @cols
2146
+ tab_w = total_w / @tabs.size
2147
+
2148
+ # Tab bar background
2149
+ ObjC::MSG_VOID.call(@tab_bg, ObjC.sel('setFill'))
2150
+ ObjC::NSRectFill.call(0.0, ty, total_w + @cell_width, tbh)
2151
+
2152
+ @tabs.each_with_index do |tab, i|
2153
+ x = i * tab_w
2154
+
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
2160
+
2161
+ # Tab title
2162
+ label = tab.title
2163
+ label = "#{label} " if label.length < 12
2164
+ ns_label = ObjC.nsstring(label)
2165
+ ns_attrs = ObjC.nsdict({
2166
+ ObjC::NSFontAttributeName => @font,
2167
+ ObjC::NSForegroundColorAttributeName => @tab_fg,
2168
+ })
2169
+ 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)
2177
+ end
2178
+ end
2179
+ end
2180
+
2181
+ def grid_position(event_ptr)
2182
+ x, y_in_window = event_location(event_ptr)
2183
+ y = view_frame_height - y_in_window
2184
+ gy_off = grid_y_offset
2185
+ grid_y = y - gy_off
2186
+ return nil if grid_y < 0 || grid_y >= @rows * @cell_height
2187
+
2188
+ visible_row = (grid_y / @cell_height).to_i.clamp(0, @rows - 1)
2189
+ col = (x / @cell_width).to_i.clamp(0, @cols - 1)
2190
+ # Return absolute row (scrollback + grid index)
2191
+ tab = current_tab
2192
+ return nil unless tab
2193
+ scrollback_size = tab.screen.scrollback.size
2194
+ abs_row = scrollback_size - tab.scroll_offset + visible_row
2195
+ [abs_row, col]
2196
+ end
2197
+
2198
+ def selection_range
2199
+ return nil unless @selection_anchor && @selection_end
2200
+
2201
+ a_r, a_c = @selection_anchor
2202
+ b_r, b_c = @selection_end
2203
+ if a_r < b_r || (a_r == b_r && a_c <= b_c)
2204
+ [a_r, a_c, b_r, b_c]
2205
+ else
2206
+ [b_r, b_c, a_r, a_c]
2207
+ end
2208
+ end
2209
+
2210
+ def toggle_search
2211
+ @search_mode = !@search_mode
2212
+ if @search_mode
2213
+ @search_query = +""
2214
+ @search_matches = []
2215
+ @search_index = -1
2216
+ end
2217
+ end
2218
+
2219
+ def search_key_down(event_ptr)
2220
+ chars_ns = ObjC::MSG_PTR.call(event_ptr, ObjC.sel('characters'))
2221
+ chars = ObjC.to_ruby_string(chars_ns)
2222
+ key_code = ObjC::MSG_RET_L.call(event_ptr, ObjC.sel('keyCode'))
2223
+ flags = ObjC::MSG_RET_L.call(event_ptr, ObjC.sel('modifierFlags'))
2224
+
2225
+ case key_code
2226
+ when 53 # Escape
2227
+ @search_mode = false
2228
+ @search_matches = []
2229
+ when 36 # Return
2230
+ if (flags & ObjC::NSEventModifierFlagShift) != 0
2231
+ search_prev
2232
+ else
2233
+ search_next
2234
+ end
2235
+ when 51 # Backspace
2236
+ @search_query.chop!
2237
+ perform_search
2238
+ else
2239
+ unless chars.empty? || chars[0].ord < 0x20
2240
+ @search_query << chars
2241
+ perform_search
2242
+ end
2243
+ end
2244
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
2245
+ end
2246
+
2247
+ def perform_search
2248
+ @search_matches = []
2249
+ @search_index = -1
2250
+ return if @search_query.empty?
2251
+
2252
+ tab = current_tab
2253
+ screen = tab.screen
2254
+ scrollback = screen.scrollback
2255
+
2256
+ # Search scrollback
2257
+ 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
2264
+ end
2265
+
2266
+ # Search grid
2267
+ screen.grid.each_with_index do |row, grid_row|
2268
+ 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
2275
+ end
2276
+
2277
+ @search_index = @search_matches.size - 1 if @search_matches.any?
2278
+ scroll_to_match if @search_index >= 0
2279
+ end
2280
+
2281
+ def search_next
2282
+ return if @search_matches.empty?
2283
+ @search_index = (@search_index + 1) % @search_matches.size
2284
+ scroll_to_match
2285
+ end
2286
+
2287
+ def search_prev
2288
+ return if @search_matches.empty?
2289
+ @search_index = (@search_index - 1) % @search_matches.size
2290
+ scroll_to_match
2291
+ end
2292
+
2293
+ def scroll_to_match
2294
+ abs_row, = @search_matches[@search_index]
2295
+ tab = current_tab
2296
+ scrollback_size = tab.screen.scrollback.size
2297
+ if abs_row < scrollback_size
2298
+ tab.scroll_offset = scrollback_size - abs_row - (@rows / 2)
2299
+ tab.scroll_offset = tab.scroll_offset.clamp(0, scrollback_size)
2300
+ else
2301
+ tab.scroll_offset = 0
2302
+ end
2303
+ end
2304
+
2305
+ def search_match_at?(abs_row, col)
2306
+ @search_matches.any? { |r, c, len| r == abs_row && col >= c && col < c + len }
2307
+ end
2308
+
2309
+ def current_search_match_at?(abs_row, col)
2310
+ return false if @search_index < 0 || @search_index >= @search_matches.size
2311
+ r, c, len = @search_matches[@search_index]
2312
+ r == abs_row && col >= c && col < c + len
2313
+ end
2314
+
2315
+ URL_REGEX = /https?:\/\/\S+/
2316
+
2317
+ def hyperlink_at(tab, abs_row, col)
2318
+ row = row_at(tab, abs_row)
2319
+ return nil unless row
2320
+
2321
+ # Check OSC 8 hyperlink first
2322
+ cell = row[col]
2323
+ return cell.hyperlink if cell&.hyperlink
2324
+
2325
+ # Detect URL in row text
2326
+ text = row.map(&:char).join
2327
+ text.scan(URL_REGEX) do |url|
2328
+ start = Regexp.last_match.begin(0)
2329
+ if col >= start && col < start + url.length
2330
+ return url
2331
+ end
2332
+ end
2333
+ nil
2334
+ end
2335
+
2336
+ def open_url(url)
2337
+ ns_url = ObjC::MSG_PTR_1.call(ObjC.cls('NSURL'), ObjC.sel('URLWithString:'), ObjC.nsstring(url))
2338
+ return if ns_url.null?
2339
+ workspace = ObjC::MSG_PTR.call(ObjC.cls('NSWorkspace'), ObjC.sel('sharedWorkspace'))
2340
+ ObjC::MSG_PTR_1.call(workspace, ObjC.sel('openURL:'), ns_url)
2341
+ end
2342
+
2343
+ def row_at(tab, abs_row)
2344
+ scrollback = tab.screen.scrollback
2345
+ if abs_row < scrollback.size
2346
+ scrollback[abs_row]
2347
+ elsif abs_row - scrollback.size < tab.screen.rows
2348
+ tab.screen.grid[abs_row - scrollback.size]
2349
+ end
2350
+ end
2351
+
2352
+ def word_boundaries_in_row(row, col)
2353
+ return nil if col < 0 || col >= row.size
2354
+
2355
+ cls = char_class_of(row[col].char)
2356
+ start_col = col
2357
+ start_col -= 1 while start_col > 0 && char_class_of(row[start_col - 1].char) == cls
2358
+ end_col = col
2359
+ end_col += 1 while end_col < row.size - 1 && char_class_of(row[end_col + 1].char) == cls
2360
+ [start_col, end_col]
2361
+ end
2362
+
2363
+ def char_class_of(c)
2364
+ if c.nil? || c.empty? || c == ' ' then :space
2365
+ elsif Echoes.config.word_separators.include?(c) then :separator
2366
+ else :word
2367
+ end
2368
+ end
2369
+
2370
+ def selected_text_from_buffer(sr, sc, er, ec)
2371
+ screen = current_tab.screen
2372
+ scrollback = screen.scrollback
2373
+ lines = []
2374
+ (sr..er).each do |abs_row|
2375
+ row = if abs_row < scrollback.size
2376
+ scrollback[abs_row]
2377
+ else
2378
+ screen.grid[abs_row - scrollback.size]
2379
+ end
2380
+ next unless row
2381
+
2382
+ from = (abs_row == sr) ? sc : 0
2383
+ to = (abs_row == er) ? ec : @cols - 1
2384
+ # Skip cells that are placeholders for a multi-cell character —
2385
+ # the second half of a wide CJK/emoji glyph (width == 0) and
2386
+ # OSC 66 continuation cells (multicell == :cont). Their `char`
2387
+ # is a leftover space from the cell reset; including it
2388
+ # produces "T e x t" instead of "Text" for scaled output.
2389
+ chars = row[from..to].reject { |c| c.width == 0 || c.multicell == :cont }.map(&:char)
2390
+ lines << chars.join.rstrip
2391
+ end
2392
+ lines.join("\n")
2393
+ end
2394
+
2395
+ def cell_selected?(row, col)
2396
+ range = selection_range
2397
+ return false unless range
2398
+
2399
+ sr, sc, er, ec = range
2400
+ return false if row < sr || row > er
2401
+ return col >= sc && col <= ec if sr == er
2402
+ return col >= sc if row == sr
2403
+ return col <= ec if row == er
2404
+
2405
+ true
2406
+ end
2407
+
2408
+ # NSView's frame.size.height in points. Querying live (rather than caching
2409
+ # @view_height) avoids a class of bugs where the cached value got out of
2410
+ # sync after macOS state restoration resized the window asynchronously.
2411
+ def view_frame_height
2412
+ buf = Fiddle::Pointer.malloc(32, Fiddle::RUBY_FREE)
2413
+ sig = ObjC::MSG_PTR_1.call(ObjC.cls('NSView'), ObjC.sel('instanceMethodSignatureForSelector:'), ObjC.sel('frame'))
2414
+ inv = ObjC::MSG_PTR_1.call(ObjC.cls('NSInvocation'), ObjC.sel('invocationWithMethodSignature:'), sig)
2415
+ ObjC::MSG_VOID_1.call(inv, ObjC.sel('setSelector:'), ObjC.sel('frame'))
2416
+ ObjC::MSG_VOID_1.call(inv, ObjC.sel('invokeWithTarget:'), @view)
2417
+ ObjC::MSG_VOID_1.call(inv, ObjC.sel('getReturnValue:'), buf)
2418
+ buf[0, 32].unpack('d4')[3]
2419
+ end
2420
+
2421
+ # Extract NSPoint (x, y) from [event locationInWindow] via NSInvocation
2422
+ # to work around Fiddle only capturing d0 (not d1) on arm64
2423
+ def event_location(event_ptr)
2424
+ event_class = ObjC::MSG_PTR.call(event_ptr, ObjC.sel('class'))
2425
+ sig = ObjC::MSG_PTR_1.call(
2426
+ event_class, ObjC.sel('instanceMethodSignatureForSelector:'),
2427
+ ObjC.sel('locationInWindow')
2428
+ )
2429
+ inv = ObjC::MSG_PTR_1.call(
2430
+ ObjC.cls('NSInvocation'), ObjC.sel('invocationWithMethodSignature:'), sig
2431
+ )
2432
+ ObjC::MSG_VOID_1.call(inv, ObjC.sel('setSelector:'), ObjC.sel('locationInWindow'))
2433
+ ObjC::MSG_VOID_1.call(inv, ObjC.sel('invokeWithTarget:'), event_ptr)
2434
+ buf = Fiddle::Pointer.malloc(16, Fiddle::RUBY_FREE)
2435
+ ObjC::MSG_VOID_1.call(inv, ObjC.sel('getReturnValue:'), buf)
2436
+ buf[0, 16].unpack('dd')
2437
+ end
2438
+
2439
+ def create_bold_nsfont(font)
2440
+ fm = ObjC::MSG_PTR.call(ObjC.cls('NSFontManager'), ObjC.sel('sharedFontManager'))
2441
+ ObjC::MSG_PTR_1L.call(fm, ObjC.sel('convertFont:toHaveTrait:'), font, 0x2) # NSBoldFontMask
2442
+ end
2443
+
2444
+ def create_italic_nsfont(font)
2445
+ 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
2447
+ end
2448
+
2449
+ # Used by Screen#put_multicell when an OSC 66 cell carries a
2450
+ # proportional font family (`f=`) without an explicit `w=`.
2451
+ # Returns the AppKit-measured pixel width of `text` in `family`
2452
+ # at the effective rendered size — Screen rounds up to whole
2453
+ # cells from there.
2454
+ def measure_glyph(text, family, scale, frac_n, frac_d)
2455
+ effective_scale = scale.to_f
2456
+ if frac_d > 0 && frac_n > 0
2457
+ effective_scale *= frac_n.to_f / frac_d.to_f
2458
+ end
2459
+ font = ObjC.retain(create_nsfont(@font_size * effective_scale, family: family))
2460
+ ns = ObjC.nsstring(text)
2461
+ attrs = ObjC.nsdict(ObjC::NSFontAttributeName => font)
2462
+ width = ObjC::MSG_RET_D_1.call(ns, ObjC.sel('sizeWithAttributes:'), attrs)
2463
+ ObjC.release(font)
2464
+ width
2465
+ end
2466
+
2467
+ # 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)
2473
+ screen.clipboard_handler = method(:handle_clipboard)
2474
+ screen.glyph_measurer = method(:measure_glyph)
2475
+ screen.cell_pixel_width = @cell_width if @cell_width
2476
+ screen.cell_pixel_height = @cell_height if @cell_height
2477
+ end
2478
+
2479
+ def create_nsfont(size, family: nil)
2480
+ family ||= Echoes.config.font_family
2481
+ if family
2482
+ font = ObjC::MSG_PTR_1D.call(
2483
+ ObjC.cls('NSFont'), ObjC.sel('fontWithName:size:'),
2484
+ ObjC.nsstring(family), size
2485
+ )
2486
+ # NSFont returns nil if the family isn't installed; fall back
2487
+ # to the monospaced system font so a bad OSC 66 `f=` doesn't
2488
+ # leave the cell unrendered.
2489
+ return font if font && font.to_i != 0
2490
+ end
2491
+ ObjC::MSG_PTR_2D.call(
2492
+ ObjC.cls('NSFont'), ObjC.sel('monospacedSystemFontOfSize:weight:'),
2493
+ size, 0.0
2494
+ )
2495
+ end
2496
+
2497
+ def update_cell_metrics
2498
+ if Echoes.config.font_family
2499
+ attrs = ObjC.nsdict({ObjC::NSFontAttributeName => @font})
2500
+ ns_m = ObjC.nsstring("M")
2501
+ @cell_width = ObjC::MSG_RET_D_1.call(ns_m, ObjC.sel('sizeWithAttributes:'), attrs)
2502
+ else
2503
+ @cell_width = ObjC::MSG_RET_D.call(@font, ObjC.sel('maximumAdvancement'))
2504
+ end
2505
+ ascender = ObjC::MSG_RET_D.call(@font, ObjC.sel('ascender'))
2506
+ descender = ObjC::MSG_RET_D.call(@font, ObjC.sel('descender'))
2507
+ leading = ObjC::MSG_RET_D.call(@font, ObjC.sel('leading'))
2508
+ @cell_height = ascender - descender + leading
2509
+ @font_default_line_height = ObjC::MSG_RET_D.call(@font, ObjC.sel('defaultLineHeightForFont'))
2510
+ @font_y_offset_cache = {}
2511
+
2512
+ # Propagate cell metrics to all pane screens (sixel sizing,
2513
+ # OSC 66 proportional-glyph layout). Handler refs are
2514
+ # idempotent so re-wiring on every font change is fine.
2515
+ @window_states.each do |ws|
2516
+ ws[:tabs]&.each do |tab|
2517
+ tab.panes.each { |pane| wire_screen_handlers(pane.screen) }
2518
+ end
2519
+ end
2520
+ end
2521
+
2522
+ def font_for_char(char)
2523
+ return @font if char.ascii_only?
2524
+
2525
+ cached = @font_cache[char]
2526
+ return cached if cached
2527
+
2528
+ ns_str = ObjC.nsstring(char)
2529
+ ns_len = ObjC::MSG_RET_L.call(ns_str, ObjC.sel('length'))
2530
+ fallback = ObjC::CTFontCreateForString.call(@font, ns_str, 0, ns_len)
2531
+ if fallback.to_i == @font.to_i
2532
+ @font_cache[char] = @font
2533
+ else
2534
+ @font_cache[char] = ObjC.retain(fallback)
2535
+ end
2536
+ @font_cache[char]
2537
+ end
2538
+
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.
2545
+ def y_offset_for_font(font)
2546
+ return 0.0 if font.to_i == @font.to_i
2547
+ cached = @font_y_offset_cache[font.to_i]
2548
+ 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
2551
+ end
2552
+
2553
+ MODIFIED_KEYS = {
2554
+ "\u{F700}" => ['1', 'A'], # Up
2555
+ "\u{F701}" => ['1', 'B'], # Down
2556
+ "\u{F702}" => ['1', 'D'], # Left
2557
+ "\u{F703}" => ['1', 'C'], # Right
2558
+ "\u{F728}" => ['3', '~'], # Delete
2559
+ "\u{F729}" => ['1', 'H'], # Home
2560
+ "\u{F72B}" => ['1', 'F'], # End
2561
+ "\u{F72C}" => ['5', '~'], # Page Up
2562
+ "\u{F72D}" => ['6', '~'], # Page Down
2563
+ "\u{F704}" => ['1', 'P'], # F1
2564
+ "\u{F705}" => ['1', 'Q'], # F2
2565
+ "\u{F706}" => ['1', 'R'], # F3
2566
+ "\u{F707}" => ['1', 'S'], # F4
2567
+ "\u{F708}" => ['15', '~'], # F5
2568
+ "\u{F709}" => ['17', '~'], # F6
2569
+ "\u{F70A}" => ['18', '~'], # F7
2570
+ "\u{F70B}" => ['19', '~'], # F8
2571
+ "\u{F70C}" => ['20', '~'], # F9
2572
+ "\u{F70D}" => ['21', '~'], # F10
2573
+ "\u{F70E}" => ['23', '~'], # F11
2574
+ "\u{F70F}" => ['24', '~'], # F12
2575
+ }.freeze
2576
+
2577
+ def modifier_param(flags)
2578
+ m = 1
2579
+ m += 1 if (flags & ObjC::NSEventModifierFlagShift) != 0
2580
+ m += 2 if (flags & ObjC::NSEventModifierFlagOption) != 0
2581
+ m += 4 if (flags & ObjC::NSEventModifierFlagControl) != 0
2582
+ m
2583
+ end
2584
+
2585
+ def map_modified_key(chars, mod)
2586
+ entry = MODIFIED_KEYS[chars]
2587
+ return nil unless entry
2588
+
2589
+ param, final = entry
2590
+ "\e[#{param};#{mod}#{final}"
2591
+ end
2592
+
2593
+ KEYPAD_APP_MAP = {
2594
+ '0' => "\eOp", '1' => "\eOq", '2' => "\eOr", '3' => "\eOs",
2595
+ '4' => "\eOt", '5' => "\eOu", '6' => "\eOv", '7' => "\eOw",
2596
+ '8' => "\eOx", '9' => "\eOy", '-' => "\eOm", '+' => "\eOk",
2597
+ '*' => "\eOj", '/' => "\eOo", '.' => "\eOn", "\r" => "\eOM",
2598
+ '=' => "\eOX",
2599
+ }.freeze
2600
+
2601
+ def map_special_keys(chars, app_cursor = false, app_keypad: false)
2602
+ if app_keypad && (seq = KEYPAD_APP_MAP[chars])
2603
+ return seq
2604
+ end
2605
+
2606
+ case chars
2607
+ when "\u{F700}" then app_cursor ? "\eOA" : "\e[A" # Up
2608
+ when "\u{F701}" then app_cursor ? "\eOB" : "\e[B" # Down
2609
+ when "\u{F702}" then app_cursor ? "\eOD" : "\e[D" # Left
2610
+ when "\u{F703}" then app_cursor ? "\eOC" : "\e[C" # Right
2611
+ when "\u{F728}" then "\e[3~" # Delete
2612
+ when "\u{F729}" then "\e[H" # Home
2613
+ when "\u{F72B}" then "\e[F" # End
2614
+ when "\u{F72C}" then "\e[5~" # Page Up
2615
+ when "\u{F72D}" then "\e[6~" # Page Down
2616
+ when "\u{F704}" then "\eOP" # F1
2617
+ when "\u{F705}" then "\eOQ" # F2
2618
+ when "\u{F706}" then "\eOR" # F3
2619
+ when "\u{F707}" then "\eOS" # F4
2620
+ when "\u{F708}" then "\e[15~" # F5
2621
+ when "\u{F709}" then "\e[17~" # F6
2622
+ when "\u{F70A}" then "\e[18~" # F7
2623
+ when "\u{F70B}" then "\e[19~" # F8
2624
+ when "\u{F70C}" then "\e[20~" # F9
2625
+ when "\u{F70D}" then "\e[21~" # F10
2626
+ when "\u{F70E}" then "\e[23~" # F11
2627
+ when "\u{F70F}" then "\e[24~" # F12
2628
+ else chars
2629
+ end
2630
+ end
2631
+
2632
+ def build_color_table
2633
+ ansi_rgb = [
2634
+ [0.0, 0.0, 0.0], # 0: black
2635
+ [0.8, 0.0, 0.0], # 1: red
2636
+ [0.0, 0.8, 0.0], # 2: green
2637
+ [0.8, 0.8, 0.0], # 3: yellow
2638
+ [0.0, 0.0, 0.8], # 4: blue
2639
+ [0.8, 0.0, 0.8], # 5: magenta
2640
+ [0.0, 0.8, 0.8], # 6: cyan
2641
+ [0.75, 0.75, 0.75], # 7: white
2642
+ [0.5, 0.5, 0.5], # 8: bright black
2643
+ [1.0, 0.0, 0.0], # 9: bright red
2644
+ [0.0, 1.0, 0.0], # 10: bright green
2645
+ [1.0, 1.0, 0.0], # 11: bright yellow
2646
+ [0.0, 0.0, 1.0], # 12: bright blue
2647
+ [1.0, 0.0, 1.0], # 13: bright magenta
2648
+ [0.0, 1.0, 1.0], # 14: bright cyan
2649
+ [1.0, 1.0, 1.0], # 15: bright white
2650
+ ]
2651
+
2652
+ # Override with user-configured palette
2653
+ if (palette = Echoes.config.color_palette)
2654
+ palette.each_with_index do |rgb, i|
2655
+ ansi_rgb[i] = rgb if i < 16 && rgb
2656
+ end
2657
+ end
2658
+
2659
+ colors = {}
2660
+ ansi_rgb.each_with_index do |(r, g, b), i|
2661
+ colors[i] = make_color(r, g, b)
2662
+ end
2663
+
2664
+ # 6x6x6 color cube (indices 16-231)
2665
+ 216.times do |i|
2666
+ idx = 16 + i
2667
+ b_val = (i % 6) * 51
2668
+ g_val = ((i / 6) % 6) * 51
2669
+ r_val = (i / 36) * 51
2670
+ colors[idx] = make_color(r_val / 255.0, g_val / 255.0, b_val / 255.0)
2671
+ end
2672
+
2673
+ # Grayscale ramp (indices 232-255)
2674
+ 24.times do |i|
2675
+ idx = 232 + i
2676
+ v = (8 + 10 * i) / 255.0
2677
+ colors[idx] = make_color(v, v, v)
2678
+ end
2679
+
2680
+ colors
2681
+ end
2682
+
2683
+ def send_mouse_event(tab, button, col, row, release: false)
2684
+ cx = col + 1
2685
+ cy = row + 1
2686
+ if tab.screen.mouse_encoding == :sgr
2687
+ final = release ? 'm' : 'M'
2688
+ tab.write_input("\e[<#{button};#{cx};#{cy}#{final}")
2689
+ else
2690
+ tab.write_input("\e[M#{(button + 32).chr}#{(cx + 32).chr}#{(cy + 32).chr}")
2691
+ end
2692
+ rescue Errno::EIO, IOError
2693
+ end
2694
+
2695
+ def resolve_color(val, default)
2696
+ case val
2697
+ when nil then default
2698
+ when Integer then @colors[val]
2699
+ when Array
2700
+ key = (val[0] << 16) | (val[1] << 8) | val[2]
2701
+ @rgb_color_cache[key] ||= make_color(val[0] / 255.0, val[1] / 255.0, val[2] / 255.0)
2702
+ else default
2703
+ end
2704
+ end
2705
+
2706
+ def make_color_with_alpha(color, alpha)
2707
+ ObjC::MSG_PTR_D.call(color, ObjC.sel('colorWithAlphaComponent:'), alpha)
2708
+ end
2709
+
2710
+ def cached_nsstring(str)
2711
+ @nsstring_cache[str] ||= ObjC.retain(ObjC.nsstring(str))
2712
+ end
2713
+
2714
+ def nsstring_from_input(obj_ptr)
2715
+ is_attr = ObjC::MSG_PTR_1.call(obj_ptr, ObjC.sel('isKindOfClass:'), ObjC.cls('NSAttributedString'))
2716
+ if is_attr.to_i != 0
2717
+ ns_str = ObjC::MSG_PTR.call(obj_ptr, ObjC.sel('string'))
2718
+ ObjC.to_ruby_string(ns_str)
2719
+ else
2720
+ ObjC.to_ruby_string(obj_ptr)
2721
+ end
2722
+ end
2723
+
2724
+ def make_color(r, g, b, a = 1.0)
2725
+ ObjC.retain(ObjC::MSG_PTR_4D.call(
2726
+ ObjC.cls('NSColor'), ObjC.sel('colorWithRed:green:blue:alpha:'),
2727
+ r, g, b, a
2728
+ ))
2729
+ end
2730
+
2731
+ # Paint the pane's background (set via OSC 7772). `spec` is the
2732
+ # hash the parser stored on `screen.background`:
2733
+ # {type: :flat, colors: [[r,g,b,a]]}
2734
+ # {type: :linear, angle: Float, colors: [[r,g,b,a], ...]}
2735
+ # Cell-level bg colors paint on top, so selection / highlight /
2736
+ # themed cells still occlude correctly. Unknown types (e.g.
2737
+ # :radial — not implemented) are a no-op.
2738
+ def draw_pane_background(spec, px, py, pane_cols, pane_rows)
2739
+ colors = spec[:colors]
2740
+ return if !colors || colors.empty?
2741
+ w = pane_cols * @cell_width
2742
+ h = pane_rows * @cell_height
2743
+
2744
+ case spec[:type]
2745
+ when :flat
2746
+ rgba = colors.first
2747
+ color = make_color(*rgba)
2748
+ ObjC::MSG_VOID.call(color, ObjC.sel('setFill'))
2749
+ ObjC::NSRectFill.call(px, py, w, h)
2750
+ ObjC.release(color)
2751
+ when :linear
2752
+ return if colors.size < 2
2753
+ # First cut supports two endpoint colors. If the spec carries
2754
+ # more, treat first/last as the endpoints — N-color NSArray
2755
+ # construction through Fiddle is awkward and isn't required
2756
+ # for the keynote-style two-color use case that motivated
2757
+ # this.
2758
+ start_rgba = colors.first
2759
+ end_rgba = colors.last
2760
+ ns_start = make_color(*start_rgba)
2761
+ ns_end = make_color(*end_rgba)
2762
+ alloc = ObjC::MSG_PTR.call(ObjC.cls('NSGradient'), ObjC.sel('alloc'))
2763
+ gradient = ObjC::MSG_PTR_2.call(alloc, ObjC.sel('initWithStartingColor:endingColor:'),
2764
+ ns_start, ns_end)
2765
+
2766
+ ObjC::MSG_VOID_RECT_D.call(gradient, ObjC.sel('drawInRect:angle:'),
2767
+ px, py, w, h, spec[:angle].to_f)
2768
+
2769
+ ObjC.release(gradient)
2770
+ ObjC.release(ns_start)
2771
+ ObjC.release(ns_end)
2772
+ end
2773
+ end
2774
+
2775
+ # Paint each `bg-fill` overlay rectangle on top of the base pane
2776
+ # background but below cell content. `fills` is a list of
2777
+ # {rect: [r1,c1,r2,c2], color: [r,g,b,a]} hashes accumulated by
2778
+ # the OSC 7772 `bg-fill` parser. Coordinates are 0-indexed and
2779
+ # inclusive on both ends; the rect is clipped to the pane bounds
2780
+ # so out-of-range emitter values don't draw outside the pane.
2781
+ def draw_pane_fills(fills, px, py, pane_cols, pane_rows)
2782
+ fills.each do |fill|
2783
+ rect = fill[:rect]
2784
+ rgba = fill[:color]
2785
+ next unless rect && rgba && rect.size == 4
2786
+ r1, c1, r2, c2 = rect
2787
+ r1 = r1.clamp(0, pane_rows - 1)
2788
+ r2 = r2.clamp(0, pane_rows - 1)
2789
+ c1 = c1.clamp(0, pane_cols - 1)
2790
+ c2 = c2.clamp(0, pane_cols - 1)
2791
+ next if r1 > r2 || c1 > c2
2792
+
2793
+ x = px + c1 * @cell_width
2794
+ y = py + r1 * @cell_height
2795
+ w = (c2 - c1 + 1) * @cell_width
2796
+ h = (r2 - r1 + 1) * @cell_height
2797
+
2798
+ ns = make_color(*rgba)
2799
+ ObjC::MSG_VOID.call(ns, ObjC.sel('setFill'))
2800
+ ObjC::NSRectFill.call(x, y, w, h)
2801
+ ObjC.release(ns)
2802
+ end
2803
+ end
2804
+ end
2805
+
2806
+ # Detect a "shake to find pointer" gesture from a stream of mouse
2807
+ # samples. Looks at the trailing WINDOW seconds of motion: a shake
2808
+ # is several quick direction reversals over a meaningful distance.
2809
+ # Tuned for casual wrist-shakes; not a substitute for the OS's own
2810
+ # accessibility feature, which kicks in for the visible system
2811
+ # cursor regardless of what apps are doing.
2812
+ class ShakeDetector
2813
+ WINDOW = 0.5 # seconds of history to consider
2814
+ MIN_REVS = 3 # direction reversals required
2815
+ MIN_PATH = 150.0 # cumulative pixels of motion required
2816
+
2817
+ def initialize
2818
+ @samples = []
2819
+ end
2820
+
2821
+ # Add a (time, x, y) sample. Returns true on the call where a
2822
+ # shake first crosses the threshold; callers should treat this
2823
+ # as edge-triggered and follow up with #reset.
2824
+ def observe(t, x, y)
2825
+ @samples << [t, x, y]
2826
+ cutoff = t - WINDOW
2827
+ @samples.shift while @samples.first && @samples.first[0] < cutoff
2828
+ return false if @samples.size < 4
2829
+ detect
2830
+ end
2831
+
2832
+ def reset
2833
+ @samples.clear
2834
+ end
2835
+
2836
+ private
2837
+
2838
+ def detect
2839
+ reversals = 0
2840
+ path = 0.0
2841
+ prev_dx = nil
2842
+ prev_dy = nil
2843
+ (1...@samples.size).each do |i|
2844
+ _, x0, y0 = @samples[i - 1]
2845
+ _, x1, y1 = @samples[i]
2846
+ dx = x1 - x0
2847
+ dy = y1 - y0
2848
+ next if dx.zero? && dy.zero?
2849
+ path += Math.sqrt(dx * dx + dy * dy)
2850
+ if prev_dx
2851
+ if (prev_dx * dx < 0) || (prev_dy * dy < 0)
2852
+ reversals += 1
2853
+ end
2854
+ end
2855
+ prev_dx = dx
2856
+ prev_dy = dy
2857
+ end
2858
+ reversals >= MIN_REVS && path >= MIN_PATH
2859
+ end
2860
+ end
2861
+ end