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.
- checksums.yaml +7 -0
- data/CLAUDE.md +33 -0
- data/Echoes.app/Contents/Info.plist +16 -0
- data/Echoes.app/Contents/MacOS/Echoes +50 -0
- data/EchoesEmbed.app/Contents/Info.plist +16 -0
- data/EchoesEmbed.app/Contents/MacOS/EchoesEmbed +41 -0
- data/LICENSE.txt +21 -0
- data/README.md +133 -0
- data/Rakefile +45 -0
- data/exe/echoes +15 -0
- data/lib/echoes/cell.rb +54 -0
- data/lib/echoes/client.rb +96 -0
- data/lib/echoes/configuration.rb +135 -0
- data/lib/echoes/copy_mode.rb +545 -0
- data/lib/echoes/cursor.rb +18 -0
- data/lib/echoes/editor.rb +225 -0
- data/lib/echoes/embedded_shell.rb +360 -0
- data/lib/echoes/embedded_shell_helper.rb +265 -0
- data/lib/echoes/gui.rb +2861 -0
- data/lib/echoes/installer.rb +95 -0
- data/lib/echoes/objc.rb +188 -0
- data/lib/echoes/pane.rb +1122 -0
- data/lib/echoes/pane_tree.rb +194 -0
- data/lib/echoes/parser.rb +821 -0
- data/lib/echoes/preferences.rb +45 -0
- data/lib/echoes/screen.rb +1468 -0
- data/lib/echoes/sixel_decoder.rb +221 -0
- data/lib/echoes/tab.rb +152 -0
- data/lib/echoes/terminal.rb +124 -0
- data/lib/echoes/version.rb +5 -0
- data/lib/echoes.rb +37 -0
- data/sig/echoes.rbs +4 -0
- metadata +123 -0
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
|