echoes 0.5.0 → 0.6.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: d12ae8bf0fab5f169c0d380125005490662167338fb529d6c0a1c3daf725a415
4
- data.tar.gz: dabea435d3121c0411048313744738e62f759de537f4afd4e4d28dc378188cea
3
+ metadata.gz: 5125e5e42d6d0a336df431a944a75f07ba2b90ba91a3996f0a9fe9e634878c62
4
+ data.tar.gz: 7f1b2ea05ad61fe36922eef9da79fa04db3446b0019b2a25d2b0a5d4ad555205
5
5
  SHA512:
6
- metadata.gz: c6306b65e5234b7e35cdb5624ae04b6a0585c5a418bbab22ca8d517a9c8aa928c5e6fdcf0b826d333fa73870275046efd16c4519bccf8d35de7959fba8958d08
7
- data.tar.gz: 8481081986ffd00775f18a9fd6a5b8a0f3691e0f030c9f6f601b898f1ffc875d8c266b191ca0489e85e39cf6d7117c19843c68dbf9f2a091ec5fa757addcb5f6
6
+ metadata.gz: d22756dcafcec61e3d73a87ce3788bd928edea5eb3a3eb137ec6481da5d1faeda999b86850c1ae34f826c8eb57d876f1cebfb560ceacd85c49a3716bea98a1a3
7
+ data.tar.gz: 219539e6e1b55c53ce7690740b3f891a41034dd1df3275d8a8db7bcc2b6739f637236e9484a415d5e82e12a89b1c54d82251148f6faad130d09bc91caadd94f2
data/README.md CHANGED
@@ -96,6 +96,8 @@ bundle exec exe/echoes -t # TTY mode
96
96
  | Cmd+Shift+D | Split pane down |
97
97
  | Cmd+] / Cmd+[ | Next / previous pane |
98
98
  | Cmd+Shift+] / Cmd+Shift+[ | Next / previous tab |
99
+ | Cmd+1 … Cmd+8 | Jump to tab 1 … 8 |
100
+ | Cmd+9 | Jump to the last tab |
99
101
  | Cmd++ / Cmd+- / Cmd+0 | Bigger / smaller / reset font |
100
102
  | Cmd+F | Find |
101
103
  | Cmd+G / Cmd+Shift+G | Find next / previous |
@@ -161,9 +163,10 @@ keybind "", :toggle_pointer # disable the default
161
163
  Available actions (one per menu item): `:new_window`, `:new_tab`,
162
164
  `:close_tab`, `:close_pane`, `:edit_file`, `:split_right`, `:split_down`,
163
165
  `:select_next_pane`, `:select_previous_pane`, `:show_next_tab`,
164
- `:show_previous_tab`, `:increase_font_size`, `:decrease_font_size`,
165
- `:reset_font_size`, `:toggle_find`, `:find_next`, `:find_previous`,
166
- `:toggle_pointer`, `:toggle_copy_mode`.
166
+ `:show_previous_tab`, `:select_tab_1` `:select_tab_9`
167
+ (jump-to-tab; `:select_tab_9` is "last tab"), `:increase_font_size`,
168
+ `:decrease_font_size`, `:reset_font_size`, `:toggle_find`,
169
+ `:find_next`, `:find_previous`, `:toggle_pointer`, `:toggle_copy_mode`.
167
170
 
168
171
  Modifier names are case-insensitive and accept the obvious aliases:
169
172
  `Cmd`/`Command`/`Super`, `Ctrl`/`Control`, `Opt`/`Option`/`Alt`, `Shift`.
data/lib/echoes/gui.rb CHANGED
@@ -143,6 +143,20 @@ module Echoes
143
143
  @tabs[@active_tab]
144
144
  end
145
145
 
146
+ # Switch to a tab by 1-based slot number (Cmd+N shortcuts).
147
+ # n=1..8 maps to that tab index; n=9 maps to the LAST tab
148
+ # regardless of count, matching Safari / Chrome / iTerm2 /
149
+ # Ghostty. Returns true when the active tab actually changed
150
+ # (the caller redraws on true), false when the request was a
151
+ # no-op — target out of range or already active.
152
+ def select_tab(n)
153
+ target = (n == 9) ? @tabs.size - 1 : n - 1
154
+ return false if target < 0 || target >= @tabs.size
155
+ return false if target == @active_tab
156
+ @active_tab = target
157
+ true
158
+ end
159
+
146
160
  # Phase 1 launch flag for the in-process Rubish embedding. Setting
147
161
  # ECHOES_EMBED=1 in the environment routes new Tab/Pane creation
148
162
  # through Echoes::EmbeddedShell instead of PTY.spawn.
@@ -323,6 +337,12 @@ module Echoes
323
337
  add_menu_item(window_menu, "Show Next Tab", 'showNextTab:', '}',
324
338
  modifiers: ObjC::NSEventModifierFlagCommand | ObjC::NSEventModifierFlagShift,
325
339
  bind: :show_next_tab)
340
+ # Cmd+1..8 jump to that tab index; Cmd+9 goes to the last tab.
341
+ (1..9).each do |n|
342
+ title = (n == 9) ? "Show Last Tab" : "Show Tab #{n}"
343
+ add_menu_item(window_menu, title, "selectTab#{n}:", n.to_s,
344
+ bind: :"select_tab_#{n}")
345
+ end
326
346
  add_separator(window_menu)
327
347
  add_menu_item(window_menu, "Select Next Pane", 'selectNextPane:', ']', bind: :select_next_pane)
328
348
  add_menu_item(window_menu, "Select Previous Pane", 'selectPreviousPane:', '[', bind: :select_previous_pane)
@@ -435,10 +455,19 @@ module Echoes
435
455
  def create_fonts
436
456
  @font = ObjC.retain(create_nsfont(@font_size))
437
457
  @bold_font = ObjC.retain(create_bold_nsfont(@font))
458
+ @tab_font = ObjC.retain(create_nsfont(tab_font_size))
438
459
  @font_y_offset_cache = {}
439
460
  update_cell_metrics
440
461
  end
441
462
 
463
+ # Tab labels render in a smaller font than the cell grid so long
464
+ # titles read comfortably even when the bar's split across many
465
+ # narrow tabs. Scales with the user's font_size so the bar stays
466
+ # proportional at any zoom.
467
+ def tab_font_size
468
+ [(@font_size * 0.85).round(1), 9.0].max
469
+ end
470
+
442
471
  def create_view_class
443
472
  gui = self
444
473
 
@@ -770,6 +799,15 @@ module Echoes
770
799
  @active_tab = (@active_tab + 1) % @tabs.size
771
800
  ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1)
772
801
  })
802
+ # Cmd+1..8 → tabs 1..8; Cmd+9 → last tab (whichever it is) —
803
+ # matches Safari, Chrome, iTerm2, Ghostty. The indexing /
804
+ # bounds / no-op logic lives in select_tab so it's testable
805
+ # without standing up the closure + menu machinery.
806
+ @select_tab_closures = (1..9).map do |n|
807
+ menu_action.call(-> {
808
+ ObjC::MSG_VOID_I.call(@view, ObjC.sel('setNeedsDisplay:'), 1) if select_tab(n)
809
+ })
810
+ end
773
811
  @split_right_closure = menu_action.call(-> {
774
812
  tab = current_tab
775
813
  new_pane = tab.split_vertical
@@ -902,6 +940,9 @@ module Echoes
902
940
  'findPrevious:' => ['v@:@', @find_prev_closure],
903
941
  'showPreviousTab:' => ['v@:@', @prev_tab_closure],
904
942
  'showNextTab:' => ['v@:@', @next_tab_closure],
943
+ **(1..9).each_with_object({}) { |n, h|
944
+ h["selectTab#{n}:"] = ['v@:@', @select_tab_closures[n - 1]]
945
+ },
905
946
  'splitRight:' => ['v@:@', @split_right_closure],
906
947
  'splitDown:' => ['v@:@', @split_down_closure],
907
948
  'closePane:' => ['v@:@', @close_pane_closure],
@@ -2084,10 +2125,13 @@ module Echoes
2084
2125
  Preferences.set_double(:font_size, new_size) if persist
2085
2126
  old_font = @font
2086
2127
  old_bold = @bold_font
2128
+ old_tab = @tab_font
2087
2129
  @font = ObjC.retain(create_nsfont(@font_size))
2088
2130
  @bold_font = ObjC.retain(create_bold_nsfont(@font))
2131
+ @tab_font = ObjC.retain(create_nsfont(tab_font_size))
2089
2132
  ObjC.release(old_font) if old_font
2090
2133
  ObjC.release(old_bold) if old_bold
2134
+ ObjC.release(old_tab) if old_tab
2091
2135
  @font_cache.each_value { |f| ObjC.release(f) unless f.to_i == old_font&.to_i }
2092
2136
  @font_cache = {}
2093
2137
  @font_y_offset_cache = {}
@@ -2522,6 +2566,20 @@ module Echoes
2522
2566
  ObjC::CGContextRestoreGState.call(cg_ctx)
2523
2567
  end
2524
2568
 
2569
+ NSLineBreakByTruncatingTail = 4
2570
+ # macOS empirically uses { Left=0, Center=1, Right=2 } at runtime
2571
+ # (despite Apple's header comments and several years of doc pages
2572
+ # claiming Center=2 / Right=1 for macOS) — verified by setting and
2573
+ # rendering with each value against an offscreen bitmap context.
2574
+ # Get this wrong and the truncated tab title pins to the right edge.
2575
+ NSTextAlignmentCenter = 1
2576
+ # Engage the modern Cocoa typesetter so the paragraph-style
2577
+ # alignment + line-break-mode actually take effect; the simple
2578
+ # NSString-based draw APIs honor font/color but silently ignore
2579
+ # paragraph styles.
2580
+ NSStringDrawingUsesLineFragmentOrigin = 1
2581
+ NSStringDrawingTruncatesLastVisibleLine = 1 << 5 # 32
2582
+
2525
2583
  def draw_tab_bar(tbh, ty)
2526
2584
  total_w = @cell_width * @cols
2527
2585
  tab_w = total_w / @tabs.size
@@ -2531,24 +2589,51 @@ module Echoes
2531
2589
  ObjC::NSRectFill.call(0.0, ty, total_w + @cell_width, tbh)
2532
2590
 
2533
2591
  # Vertically center titles in the bar.
2534
- title_y = ty + (tbh - @font_default_line_height) / 2.0
2592
+ title_y = ty + (tbh - @tab_font_line_height) / 2.0
2535
2593
 
2536
2594
  accent_color = make_color(*@active_profile.cursor_color)
2537
2595
  accent_h = 2.0
2538
2596
  accent_inset = @cell_width * 0.5
2539
2597
 
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).
2602
+ ns_para = ObjC::MSG_PTR.call(ObjC.cls('NSMutableParagraphStyle'), ObjC.sel('alloc'))
2603
+ ns_para = ObjC::MSG_PTR.call(ns_para, ObjC.sel('init'))
2604
+ ObjC::MSG_VOID_L.call(ns_para, ObjC.sel('setLineBreakMode:'), NSLineBreakByTruncatingTail)
2605
+ ObjC::MSG_VOID_L.call(ns_para, ObjC.sel('setAlignment:'), NSTextAlignmentCenter)
2606
+
2607
+ pad = @cell_width * 0.5
2608
+ rect_w = tab_w - 2 * pad
2609
+ rect_w = 0.0 if rect_w < 0
2610
+
2540
2611
  @tabs.each_with_index do |tab, i|
2541
2612
  x = i * tab_w
2542
2613
  is_active = (i == @active_tab)
2543
2614
 
2544
- label = tab.title
2545
- ns_label = ObjC.nsstring(label)
2615
+ ns_label = ObjC.nsstring(tab.title)
2546
2616
  ns_attrs = ObjC.nsdict({
2547
- ObjC::NSFontAttributeName => @font,
2617
+ ObjC::NSFontAttributeName => @tab_font,
2548
2618
  ObjC::NSForegroundColorAttributeName => is_active ? @tab_fg_active : @tab_fg_inactive,
2619
+ ObjC::NSParagraphStyleAttributeName => ns_para,
2549
2620
  })
2550
- text_x = x + @cell_width * 0.5
2551
- ObjC::MSG_VOID_PT_1.call(ns_label, ObjC.sel('drawAtPoint:withAttributes:'), text_x, title_y, ns_attrs)
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)
2552
2637
 
2553
2638
  # Thin accent strip flush to the bottom of the bar marks
2554
2639
  # the active tab — replaces the previous heavier full-cell
@@ -2560,6 +2645,8 @@ module Echoes
2560
2645
  tab_w - 2 * accent_inset, accent_h)
2561
2646
  end
2562
2647
  end
2648
+
2649
+ ObjC.release(ns_para)
2563
2650
  end
2564
2651
 
2565
2652
  def grid_position(event_ptr)
@@ -3353,6 +3440,9 @@ module Echoes
3353
3440
  @font_default_line_height = ObjC::MSG_RET_D.call(@font, ObjC.sel('defaultLineHeightForFont'))
3354
3441
  @font_default_ascender = ascender
3355
3442
  @font_default_family = ObjC.to_ruby_string(ObjC::MSG_PTR.call(@font, ObjC.sel('familyName')))
3443
+ @tab_font_line_height = @tab_font ?
3444
+ ObjC::MSG_RET_D.call(@tab_font, ObjC.sel('defaultLineHeightForFont')) :
3445
+ @font_default_line_height
3356
3446
  @font_y_offset_cache = {}
3357
3447
 
3358
3448
  # Propagate cell metrics to all pane screens (sixel sizing,
data/lib/echoes/objc.rb CHANGED
@@ -53,6 +53,8 @@ module Echoes
53
53
  MSG_PTR_RECT = new_msg([P, P, D, D, D, D], P) # initWithFrame:
54
54
  MSG_VOID_RECT = new_msg([P, P, D, D, D, D], V) # NSRectFill equivalent
55
55
  MSG_VOID_RECT_1 = new_msg([P, P, D, D, D, D, P], V) # addCursorRect:cursor:
56
+ # drawWithRect:options:attributes: (NSRect + NSStringDrawingOptions + NSDictionary)
57
+ MSG_VOID_RECT_L_1 = new_msg([P, P, D, D, D, D, L, P], V)
56
58
  # NSGradient drawInRect:angle: (4 doubles for rect + 1 double for angle)
57
59
  MSG_VOID_RECT_D = new_msg([P, P, D, D, D, D, D], V)
58
60
 
@@ -159,6 +161,7 @@ module Echoes
159
161
  NSUnderlineStyleAttributeName = appkit_const('NSUnderlineStyleAttributeName')
160
162
  NSStrikethroughStyleAttributeName = appkit_const('NSStrikethroughStyleAttributeName')
161
163
  NSLigatureAttributeName = appkit_const('NSLigatureAttributeName')
164
+ NSParagraphStyleAttributeName = appkit_const('NSParagraphStyleAttributeName')
162
165
  NSPasteboardTypeString = appkit_const('NSPasteboardTypeString')
163
166
  NSPasteboardTypeFileURL = appkit_const('NSPasteboardTypeFileURL')
164
167
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Echoes
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Akira Matsuda