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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5125e5e42d6d0a336df431a944a75f07ba2b90ba91a3996f0a9fe9e634878c62
4
- data.tar.gz: 7f1b2ea05ad61fe36922eef9da79fa04db3446b0019b2a25d2b0a5d4ad555205
3
+ metadata.gz: f5fa8600adc9387da347fcf6388529566e2f991c7d80718d031e350d8331ad38
4
+ data.tar.gz: 8ceb984a585917dfb84333edc142a47e7b07eb85e41cc0239a4624c4cf6f4a41
5
5
  SHA512:
6
- metadata.gz: d22756dcafcec61e3d73a87ce3788bd928edea5eb3a3eb137ec6481da5d1faeda999b86850c1ae34f826c8eb57d876f1cebfb560ceacd85c49a3716bea98a1a3
7
- data.tar.gz: 219539e6e1b55c53ce7690740b3f891a41034dd1df3275d8a8db7bcc2b6739f637236e9484a415d5e82e12a89b1c54d82251148f6faad130d09bc91caadd94f2
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
- ) { |_self, _cmd, _sender| 1 } # NSDragOperationCopy
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
- drag_types = ObjC::MSG_PTR_1.call(ObjC.cls('NSArray'), ObjC.sel('arrayWithObject:'), ObjC::NSPasteboardTypeFileURL)
2228
- ObjC::MSG_VOID_1.call(new_view, ObjC.sel('registerForDraggedTypes:'), drag_types)
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
- # Tab bar background
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
- # Vertically center titles in the bar.
2592
- title_y = ty + (tbh - @tab_font_line_height) / 2.0
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
- accent_color = make_color(*@active_profile.cursor_color)
2595
- accent_h = 2.0
2596
- accent_inset = @cell_width * 0.5
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
- # Truncating-tail paragraph style so titles wider than the tab
2599
- # render with a trailing "…" instead of overlapping their
2600
- # neighbors. Built once per draw (paragraph style is mutable
2601
- # and shared across the loop's attrs dicts).
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
- rect_w = 0.0 if rect_w < 0
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
- @tabs.each_with_index do |tab, i|
2612
- x = i * tab_w
2613
- is_active = (i == @active_tab)
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
- ns_label = ObjC.nsstring(tab.title)
2616
- ns_attrs = ObjC.nsdict({
2617
- ObjC::NSFontAttributeName => @tab_font,
2618
- ObjC::NSForegroundColorAttributeName => is_active ? @tab_fg_active : @tab_fg_inactive,
2619
- ObjC::NSParagraphStyleAttributeName => ns_para,
2620
- })
2621
- # Build an NSAttributedString rather than passing the dict to
2622
- # NSString's draw methods the NSString-based draw APIs honor
2623
- # font + color but silently ignore paragraph-style alignment,
2624
- # which is why earlier attempts left the truncated text
2625
- # drifted toward one edge instead of centered. NSAttributedString
2626
- # drawWithRect:options:context: routes through the canonical
2627
- # typesetter and respects the alignment + truncating-tail mode.
2628
- attr_str = ObjC::MSG_PTR.call(ObjC.cls('NSAttributedString'), ObjC.sel('alloc'))
2629
- attr_str = ObjC::MSG_PTR_2.call(attr_str,
2630
- ObjC.sel('initWithString:attributes:'), ns_label, ns_attrs)
2631
- ObjC::MSG_VOID_RECT_L_1.call(attr_str,
2632
- ObjC.sel('drawWithRect:options:context:'),
2633
- x + pad, title_y, rect_w, @tab_font_line_height,
2634
- NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingTruncatesLastVisibleLine,
2635
- Fiddle::Pointer.new(0))
2636
- ObjC.release(attr_str)
2637
-
2638
- # Thin accent strip flush to the bottom of the bar marks
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
- ObjC.release(ns_para)
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:'), ObjC.nsstring(tab.title))
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 fullscreen
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
- drag_types = ObjC::MSG_PTR_1.call(ObjC.cls('NSArray'), ObjC.sel('arrayWithObject:'),
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 = [tab]
3333
- @active_tab = 0
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
- CACHE_LIMIT = 16 # most-recent N images, LRU
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, 2)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Echoes
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: echoes
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Akira Matsuda