echoes 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/lib/echoes/gui.rb +556 -69
- data/lib/echoes/kitty_graphics.rb +8 -1
- data/lib/echoes/objc.rb +18 -0
- data/lib/echoes/parser.rb +1 -1
- data/lib/echoes/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f5fa8600adc9387da347fcf6388529566e2f991c7d80718d031e350d8331ad38
|
|
4
|
+
data.tar.gz: 8ceb984a585917dfb84333edc142a47e7b07eb85e41cc0239a4624c4cf6f4a41
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9fcb90521884937e71edd306efc686c8472616e6de9a553d288ecc8e8db53cab7137bd0a0f95eeec48b65dedfa1d3d701330f0fc41635645498f91365f7ae8d0
|
|
7
|
+
data.tar.gz: 84ee319742e8191a016ff164c3048b2901874c1508580ec506674db4bd6dafe291680de6d9afc29f952b84392a01a1bc28d887902711b863cd6c1a069089936f
|
data/README.md
CHANGED
|
@@ -98,6 +98,7 @@ bundle exec exe/echoes -t # TTY mode
|
|
|
98
98
|
| Cmd+Shift+] / Cmd+Shift+[ | Next / previous tab |
|
|
99
99
|
| Cmd+1 … Cmd+8 | Jump to tab 1 … 8 |
|
|
100
100
|
| Cmd+9 | Jump to the last tab |
|
|
101
|
+
| drag tab in the bar | Reorder, move to another window, or tear out to a new window |
|
|
101
102
|
| Cmd++ / Cmd+- / Cmd+0 | Bigger / smaller / reset font |
|
|
102
103
|
| Cmd+F | Find |
|
|
103
104
|
| Cmd+G / Cmd+Shift+G | Find next / previous |
|
data/lib/echoes/gui.rb
CHANGED
|
@@ -51,6 +51,9 @@ module Echoes
|
|
|
51
51
|
@selection_anchor = nil
|
|
52
52
|
@selection_end = nil
|
|
53
53
|
@selection_word_anchor = nil
|
|
54
|
+
@drag_start_tab_index = nil
|
|
55
|
+
@drag_start_point = nil
|
|
56
|
+
@drag_insertion_index = nil
|
|
54
57
|
@font_cache = {}
|
|
55
58
|
@rgb_color_cache = {}
|
|
56
59
|
@nsstring_cache = {}
|
|
@@ -143,6 +146,63 @@ module Echoes
|
|
|
143
146
|
@tabs[@active_tab]
|
|
144
147
|
end
|
|
145
148
|
|
|
149
|
+
# --- Tab drag-and-drop pure-state helpers ---
|
|
150
|
+
#
|
|
151
|
+
# All three live here (as opposed to inside the AppKit closures)
|
|
152
|
+
# so they're testable without standing up a real NSView. The
|
|
153
|
+
# closures call into these; the AppKit half stays a thin wrapper.
|
|
154
|
+
|
|
155
|
+
# Insertion index 0..num_tabs for a cursor at view-x. Splits each
|
|
156
|
+
# tab in half: cursor in the left half of tab i → insert before i;
|
|
157
|
+
# right half → insert before i+1. Clamped at both ends.
|
|
158
|
+
def compute_drop_index(x, tab_w, num_tabs)
|
|
159
|
+
return 0 if num_tabs <= 0 || tab_w <= 0
|
|
160
|
+
((x + tab_w / 2.0) / tab_w).to_i.clamp(0, num_tabs)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Splice a tab between window-state hashes. Updates the destination
|
|
164
|
+
# window's :active_tab to land on the dropped tab. For same-array
|
|
165
|
+
# moves, treats dst==src and dst==src+1 as no-ops (both yield no
|
|
166
|
+
# movement). Returns true on a real move, false on a no-op.
|
|
167
|
+
def transfer_tab(src_ws, src_index, dst_ws, dst_index)
|
|
168
|
+
same = src_ws.equal?(dst_ws)
|
|
169
|
+
return false if same && (dst_index == src_index || dst_index == src_index + 1)
|
|
170
|
+
src_tabs = src_ws[:tabs]
|
|
171
|
+
return false if src_index < 0 || src_index >= src_tabs.size
|
|
172
|
+
|
|
173
|
+
tab = src_tabs.delete_at(src_index)
|
|
174
|
+
# Same-array: removal shifts later positions down by 1.
|
|
175
|
+
dst_index -= 1 if same && src_index < dst_index
|
|
176
|
+
|
|
177
|
+
dst_tabs = dst_ws[:tabs]
|
|
178
|
+
dst_index = dst_index.clamp(0, dst_tabs.size)
|
|
179
|
+
dst_tabs.insert(dst_index, tab)
|
|
180
|
+
|
|
181
|
+
dst_ws[:active_tab] = dst_index
|
|
182
|
+
unless same
|
|
183
|
+
# Source window's active tab should stay in bounds.
|
|
184
|
+
src_ws[:active_tab] = src_ws[:active_tab].clamp(0, [src_tabs.size - 1, 0].max)
|
|
185
|
+
end
|
|
186
|
+
true
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Token format on the pasteboard for tab drag. Single-process app,
|
|
190
|
+
# so identity = view pointer + tab index at the time the drag
|
|
191
|
+
# started. Decoded into integer pair or nil for malformed input.
|
|
192
|
+
def encode_tab_drag_token(view_ptr, tab_index)
|
|
193
|
+
"#{view_ptr}:#{tab_index}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def decode_tab_drag_token(str)
|
|
197
|
+
return nil if str.nil? || str.empty?
|
|
198
|
+
parts = str.split(':')
|
|
199
|
+
return nil unless parts.size == 2
|
|
200
|
+
vp = Integer(parts[0], 10) rescue nil
|
|
201
|
+
ti = Integer(parts[1], 10) rescue nil
|
|
202
|
+
return nil if vp.nil? || ti.nil? || ti < 0
|
|
203
|
+
[vp, ti]
|
|
204
|
+
end
|
|
205
|
+
|
|
146
206
|
# Switch to a tab by 1-based slot number (Cmd+N shortcuts).
|
|
147
207
|
# n=1..8 maps to that tab index; n=9 maps to the LAST tab
|
|
148
208
|
# regardless of count, matching Safari / Chrome / iTerm2 /
|
|
@@ -188,6 +248,9 @@ module Echoes
|
|
|
188
248
|
ws[:current_event] = @current_event
|
|
189
249
|
ws[:selection_anchor] = @selection_anchor
|
|
190
250
|
ws[:selection_end] = @selection_end
|
|
251
|
+
ws[:drag_start_tab_index] = @drag_start_tab_index
|
|
252
|
+
ws[:drag_start_point] = @drag_start_point
|
|
253
|
+
ws[:drag_insertion_index] = @drag_insertion_index
|
|
191
254
|
ws[:rows] = @rows
|
|
192
255
|
ws[:cols] = @cols
|
|
193
256
|
ws[:focused] = @window_focused
|
|
@@ -207,6 +270,9 @@ module Echoes
|
|
|
207
270
|
@current_event = ws[:current_event]
|
|
208
271
|
@selection_anchor = ws[:selection_anchor]
|
|
209
272
|
@selection_end = ws[:selection_end]
|
|
273
|
+
@drag_start_tab_index = ws[:drag_start_tab_index]
|
|
274
|
+
@drag_start_point = ws[:drag_start_point]
|
|
275
|
+
@drag_insertion_index = ws[:drag_insertion_index]
|
|
210
276
|
@rows = ws[:rows]
|
|
211
277
|
@cols = ws[:cols]
|
|
212
278
|
@window_focused = ws.fetch(:focused, true)
|
|
@@ -879,7 +945,46 @@ module Echoes
|
|
|
879
945
|
@dragging_entered_closure = Fiddle::Closure::BlockCaller.new(
|
|
880
946
|
Fiddle::TYPE_LONG,
|
|
881
947
|
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
|
|
882
|
-
)
|
|
948
|
+
) do |_self, _cmd, sender|
|
|
949
|
+
gui.activate_for_view(_self); gui.update_drag_target(sender)
|
|
950
|
+
rescue => e
|
|
951
|
+
gui.log_crash(e, context: 'draggingEntered')
|
|
952
|
+
1 # fall back to NSDragOperationCopy so file-URL drops still work
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
@dragging_updated_closure = Fiddle::Closure::BlockCaller.new(
|
|
956
|
+
Fiddle::TYPE_LONG,
|
|
957
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
|
|
958
|
+
) do |_self, _cmd, sender|
|
|
959
|
+
gui.activate_for_view(_self); gui.update_drag_target(sender)
|
|
960
|
+
rescue => e
|
|
961
|
+
gui.log_crash(e, context: 'draggingUpdated')
|
|
962
|
+
1
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
@dragging_exited_closure = Fiddle::Closure::BlockCaller.new(
|
|
966
|
+
Fiddle::TYPE_VOID,
|
|
967
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP]
|
|
968
|
+
) do |_self, _cmd, _sender|
|
|
969
|
+
gui.activate_for_view(_self); gui.clear_drag_target
|
|
970
|
+
rescue => e
|
|
971
|
+
gui.log_crash(e, context: 'draggingExited')
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
@dragging_session_source_op_mask_closure = Fiddle::Closure::BlockCaller.new(
|
|
975
|
+
Fiddle::TYPE_LONG,
|
|
976
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG]
|
|
977
|
+
) { |_self, _cmd, _session, _ctx| ObjC::NSDragOperationMove }
|
|
978
|
+
|
|
979
|
+
@dragging_session_ended_closure = Fiddle::Closure::BlockCaller.new(
|
|
980
|
+
Fiddle::TYPE_VOID,
|
|
981
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP,
|
|
982
|
+
Fiddle::TYPE_DOUBLE, Fiddle::TYPE_DOUBLE, Fiddle::TYPE_LONG]
|
|
983
|
+
) do |_self, _cmd, _session, screen_x, screen_y, operation|
|
|
984
|
+
gui.tab_drag_ended(_self, screen_x, screen_y, operation)
|
|
985
|
+
rescue => e
|
|
986
|
+
gui.log_crash(e, context: 'draggingSessionEnded')
|
|
987
|
+
end
|
|
883
988
|
|
|
884
989
|
@perform_drag_closure = Fiddle::Closure::BlockCaller.new(
|
|
885
990
|
Fiddle::TYPE_INT,
|
|
@@ -965,7 +1070,11 @@ module Echoes
|
|
|
965
1070
|
'firstRectForCharacterRange:actualRange:' => ['{CGRect={CGPoint=dd}{CGSize=dd}}@:{_NSRange=QQ}^{_NSRange=QQ}', @first_rect_closure],
|
|
966
1071
|
'characterIndexForPoint:' => ['Q@:{CGPoint=dd}', @char_index_closure],
|
|
967
1072
|
'draggingEntered:' => ['Q@:@', @dragging_entered_closure],
|
|
1073
|
+
'draggingUpdated:' => ['Q@:@', @dragging_updated_closure],
|
|
1074
|
+
'draggingExited:' => ['v@:@', @dragging_exited_closure],
|
|
968
1075
|
'performDragOperation:' => ['c@:@', @perform_drag_closure],
|
|
1076
|
+
'draggingSession:sourceOperationMaskForDraggingContext:' => ['Q@:@Q', @dragging_session_source_op_mask_closure],
|
|
1077
|
+
'draggingSession:endedAtPoint:operation:' => ['v@:@{CGPoint=dd}Q', @dragging_session_ended_closure],
|
|
969
1078
|
})
|
|
970
1079
|
|
|
971
1080
|
# Add NSTextInputClient protocol conformance for IME
|
|
@@ -1269,6 +1378,28 @@ module Echoes
|
|
|
1269
1378
|
draw_y = case mc[:valign]
|
|
1270
1379
|
when 1 then y + block_h - text_h
|
|
1271
1380
|
when 2 then y + (block_h - text_h) / 2.0
|
|
1381
|
+
when 3
|
|
1382
|
+
# Baseline-align mode (Echoes extension). All
|
|
1383
|
+
# multicells on the same anchor row that share
|
|
1384
|
+
# the same `s=` end up with their baselines on
|
|
1385
|
+
# the same y, regardless of font / scale / n/d.
|
|
1386
|
+
# Target baseline sits at "last cell's natural
|
|
1387
|
+
# baseline within the block" — i.e. the bottom
|
|
1388
|
+
# row of the block, treated as if it were a
|
|
1389
|
+
# single body cell with @font's natural metrics.
|
|
1390
|
+
# Concretely: baseline_y = block_top +
|
|
1391
|
+
# block_h - cell_h + @font_default_ascender.
|
|
1392
|
+
# We solve for draw_y from "drawAtPoint sets
|
|
1393
|
+
# line-box top, baseline = top + ascender":
|
|
1394
|
+
# draw_y = baseline_y - scaled_ascender.
|
|
1395
|
+
# Big and small spans pick different draw_y
|
|
1396
|
+
# values to land on the SAME baseline_y.
|
|
1397
|
+
# For tall scaled text whose ascender exceeds
|
|
1398
|
+
# block_h - cell_h, draw_y goes negative within
|
|
1399
|
+
# the block and the glyph top extends above —
|
|
1400
|
+
# which matches how a real baseline-shared row
|
|
1401
|
+
# with mixed sizes is supposed to look.
|
|
1402
|
+
y + block_h - @cell_height + @font_default_ascender - scaled_ascender
|
|
1272
1403
|
else y
|
|
1273
1404
|
end
|
|
1274
1405
|
|
|
@@ -1831,10 +1962,15 @@ module Echoes
|
|
|
1831
1962
|
|
|
1832
1963
|
if pos.nil?
|
|
1833
1964
|
# Click in tab bar
|
|
1834
|
-
click_x, = event_location(event_ptr)
|
|
1965
|
+
click_x, click_y = event_location(event_ptr)
|
|
1835
1966
|
tab_w = (@cell_width * @cols) / @tabs.size
|
|
1836
1967
|
clicked_tab = (click_x / tab_w).to_i.clamp(0, @tabs.size - 1)
|
|
1837
1968
|
@active_tab = clicked_tab
|
|
1969
|
+
# Record drag intent — mouseDragged checks whether the cursor
|
|
1970
|
+
# has moved past a small threshold and, if so, hands the
|
|
1971
|
+
# gesture to AppKit's drag session.
|
|
1972
|
+
@drag_start_tab_index = clicked_tab
|
|
1973
|
+
@drag_start_point = [click_x, click_y]
|
|
1838
1974
|
elsif (flags & ObjC::NSEventModifierFlagCommand) != 0 && pos
|
|
1839
1975
|
# Cmd+click: open hyperlink/URL if the cell has one; otherwise
|
|
1840
1976
|
# in an embedded pane, recall the command at this prompt row
|
|
@@ -1895,9 +2031,28 @@ module Echoes
|
|
|
1895
2031
|
ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
|
|
1896
2032
|
end
|
|
1897
2033
|
|
|
2034
|
+
DRAG_THRESHOLD_PX = 4
|
|
2035
|
+
|
|
1898
2036
|
def mouse_dragged(event_ptr)
|
|
1899
2037
|
tab = current_tab
|
|
1900
2038
|
return unless tab
|
|
2039
|
+
|
|
2040
|
+
# Tab-drag intent: if the press landed on the tab bar AND the
|
|
2041
|
+
# cursor has moved past a small threshold, hand the gesture to
|
|
2042
|
+
# AppKit's drag session. Tested first because the threshold can
|
|
2043
|
+
# fire while pos is still nil (cursor still inside the tab bar).
|
|
2044
|
+
if @drag_start_tab_index
|
|
2045
|
+
cx, cy = event_location(event_ptr)
|
|
2046
|
+
sx, sy = @drag_start_point
|
|
2047
|
+
if (cx - sx).abs > DRAG_THRESHOLD_PX || (cy - sy).abs > DRAG_THRESHOLD_PX
|
|
2048
|
+
tab_index = @drag_start_tab_index
|
|
2049
|
+
@drag_start_tab_index = nil
|
|
2050
|
+
@drag_start_point = nil
|
|
2051
|
+
start_tab_drag(event_ptr, tab_index)
|
|
2052
|
+
return
|
|
2053
|
+
end
|
|
2054
|
+
end
|
|
2055
|
+
|
|
1901
2056
|
pos = grid_position(event_ptr)
|
|
1902
2057
|
return unless pos
|
|
1903
2058
|
|
|
@@ -1945,6 +2100,11 @@ module Echoes
|
|
|
1945
2100
|
end
|
|
1946
2101
|
|
|
1947
2102
|
def mouse_up(event_ptr)
|
|
2103
|
+
# Clear tab-drag intent so a click-without-drag doesn't leave
|
|
2104
|
+
# stale state for the next mouseDown.
|
|
2105
|
+
@drag_start_tab_index = nil
|
|
2106
|
+
@drag_start_point = nil
|
|
2107
|
+
|
|
1948
2108
|
tab = current_tab
|
|
1949
2109
|
return unless tab
|
|
1950
2110
|
return if tab.screen.mouse_tracking == :off || tab.screen.mouse_tracking == :x10
|
|
@@ -2151,6 +2311,20 @@ module Echoes
|
|
|
2151
2311
|
|
|
2152
2312
|
def perform_drag_operation(sender)
|
|
2153
2313
|
pb = ObjC::MSG_PTR.call(sender, ObjC.sel('draggingPasteboard'))
|
|
2314
|
+
|
|
2315
|
+
# Our private tab-drag type takes precedence — the user is
|
|
2316
|
+
# moving a tab, not pasting a file.
|
|
2317
|
+
tab_type = ObjC.nsstring(ObjC::EchoesPasteboardTypeTab)
|
|
2318
|
+
tab_type_array = ObjC::MSG_PTR_1.call(ObjC.cls('NSArray'),
|
|
2319
|
+
ObjC.sel('arrayWithObject:'), tab_type)
|
|
2320
|
+
avail = ObjC::MSG_PTR_1.call(pb, ObjC.sel('availableTypeFromArray:'),
|
|
2321
|
+
tab_type_array)
|
|
2322
|
+
unless avail.null?
|
|
2323
|
+
token = ObjC::MSG_PTR_1.call(pb, ObjC.sel('stringForType:'), tab_type)
|
|
2324
|
+
return handle_tab_drop(sender, ObjC.to_ruby_string(token))
|
|
2325
|
+
end
|
|
2326
|
+
|
|
2327
|
+
# Fall through to the file-URL path.
|
|
2154
2328
|
str = self.class.file_paths_from_pasteboard(pb)
|
|
2155
2329
|
return false if str.nil?
|
|
2156
2330
|
|
|
@@ -2167,6 +2341,71 @@ module Echoes
|
|
|
2167
2341
|
false
|
|
2168
2342
|
end
|
|
2169
2343
|
|
|
2344
|
+
# Resolve a tab-drag token, look up source & target window-states,
|
|
2345
|
+
# splice via transfer_tab, and close the source window if its
|
|
2346
|
+
# last tab moved away. Both windows redraw on success.
|
|
2347
|
+
def handle_tab_drop(sender, token)
|
|
2348
|
+
parsed = decode_tab_drag_token(token)
|
|
2349
|
+
return false unless parsed
|
|
2350
|
+
src_view_ptr, src_index = parsed
|
|
2351
|
+
|
|
2352
|
+
src_ws = @view_to_ws[src_view_ptr]
|
|
2353
|
+
target_ws = @view_to_ws[@view.to_i]
|
|
2354
|
+
return false unless src_ws && target_ws
|
|
2355
|
+
|
|
2356
|
+
# Compute insertion index from the destination view's cursor —
|
|
2357
|
+
# the same calculation update_drag_target did during hover. We
|
|
2358
|
+
# recompute rather than trusting @drag_insertion_index because
|
|
2359
|
+
# the user may have crossed back outside the bar between the
|
|
2360
|
+
# last move event and the drop.
|
|
2361
|
+
dx, dy_window = dragging_location(sender)
|
|
2362
|
+
dy = view_frame_height - dy_window
|
|
2363
|
+
tbh = tab_bar_height
|
|
2364
|
+
bar_y = tab_bar_y
|
|
2365
|
+
if tbh > 0 && dy >= bar_y && dy < bar_y + tbh
|
|
2366
|
+
tab_w = (@cell_width * @cols).to_f / @tabs.size
|
|
2367
|
+
dst_index = compute_drop_index(dx, tab_w, @tabs.size)
|
|
2368
|
+
else
|
|
2369
|
+
# Drop landed in the grid area of the target window — treat as
|
|
2370
|
+
# append. (Tear-out outside any window is handled separately
|
|
2371
|
+
# by the source's endedAtPoint callback.)
|
|
2372
|
+
dst_index = target_ws[:tabs].size
|
|
2373
|
+
end
|
|
2374
|
+
|
|
2375
|
+
moved = transfer_tab(src_ws, src_index, target_ws, dst_index)
|
|
2376
|
+
clear_drag_target
|
|
2377
|
+
# Source-side stash was set by start_tab_drag; the gesture is
|
|
2378
|
+
# resolved, so clear it.
|
|
2379
|
+
src_ws.delete(:dragging_tab_index)
|
|
2380
|
+
return true unless moved
|
|
2381
|
+
|
|
2382
|
+
# target_ws is the live current-view ws (we set it from
|
|
2383
|
+
# @view_to_ws[@view.to_i] above). transfer_tab updated the
|
|
2384
|
+
# saved-state form (ws[:active_tab]); we still need to mirror
|
|
2385
|
+
# it into the live @active_tab ivar so the on-screen active
|
|
2386
|
+
# tab actually follows the dropped tab. Without this, the
|
|
2387
|
+
# window keeps showing whatever was active before the drop.
|
|
2388
|
+
@active_tab = target_ws[:active_tab]
|
|
2389
|
+
|
|
2390
|
+
# If source emptied, close that window. close_current_window
|
|
2391
|
+
# operates on the currently-active view, so switch to the
|
|
2392
|
+
# source view first, then switch back.
|
|
2393
|
+
if src_ws[:tabs].empty? && src_ws[:nsview] && src_view_ptr != @view.to_i
|
|
2394
|
+
save_window_state
|
|
2395
|
+
load_window_state(src_ws)
|
|
2396
|
+
close_current_window
|
|
2397
|
+
load_window_state(target_ws)
|
|
2398
|
+
end
|
|
2399
|
+
|
|
2400
|
+
# Redraw both views. The target view is @view; mark the source
|
|
2401
|
+
# view's NSView too (no-op if it was just closed above).
|
|
2402
|
+
ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
|
|
2403
|
+
if src_ws != target_ws && src_ws[:nsview] && !src_ws[:tabs].empty?
|
|
2404
|
+
ObjC::MSG_VOID_I.call(src_ws[:nsview], ObjC.sel('setNeedsDisplay:'), 1)
|
|
2405
|
+
end
|
|
2406
|
+
true
|
|
2407
|
+
end
|
|
2408
|
+
|
|
2170
2409
|
def self.file_paths_from_pasteboard(pb)
|
|
2171
2410
|
nsurl_class = ObjC.cls('NSURL')
|
|
2172
2411
|
class_array = ObjC::MSG_PTR_1.call(ObjC.cls('NSArray'), ObjC.sel('arrayWithObject:'), nsurl_class)
|
|
@@ -2223,9 +2462,10 @@ module Echoes
|
|
|
2223
2462
|
0.0, 0.0, win_width, win_height
|
|
2224
2463
|
)
|
|
2225
2464
|
|
|
2226
|
-
# Register for file drag-and-drop
|
|
2227
|
-
|
|
2228
|
-
|
|
2465
|
+
# Register for file drag-and-drop and the Echoes tab-drag type
|
|
2466
|
+
# so the window accepts both Finder file drops and tabs dragged
|
|
2467
|
+
# from any other Echoes window.
|
|
2468
|
+
register_drag_types(new_view)
|
|
2229
2469
|
|
|
2230
2470
|
# Connect view to window and show it. makeKeyAndOrderFront: triggers a
|
|
2231
2471
|
# focus_lost handler on the prior key window that may call
|
|
@@ -2266,6 +2506,9 @@ module Echoes
|
|
|
2266
2506
|
@current_event = nil
|
|
2267
2507
|
@selection_anchor = nil
|
|
2268
2508
|
@selection_end = nil
|
|
2509
|
+
@drag_start_tab_index = nil
|
|
2510
|
+
@drag_start_point = nil
|
|
2511
|
+
@drag_insertion_index = nil
|
|
2269
2512
|
@window_focused = true
|
|
2270
2513
|
|
|
2271
2514
|
# Register window state
|
|
@@ -2584,69 +2827,265 @@ module Echoes
|
|
|
2584
2827
|
total_w = @cell_width * @cols
|
|
2585
2828
|
tab_w = total_w / @tabs.size
|
|
2586
2829
|
|
|
2587
|
-
#
|
|
2830
|
+
# Bar background — slightly wider than tabs.size * tab_w so the
|
|
2831
|
+
# gap on the right (from integer division) is covered.
|
|
2588
2832
|
ObjC::MSG_VOID.call(@tab_bg, ObjC.sel('setFill'))
|
|
2589
2833
|
ObjC::NSRectFill.call(0.0, ty, total_w + @cell_width, tbh)
|
|
2590
2834
|
|
|
2591
|
-
|
|
2592
|
-
|
|
2835
|
+
ns_para = build_tab_paragraph_style
|
|
2836
|
+
@tabs.each_with_index do |tab, i|
|
|
2837
|
+
paint_one_tab(tab, i * tab_w, ty, tab_w, tbh, i == @active_tab, ns_para)
|
|
2838
|
+
end
|
|
2839
|
+
ObjC.release(ns_para)
|
|
2593
2840
|
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2841
|
+
# Insertion marker: a thin vertical accent strip where the
|
|
2842
|
+
# in-flight tab would land if dropped now. Set in
|
|
2843
|
+
# update_drag_target while a tab drag is hovering over the bar.
|
|
2844
|
+
if @drag_insertion_index
|
|
2845
|
+
marker_w = 2.0
|
|
2846
|
+
marker_x = (@drag_insertion_index * tab_w) - (marker_w / 2.0)
|
|
2847
|
+
accent_color = make_color(*@active_profile.cursor_color)
|
|
2848
|
+
ObjC::MSG_VOID.call(accent_color, ObjC.sel('setFill'))
|
|
2849
|
+
ObjC::NSRectFill.call(marker_x, ty, marker_w, tbh)
|
|
2850
|
+
end
|
|
2851
|
+
end
|
|
2597
2852
|
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2853
|
+
# Truncating-tail + center-aligned paragraph style — created once
|
|
2854
|
+
# per draw and shared across every tab's attributes dict. Caller
|
|
2855
|
+
# owns the +1 retain count and must release.
|
|
2856
|
+
def build_tab_paragraph_style
|
|
2602
2857
|
ns_para = ObjC::MSG_PTR.call(ObjC.cls('NSMutableParagraphStyle'), ObjC.sel('alloc'))
|
|
2603
2858
|
ns_para = ObjC::MSG_PTR.call(ns_para, ObjC.sel('init'))
|
|
2604
2859
|
ObjC::MSG_VOID_L.call(ns_para, ObjC.sel('setLineBreakMode:'), NSLineBreakByTruncatingTail)
|
|
2605
2860
|
ObjC::MSG_VOID_L.call(ns_para, ObjC.sel('setAlignment:'), NSTextAlignmentCenter)
|
|
2861
|
+
ns_para
|
|
2862
|
+
end
|
|
2863
|
+
|
|
2864
|
+
# Paint one tab at (x, y) in the current graphics context: bg
|
|
2865
|
+
# fill, truncated/centered label, and a bottom accent strip when
|
|
2866
|
+
# active. Used both by draw_tab_bar (within the live bar) and by
|
|
2867
|
+
# tab_drag_image (against an NSImage's locked-focus context).
|
|
2868
|
+
# The per-tab bg fill overdraws the bar-wide fill for the live
|
|
2869
|
+
# case — harmless, same color — and stands in for it for the
|
|
2870
|
+
# drag-image case where there's no bar.
|
|
2871
|
+
def paint_one_tab(tab, x, y, tab_w, tbh, is_active, ns_para)
|
|
2872
|
+
ObjC::MSG_VOID.call(@tab_bg, ObjC.sel('setFill'))
|
|
2873
|
+
ObjC::NSRectFill.call(x, y, tab_w, tbh)
|
|
2606
2874
|
|
|
2607
2875
|
pad = @cell_width * 0.5
|
|
2608
|
-
rect_w = tab_w - 2 * pad
|
|
2609
|
-
|
|
2876
|
+
rect_w = [tab_w - 2 * pad, 0.0].max
|
|
2877
|
+
title_y = y + (tbh - @tab_font_line_height) / 2.0
|
|
2878
|
+
|
|
2879
|
+
ns_label = ObjC.nsstring(tab.title)
|
|
2880
|
+
ns_attrs = ObjC.nsdict({
|
|
2881
|
+
ObjC::NSFontAttributeName => @tab_font,
|
|
2882
|
+
ObjC::NSForegroundColorAttributeName => is_active ? @tab_fg_active : @tab_fg_inactive,
|
|
2883
|
+
ObjC::NSParagraphStyleAttributeName => ns_para,
|
|
2884
|
+
})
|
|
2885
|
+
# NSAttributedString draw (vs NSString) — only this path honors
|
|
2886
|
+
# the paragraph style's center alignment + truncating-tail mode.
|
|
2887
|
+
# See the NSStringDrawing alignment notes earlier in this file.
|
|
2888
|
+
attr_str = ObjC::MSG_PTR.call(ObjC.cls('NSAttributedString'), ObjC.sel('alloc'))
|
|
2889
|
+
attr_str = ObjC::MSG_PTR_2.call(attr_str,
|
|
2890
|
+
ObjC.sel('initWithString:attributes:'), ns_label, ns_attrs)
|
|
2891
|
+
ObjC::MSG_VOID_RECT_L_1.call(attr_str,
|
|
2892
|
+
ObjC.sel('drawWithRect:options:context:'),
|
|
2893
|
+
x + pad, title_y, rect_w, @tab_font_line_height,
|
|
2894
|
+
NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingTruncatesLastVisibleLine,
|
|
2895
|
+
Fiddle::Pointer.new(0))
|
|
2896
|
+
ObjC.release(attr_str)
|
|
2897
|
+
|
|
2898
|
+
if is_active
|
|
2899
|
+
accent_color = make_color(*@active_profile.cursor_color)
|
|
2900
|
+
accent_h = 2.0
|
|
2901
|
+
accent_inset = @cell_width * 0.5
|
|
2902
|
+
ObjC::MSG_VOID.call(accent_color, ObjC.sel('setFill'))
|
|
2903
|
+
ObjC::NSRectFill.call(x + accent_inset, y + tbh - accent_h,
|
|
2904
|
+
tab_w - 2 * accent_inset, accent_h)
|
|
2905
|
+
end
|
|
2906
|
+
end
|
|
2610
2907
|
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2908
|
+
# Build and kick off an AppKit drag session for tab_index. The
|
|
2909
|
+
# session is identified on the pasteboard via our private
|
|
2910
|
+
# `com.echoes.tab` type carrying a `<view_ptr>:<tab_index>` token,
|
|
2911
|
+
# so the destination's performDragOperation: can resolve the
|
|
2912
|
+
# source window-state and splice the tab.
|
|
2913
|
+
def start_tab_drag(event_ptr, tab_index)
|
|
2914
|
+
return unless @view && tab_index >= 0 && tab_index < @tabs.size
|
|
2915
|
+
tbh = tab_bar_height
|
|
2916
|
+
return if tbh <= 0
|
|
2917
|
+
tab_w = (@cell_width * @cols).to_f / @tabs.size
|
|
2918
|
+
ty = tab_bar_y
|
|
2919
|
+
|
|
2920
|
+
token = encode_tab_drag_token(@view.to_i, tab_index)
|
|
2921
|
+
|
|
2922
|
+
pb_item = ObjC::MSG_PTR.call(ObjC.cls('NSPasteboardItem'), ObjC.sel('alloc'))
|
|
2923
|
+
pb_item = ObjC::MSG_PTR.call(pb_item, ObjC.sel('init'))
|
|
2924
|
+
# setString:forType: returns BOOL — we ignore the return.
|
|
2925
|
+
ObjC::MSG_PTR_2.call(pb_item, ObjC.sel('setString:forType:'),
|
|
2926
|
+
ObjC.nsstring(token),
|
|
2927
|
+
ObjC.nsstring(ObjC::EchoesPasteboardTypeTab))
|
|
2928
|
+
|
|
2929
|
+
drag_item = ObjC::MSG_PTR.call(ObjC.cls('NSDraggingItem'), ObjC.sel('alloc'))
|
|
2930
|
+
drag_item = ObjC::MSG_PTR_1.call(drag_item, ObjC.sel('initWithPasteboardWriter:'),
|
|
2931
|
+
pb_item)
|
|
2932
|
+
|
|
2933
|
+
image = tab_drag_image(tab_index)
|
|
2934
|
+
ObjC::MSG_VOID_RECT_1.call(drag_item, ObjC.sel('setDraggingFrame:contents:'),
|
|
2935
|
+
tab_index * tab_w, ty, tab_w, tbh,
|
|
2936
|
+
image || Fiddle::Pointer.new(0))
|
|
2937
|
+
|
|
2938
|
+
items = ObjC::MSG_PTR_1.call(ObjC.cls('NSArray'),
|
|
2939
|
+
ObjC.sel('arrayWithObject:'), drag_item)
|
|
2940
|
+
|
|
2941
|
+
# AppKit owns the gesture from here until endedAtPoint:operation:.
|
|
2942
|
+
ObjC::MSG_PTR_3.call(@view,
|
|
2943
|
+
ObjC.sel('beginDraggingSessionWithItems:event:source:'),
|
|
2944
|
+
items, event_ptr, @view)
|
|
2945
|
+
|
|
2946
|
+
# Stash on the source window-state so the source-side
|
|
2947
|
+
# endedAtPoint:operation: callback (which fires on this view
|
|
2948
|
+
# when the session ends) knows which tab was dragged — needed
|
|
2949
|
+
# for the tear-out path that has to remove it from @tabs.
|
|
2950
|
+
ws = @view_to_ws[@view.to_i]
|
|
2951
|
+
ws[:dragging_tab_index] = tab_index if ws
|
|
2614
2952
|
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
ObjC::
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
# the active tab — replaces the previous heavier full-cell
|
|
2640
|
-
# background fill. Inset on each side so it reads as the
|
|
2641
|
-
# tab's own marker rather than a continuous line.
|
|
2642
|
-
if is_active
|
|
2643
|
-
ObjC::MSG_VOID.call(accent_color, ObjC.sel('setFill'))
|
|
2644
|
-
ObjC::NSRectFill.call(x + accent_inset, ty + tbh - accent_h,
|
|
2645
|
-
tab_w - 2 * accent_inset, accent_h)
|
|
2646
|
-
end
|
|
2953
|
+
# Our alloc+init gave us +1; the drag session / pasteboard /
|
|
2954
|
+
# array retain internally for their own lifetimes. Drop ours.
|
|
2955
|
+
ObjC.release(pb_item)
|
|
2956
|
+
ObjC.release(drag_item)
|
|
2957
|
+
ObjC.release(image) if image
|
|
2958
|
+
end
|
|
2959
|
+
|
|
2960
|
+
# Called from draggingEntered:/draggingUpdated: on the destination
|
|
2961
|
+
# view. Inspects the dragging pasteboard, computes a drop-index
|
|
2962
|
+
# if the cursor is hovering over the tab bar, and returns the
|
|
2963
|
+
# NSDragOperation we'd accept on drop. Triggers a redraw so the
|
|
2964
|
+
# insertion-marker line reflects the latest cursor position.
|
|
2965
|
+
def update_drag_target(sender)
|
|
2966
|
+
pb = ObjC::MSG_PTR.call(sender, ObjC.sel('draggingPasteboard'))
|
|
2967
|
+
type = ObjC.nsstring(ObjC::EchoesPasteboardTypeTab)
|
|
2968
|
+
has_tab_type = !ObjC::MSG_PTR_1.call(pb, ObjC.sel('availableTypeFromArray:'),
|
|
2969
|
+
ObjC::MSG_PTR_1.call(ObjC.cls('NSArray'), ObjC.sel('arrayWithObject:'), type)
|
|
2970
|
+
).null?
|
|
2971
|
+
|
|
2972
|
+
unless has_tab_type
|
|
2973
|
+
# Not our type — fall back to the file-drop behavior so Finder
|
|
2974
|
+
# drags keep working.
|
|
2975
|
+
clear_drag_target
|
|
2976
|
+
return ObjC::NSDragOperationCopy
|
|
2647
2977
|
end
|
|
2648
2978
|
|
|
2649
|
-
|
|
2979
|
+
dx, dy_window = dragging_location(sender)
|
|
2980
|
+
vfh = view_frame_height
|
|
2981
|
+
dy = vfh - dy_window
|
|
2982
|
+
tbh = tab_bar_height
|
|
2983
|
+
bar_y = tab_bar_y
|
|
2984
|
+
if tbh <= 0 || dy < bar_y || dy >= bar_y + tbh
|
|
2985
|
+
# Cursor isn't over the tab bar — refuse the drop so AppKit
|
|
2986
|
+
# shows the "no entry" cursor and the user knows to aim
|
|
2987
|
+
# for the bar. (Tear-out path handles drops OUTSIDE the
|
|
2988
|
+
# window via the source-side endedAtPoint callback.)
|
|
2989
|
+
clear_drag_target
|
|
2990
|
+
return ObjC::NSDragOperationNone
|
|
2991
|
+
end
|
|
2992
|
+
|
|
2993
|
+
tab_w = (@cell_width * @cols).to_f / @tabs.size
|
|
2994
|
+
@drag_insertion_index = compute_drop_index(dx, tab_w, @tabs.size)
|
|
2995
|
+
ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
|
|
2996
|
+
ObjC::NSDragOperationMove
|
|
2997
|
+
end
|
|
2998
|
+
|
|
2999
|
+
def clear_drag_target
|
|
3000
|
+
return unless @drag_insertion_index
|
|
3001
|
+
@drag_insertion_index = nil
|
|
3002
|
+
ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1) if @view
|
|
3003
|
+
end
|
|
3004
|
+
|
|
3005
|
+
# Pull NSPoint out of [sender draggingLocation] via NSInvocation —
|
|
3006
|
+
# same trick as event_location uses for NSEvent.locationInWindow,
|
|
3007
|
+
# because Fiddle can't model a 2-double return directly.
|
|
3008
|
+
def dragging_location(sender)
|
|
3009
|
+
sender_class = ObjC::MSG_PTR.call(sender, ObjC.sel('class'))
|
|
3010
|
+
sig = ObjC::MSG_PTR_1.call(sender_class,
|
|
3011
|
+
ObjC.sel('instanceMethodSignatureForSelector:'),
|
|
3012
|
+
ObjC.sel('draggingLocation'))
|
|
3013
|
+
inv = ObjC::MSG_PTR_1.call(ObjC.cls('NSInvocation'),
|
|
3014
|
+
ObjC.sel('invocationWithMethodSignature:'), sig)
|
|
3015
|
+
ObjC::MSG_VOID_1.call(inv, ObjC.sel('setSelector:'), ObjC.sel('draggingLocation'))
|
|
3016
|
+
ObjC::MSG_VOID_1.call(inv, ObjC.sel('invokeWithTarget:'), sender)
|
|
3017
|
+
buf = Fiddle::Pointer.malloc(16, Fiddle::RUBY_FREE)
|
|
3018
|
+
ObjC::MSG_VOID_1.call(inv, ObjC.sel('getReturnValue:'), buf)
|
|
3019
|
+
buf[0, 16].unpack('dd')
|
|
3020
|
+
end
|
|
3021
|
+
|
|
3022
|
+
# Source-side callback fired when AppKit's drag session ends.
|
|
3023
|
+
# operation != NSDragOperationNone means some destination
|
|
3024
|
+
# accepted the drop (handle_tab_drop already moved the tab) —
|
|
3025
|
+
# nothing to do here. operation == NSDragOperationNone means
|
|
3026
|
+
# the user dropped outside any drop target, which is our tear-
|
|
3027
|
+
# out signal: spawn a new window around the dragged tab and
|
|
3028
|
+
# remove it from the source window.
|
|
3029
|
+
def tab_drag_ended(source_view_ptr, screen_x, screen_y, operation)
|
|
3030
|
+
src_ws = @view_to_ws[source_view_ptr.to_i]
|
|
3031
|
+
return unless src_ws
|
|
3032
|
+
|
|
3033
|
+
tab_index = src_ws.delete(:dragging_tab_index)
|
|
3034
|
+
return unless operation == ObjC::NSDragOperationNone
|
|
3035
|
+
return unless tab_index && tab_index >= 0 && tab_index < src_ws[:tabs].size
|
|
3036
|
+
|
|
3037
|
+
tab = src_ws[:tabs].delete_at(tab_index)
|
|
3038
|
+
src_ws[:active_tab] = src_ws[:active_tab].clamp(
|
|
3039
|
+
0, [src_ws[:tabs].size - 1, 0].max)
|
|
3040
|
+
|
|
3041
|
+
# Switch @view to the source so open_window_with_tab's
|
|
3042
|
+
# save_window_state captures source frame (we read its size
|
|
3043
|
+
# to position the new window).
|
|
3044
|
+
if @view.to_i != source_view_ptr.to_i
|
|
3045
|
+
save_window_state
|
|
3046
|
+
load_window_state(src_ws)
|
|
3047
|
+
end
|
|
3048
|
+
|
|
3049
|
+
# Open the new window FIRST so close_current_window (if we
|
|
3050
|
+
# need it) sees a non-empty @window_states and doesn't
|
|
3051
|
+
# terminate the app on a single-tab single-window tear-out.
|
|
3052
|
+
open_window_with_tab(tab, at_screen_point: [screen_x, screen_y])
|
|
3053
|
+
|
|
3054
|
+
if src_ws[:tabs].empty?
|
|
3055
|
+
# @view now points at the new window; flip back to source
|
|
3056
|
+
# and close it.
|
|
3057
|
+
save_window_state
|
|
3058
|
+
load_window_state(src_ws)
|
|
3059
|
+
close_current_window
|
|
3060
|
+
end
|
|
3061
|
+
rescue StandardError => e
|
|
3062
|
+
log_crash(e, context: 'tab_drag_ended')
|
|
3063
|
+
end
|
|
3064
|
+
|
|
3065
|
+
# Render a tab snapshot to an NSImage at the tab's live size.
|
|
3066
|
+
# Passed as the `contents` of an NSDraggingItem so the user sees
|
|
3067
|
+
# the actual tab travel under the cursor during a drag. Returns
|
|
3068
|
+
# the NSImage with a +1 retain count; AppKit's drag session
|
|
3069
|
+
# retains it internally for the session's lifetime.
|
|
3070
|
+
def tab_drag_image(tab_index)
|
|
3071
|
+
return nil if tab_index < 0 || tab_index >= @tabs.size
|
|
3072
|
+
tbh = tab_bar_height
|
|
3073
|
+
return nil if tbh <= 0
|
|
3074
|
+
tab_w = (@cell_width * @cols).to_f / @tabs.size
|
|
3075
|
+
|
|
3076
|
+
image = ObjC::MSG_PTR.call(ObjC.cls('NSImage'), ObjC.sel('alloc'))
|
|
3077
|
+
image = ObjC::MSG_PTR_2D.call(image, ObjC.sel('initWithSize:'), tab_w, tbh)
|
|
3078
|
+
|
|
3079
|
+
ObjC::MSG_VOID.call(image, ObjC.sel('lockFocus'))
|
|
3080
|
+
begin
|
|
3081
|
+
ns_para = build_tab_paragraph_style
|
|
3082
|
+
paint_one_tab(@tabs[tab_index], 0.0, 0.0, tab_w, tbh,
|
|
3083
|
+
tab_index == @active_tab, ns_para)
|
|
3084
|
+
ObjC.release(ns_para)
|
|
3085
|
+
ensure
|
|
3086
|
+
ObjC::MSG_VOID.call(image, ObjC.sel('unlockFocus'))
|
|
3087
|
+
end
|
|
3088
|
+
image
|
|
2650
3089
|
end
|
|
2651
3090
|
|
|
2652
3091
|
def grid_position(event_ptr)
|
|
@@ -3269,8 +3708,6 @@ module Echoes
|
|
|
3269
3708
|
# standard tab/pane/window-state pipeline so the window
|
|
3270
3709
|
# auto-closes when the child program exits.
|
|
3271
3710
|
def open_external_window(argv:, display_index:, fullscreen:)
|
|
3272
|
-
save_window_state
|
|
3273
|
-
|
|
3274
3711
|
screens = ObjC::MSG_PTR.call(ObjC.cls('NSScreen'), ObjC.sel('screens'))
|
|
3275
3712
|
count = ObjC::MSG_RET_L.call(screens, ObjC.sel('count'))
|
|
3276
3713
|
return if display_index < 0 || display_index >= count
|
|
@@ -3289,30 +3726,66 @@ module Echoes
|
|
|
3289
3726
|
tab.title = File.basename(argv.first.to_s)
|
|
3290
3727
|
tab.panes.each { |pn| wire_screen_handlers(pn) }
|
|
3291
3728
|
|
|
3292
|
-
# Borderless mask (0) for fullscreen; default chrome otherwise.
|
|
3293
3729
|
style_mask = fullscreen ? 0 : ObjC::NSWindowStyleMaskDefault
|
|
3730
|
+
level = fullscreen ? 25 : nil # NSStatusWindowLevel
|
|
3731
|
+
build_window_around_tabs(tabs: [tab], active: 0,
|
|
3732
|
+
frame: [sx, sy, sw, sh], style_mask: style_mask,
|
|
3733
|
+
title: tab.title, level: level)
|
|
3734
|
+
end
|
|
3735
|
+
|
|
3736
|
+
# Tear-out: build a new window around an EXISTING tab (already
|
|
3737
|
+
# has live PTY / panes) and place it so its top-left lands near
|
|
3738
|
+
# the cursor drop point.
|
|
3739
|
+
def open_window_with_tab(tab, at_screen_point:)
|
|
3740
|
+
# Inherit the source window's content size so the moved tab
|
|
3741
|
+
# doesn't get re-laid-out at a different size mid-PTY-session.
|
|
3742
|
+
_, _, sw, sh = nsrect_via_invocation(@window, 'frame')
|
|
3743
|
+
|
|
3744
|
+
# Screen / visibleFrame for clamping. Position the new window so
|
|
3745
|
+
# its TOP-LEFT lands at the drop point — AppKit window origin
|
|
3746
|
+
# is bottom-left in screen coords, so subtract height for y.
|
|
3747
|
+
drop_x, drop_y = at_screen_point
|
|
3748
|
+
origin_x = drop_x
|
|
3749
|
+
origin_y = drop_y - sh
|
|
3750
|
+
src_screen = ObjC::MSG_PTR.call(@window, ObjC.sel('screen'))
|
|
3751
|
+
if src_screen && !src_screen.null?
|
|
3752
|
+
vfx, vfy, vfw, vfh = nsrect_via_invocation(src_screen, 'visibleFrame')
|
|
3753
|
+
origin_x = origin_x.clamp(vfx, vfx + vfw - [sw, vfw].min)
|
|
3754
|
+
origin_y = origin_y.clamp(vfy, vfy + vfh - [sh, vfh].min)
|
|
3755
|
+
end
|
|
3756
|
+
|
|
3757
|
+
build_window_around_tabs(tabs: [tab], active: 0,
|
|
3758
|
+
frame: [origin_x, origin_y, sw, sh],
|
|
3759
|
+
style_mask: ObjC::NSWindowStyleMaskDefault,
|
|
3760
|
+
title: tab.title)
|
|
3761
|
+
end
|
|
3762
|
+
|
|
3763
|
+
# Shared NSWindow / NSView / state-registration scaffolding for
|
|
3764
|
+
# every code path that births a new Echoes window — programmatic
|
|
3765
|
+
# open (open_external_window) and tab tear-out (open_window_with_tab).
|
|
3766
|
+
# Caller passes already-built Tab(s); we don't construct or rewire
|
|
3767
|
+
# them, so this works for both fresh-spawned PTYs and live tabs
|
|
3768
|
+
# moved over from another window.
|
|
3769
|
+
def build_window_around_tabs(tabs:, active:, frame:, style_mask:,
|
|
3770
|
+
title: nil, level: nil)
|
|
3771
|
+
save_window_state
|
|
3772
|
+
|
|
3773
|
+
sx, sy, sw, sh = frame
|
|
3294
3774
|
new_window = ObjC::MSG_PTR.call(ObjC.cls('NSWindow'), ObjC.sel('alloc'))
|
|
3295
3775
|
new_window = ObjC::MSG_PTR_RECT_L_L_I.call(
|
|
3296
3776
|
new_window, ObjC.sel('initWithContentRect:styleMask:backing:defer:'),
|
|
3297
3777
|
sx, sy, sw, sh, style_mask, ObjC::NSBackingStoreBuffered, 0
|
|
3298
3778
|
)
|
|
3299
|
-
ObjC::MSG_VOID_1.call(new_window, ObjC.sel('setTitle:'),
|
|
3779
|
+
ObjC::MSG_VOID_1.call(new_window, ObjC.sel('setTitle:'),
|
|
3780
|
+
ObjC.nsstring(title || Echoes.config.window_title))
|
|
3300
3781
|
ObjC::MSG_VOID_L.call(new_window, ObjC.sel('setCollectionBehavior:'), 1 << 7)
|
|
3301
3782
|
ObjC::MSG_VOID_I.call(new_window, ObjC.sel('setAcceptsMouseMovedEvents:'), 1)
|
|
3302
|
-
if
|
|
3303
|
-
# NSMainMenuWindowLevel + 1 = 25 = NSStatusWindowLevel; floats
|
|
3304
|
-
# above the menu bar so the presentation truly fills the
|
|
3305
|
-
# screen even without going through AppKit's fullscreen
|
|
3306
|
-
# transition.
|
|
3307
|
-
ObjC::MSG_VOID_L.call(new_window, ObjC.sel('setLevel:'), 25)
|
|
3308
|
-
end
|
|
3783
|
+
ObjC::MSG_VOID_L.call(new_window, ObjC.sel('setLevel:'), level) if level
|
|
3309
3784
|
|
|
3310
3785
|
new_view = ObjC::MSG_PTR.call(@view_class, ObjC.sel('alloc'))
|
|
3311
3786
|
new_view = ObjC::MSG_PTR_RECT.call(new_view, ObjC.sel('initWithFrame:'),
|
|
3312
3787
|
0.0, 0.0, sw, sh)
|
|
3313
|
-
|
|
3314
|
-
ObjC::NSPasteboardTypeFileURL)
|
|
3315
|
-
ObjC::MSG_VOID_1.call(new_view, ObjC.sel('registerForDraggedTypes:'), drag_types)
|
|
3788
|
+
register_drag_types(new_view)
|
|
3316
3789
|
|
|
3317
3790
|
ObjC::MSG_VOID_1.call(new_window, ObjC.sel('setContentView:'), new_view)
|
|
3318
3791
|
ObjC::MSG_VOID_1.call(new_window, ObjC.sel('makeKeyAndOrderFront:'), @app)
|
|
@@ -3329,8 +3802,8 @@ module Echoes
|
|
|
3329
3802
|
|
|
3330
3803
|
@window = new_window
|
|
3331
3804
|
@view = new_view
|
|
3332
|
-
@tabs =
|
|
3333
|
-
@active_tab =
|
|
3805
|
+
@tabs = tabs
|
|
3806
|
+
@active_tab = active
|
|
3334
3807
|
@search_mode = false
|
|
3335
3808
|
@search_query = +""
|
|
3336
3809
|
@search_matches = []
|
|
@@ -3343,6 +3816,9 @@ module Echoes
|
|
|
3343
3816
|
@selection_anchor = nil
|
|
3344
3817
|
@selection_end = nil
|
|
3345
3818
|
@selection_word_anchor = nil
|
|
3819
|
+
@drag_start_tab_index = nil
|
|
3820
|
+
@drag_start_point = nil
|
|
3821
|
+
@drag_insertion_index = nil
|
|
3346
3822
|
@window_focused = true
|
|
3347
3823
|
|
|
3348
3824
|
ws = {}
|
|
@@ -3351,6 +3827,17 @@ module Echoes
|
|
|
3351
3827
|
save_window_state
|
|
3352
3828
|
end
|
|
3353
3829
|
|
|
3830
|
+
# Register both the file-URL drag type (existing paste-on-drop
|
|
3831
|
+
# behavior) and our private tab-drag type on a fresh view, so the
|
|
3832
|
+
# window can be both a Finder drop target and a tab drop target.
|
|
3833
|
+
def register_drag_types(view)
|
|
3834
|
+
arr = ObjC::MSG_PTR.call(ObjC.cls('NSMutableArray'), ObjC.sel('array'))
|
|
3835
|
+
ObjC::MSG_VOID_1.call(arr, ObjC.sel('addObject:'), ObjC::NSPasteboardTypeFileURL)
|
|
3836
|
+
ObjC::MSG_VOID_1.call(arr, ObjC.sel('addObject:'),
|
|
3837
|
+
ObjC.nsstring(ObjC::EchoesPasteboardTypeTab))
|
|
3838
|
+
ObjC::MSG_VOID_1.call(view, ObjC.sel('registerForDraggedTypes:'), arr)
|
|
3839
|
+
end
|
|
3840
|
+
|
|
3354
3841
|
def capture_pane_to_png(pane, path)
|
|
3355
3842
|
return unless @view
|
|
3356
3843
|
tab = current_tab
|
|
@@ -19,7 +19,14 @@ module Echoes
|
|
|
19
19
|
# so chunks from different panes don't collide.
|
|
20
20
|
module KittyGraphics
|
|
21
21
|
CHUNK_LIMIT_BYTES = 16 * 1024 * 1024
|
|
22
|
-
|
|
22
|
+
# Most-recent N images, LRU. Slide-deck tooling (przn) uploads one
|
|
23
|
+
# image per shape primitive (`<rect>`, `<line>`, etc.) plus regular
|
|
24
|
+
# `<img>` tags, so a single slide easily crosses double digits. 16
|
|
25
|
+
# was a leftover sized for typical TUI clients that only stream a
|
|
26
|
+
# few inline images; bumping to 128 keeps memory bounded
|
|
27
|
+
# (~50 MB at typical slide-image sizes) while comfortably handling
|
|
28
|
+
# a busy slide and a few previous slides' images cached in.
|
|
29
|
+
CACHE_LIMIT = 128
|
|
23
30
|
DEFAULT_FORMAT = '100'.freeze # PNG
|
|
24
31
|
|
|
25
32
|
module_function
|
data/lib/echoes/objc.rb
CHANGED
|
@@ -82,6 +82,14 @@ module Echoes
|
|
|
82
82
|
# scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:
|
|
83
83
|
MSG_PTR_D_P_P_P_I = new_msg([P, P, D, P, P, P, I], P)
|
|
84
84
|
|
|
85
|
+
# draggingSession:endedAtPoint:operation: — NSDraggingSession*,
|
|
86
|
+
# NSPoint (2 doubles), NSDragOperation (NSUInteger -> long).
|
|
87
|
+
MSG_VOID_1_PT_L = new_msg([P, P, P, D, D, L], V)
|
|
88
|
+
# draggingSession:sourceOperationMaskForDraggingContext: —
|
|
89
|
+
# NSDraggingSession*, NSDraggingContext (NSInteger), returns
|
|
90
|
+
# NSDragOperation (NSUInteger).
|
|
91
|
+
MSG_RET_L_1L = new_msg([P, P, P, L], L)
|
|
92
|
+
|
|
85
93
|
# NSRectFill C function
|
|
86
94
|
NSRectFill = Fiddle::Function.new(APPKIT['NSRectFill'], [D, D, D, D], V)
|
|
87
95
|
|
|
@@ -100,6 +108,16 @@ module Echoes
|
|
|
100
108
|
NSEventModifierFlagCommand = 1 << 20
|
|
101
109
|
NSEventModifierFlagNumericPad = 1 << 21
|
|
102
110
|
|
|
111
|
+
# NSDragOperation bitmask values. We only ever signal Move /
|
|
112
|
+
# None for tab drag; the file-drop path keeps using Copy.
|
|
113
|
+
NSDragOperationNone = 0
|
|
114
|
+
NSDragOperationCopy = 1
|
|
115
|
+
NSDragOperationMove = 16
|
|
116
|
+
|
|
117
|
+
# Echoes-private pasteboard type for tab drag. Plain string;
|
|
118
|
+
# AppKit accepts arbitrary UTI-shaped identifiers.
|
|
119
|
+
EchoesPasteboardTypeTab = 'com.echoes.tab'
|
|
120
|
+
|
|
103
121
|
# Selector cache
|
|
104
122
|
SEL_CACHE = {}
|
|
105
123
|
|
data/lib/echoes/parser.rb
CHANGED
|
@@ -509,7 +509,7 @@ module Echoes
|
|
|
509
509
|
when 'w' then params[:width] = v.to_i.clamp(0, 7)
|
|
510
510
|
when 'n' then params[:frac_n] = v.to_i.clamp(0, 15)
|
|
511
511
|
when 'd' then params[:frac_d] = v.to_i.clamp(0, 15)
|
|
512
|
-
when 'v' then params[:valign] = v.to_i.clamp(0,
|
|
512
|
+
when 'v' then params[:valign] = v.to_i.clamp(0, 3)
|
|
513
513
|
when 'h' then params[:halign] = v.to_i.clamp(0, 2)
|
|
514
514
|
when 'f'
|
|
515
515
|
# Family name. Names with ':' aren't representable here
|
data/lib/echoes/version.rb
CHANGED