Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions apps/macos/Sources/Kit/Support/HorizontalWheelScrolling.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import AppKit
import SwiftUI

/// Lets a mouse's vertical wheel scroll a horizontal strip, as Shift+wheel
/// does. A horizontal SwiftUI scroll view ignores vertical wheel deltas, so
/// without this a mouse can't reach tabs that overflow.
///
/// Place it as the scroll view's background. While the pointer is over it,
/// the window's vertical mouse-wheel events scroll the strip across instead.
/// Trackpads scroll both axes already, and their events pass through.
struct HorizontalWheelScrolling: NSViewRepresentable {
func makeNSView(context: Context) -> Region { Region() }
func updateNSView(_ nsView: Region, context: Context) {}
static func dismantleNSView(_ nsView: Region, coordinator: ()) { nsView.stop() }

/// The horizontal equivalent of a vertical mouse-wheel event, or nil
/// when the event should scroll as it is.
static func horizontal(_ event: NSEvent) -> NSEvent? {
guard event.type == .scrollWheel, !event.hasPreciseScrollingDeltas,
event.scrollingDeltaX == 0, event.scrollingDeltaY != 0,
!event.modifierFlags.contains(.shift),
let copy = event.cgEvent?.copy() else { return nil }
for (vertical, horizontal) in [(CGEventField.scrollWheelEventDeltaAxis1, CGEventField.scrollWheelEventDeltaAxis2),
(.scrollWheelEventPointDeltaAxis1, .scrollWheelEventPointDeltaAxis2)] {
copy.setIntegerValueField(horizontal, value: copy.getIntegerValueField(vertical))
copy.setIntegerValueField(vertical, value: 0)
}
copy.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis2,
value: copy.getDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1))
copy.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1, value: 0)
return NSEvent(cgEvent: copy)
}

/// The strip's area. It takes no clicks; it only watches the wheel.
final class Region: NSView {
private var token: Token?

override func hitTest(_ point: NSPoint) -> NSView? { nil }

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
stop()
guard window != nil else { return }
// The handler holds the token, so a region that goes without
// being dismantled still removes its monitor.
let token = Token()
token.monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in
guard let self else { token.remove(); return event }
guard event.window === self.window else { return event }
return self.redirect(event)
}
self.token = token
}

/// Scrolls the strip under a vertical mouse-wheel event across,
/// consuming the event; any other event passes on. Only the strip
/// itself under the pointer counts, not a panel over it. The
/// horizontal copy goes straight to the strip, as a rebuilt event
/// has no window for AppKit to route it by.
func redirect(_ event: NSEvent) -> NSEvent? {
guard let window, !isHiddenOrHasHiddenAncestor,
bounds.contains(convert(event.locationInWindow, from: nil)),
let horizontal = HorizontalWheelScrolling.horizontal(event),
let hit = window.contentView?.hitTest(event.locationInWindow),
let strip = (hit as? NSScrollView) ?? hit.enclosingScrollView,
Self.same(strip.convert(strip.bounds, to: nil), convert(bounds, to: nil))
else { return event }
strip.scrollWheel(with: horizontal)
return nil
}

func stop() {
token?.remove()
token = nil
}

/// The region is the strip's background, so it shares its frame.
private static func same(_ a: CGRect, _ b: CGRect) -> Bool {
abs(a.minX - b.minX) < 0.5 && abs(a.minY - b.minY) < 0.5
&& abs(a.width - b.width) < 0.5 && abs(a.height - b.height) < 0.5
}
}

private final class Token {
var monitor: Any?
func remove() {
if let monitor { NSEvent.removeMonitor(monitor) }
monitor = nil
}
}
}
2 changes: 2 additions & 0 deletions apps/macos/Sources/Kit/Views/AnnotationStrip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ struct AnnotationStrip: View {
}
}
}
.scrollIndicators(.never)
.background(HorizontalWheelScrolling())
}
if let error = annotations.error {
Text(error).font(.kit(size: 12)).foregroundStyle(theme.muted)
Expand Down
2 changes: 1 addition & 1 deletion apps/macos/Sources/Kit/Views/ComposerAttachmentStrip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ struct ComposerAttachmentStrip: View {
.background(theme.raised, in: RoundedRectangle(cornerRadius: 6))
}
}.padding(12)
}.scrollIndicators(.hidden)
}.scrollIndicators(.never).background(HorizontalWheelScrolling())
Rule()
}
}
Expand Down
2 changes: 1 addition & 1 deletion apps/macos/Sources/Kit/Views/ComposerMentionPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ private struct ComposerPickerHost: View {
ScrollView(.vertical) {
content.frame(maxWidth: .infinity, alignment: .leading)
}
.scrollIndicators(.hidden)
.scrollIndicators(.never)
.frame(maxHeight: .infinity)
}
.padding(.bottom, 4)
Expand Down
3 changes: 2 additions & 1 deletion apps/macos/Sources/Kit/Views/ComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ struct ComposerView: View {
}
}.padding(.horizontal, 16).padding(.vertical, 10)
}
.scrollIndicators(.hidden)
.scrollIndicators(.never)
.background(HorizontalWheelScrolling())
.background(theme.raised)
Rule()
}
Expand Down
2 changes: 1 addition & 1 deletion apps/macos/Sources/Kit/Views/SessionFeedbackView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ struct SessionReloadEvidenceView: View {
if !outerScrollable && (report.sources.count > 4 || report.diagnostics.count > 4
|| report.diagnostics.contains(where: { $0.message.count > 280 })) {
ScrollView { evidence }
.scrollIndicators(.hidden)
.scrollIndicators(.never)
.frame(maxHeight: 360)
} else {
evidence
Expand Down
35 changes: 5 additions & 30 deletions apps/macos/Sources/Kit/Views/SessionTabs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,7 @@ struct SessionTopTabs: View {
var body: some View {
let selectedDirectory = model.selectedTab.directory
let tabs = model.familyOrder
let overflow = SessionTabOverflow(frames: frames, offset: offset, viewport: viewportWidth)
let overflow = TabStripOverflow(frames: frames, offset: offset, viewport: viewportWidth)
let hidden = tabs.filter { overflow.hides($0.id) }
let hints = model.tabNumberHints(.top)
HStack(spacing: 6) {
Expand Down Expand Up @@ -500,14 +500,10 @@ struct SessionTopTabs: View {
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { viewportWidth = $0 }
.onScrollGeometryChange(for: CGFloat.self) { $0.contentOffset.x } action: { _, x in offset = x }
.mask {
HStack(spacing: 0) {
LinearGradient(colors: [.black.opacity(overflow.hiddenBefore ? 0 : 1), .black],
startPoint: .leading, endPoint: .trailing).frame(width: 32)
Rectangle()
LinearGradient(colors: [.black, .black.opacity(overflow.hiddenAfter ? 0 : 1)],
startPoint: .leading, endPoint: .trailing).frame(width: 96)
}
TabStripEdgeMask(hiddenBefore: overflow.hiddenBefore, hiddenAfter: overflow.hiddenAfter,
leading: 32, trailing: 96)
}
.background(HorizontalWheelScrolling())
.onChange(of: model.selectedTabID) { withAnimation(.easeOut(duration: 0.15)) { proxy.scrollTo(model.selectedTabID) } }
.onAppear { proxy.scrollTo(model.selectedTabID) }
}
Expand All @@ -529,23 +525,8 @@ struct SessionTopTabs: View {
private static let space = "SessionTopTabs"
}

/// Which top tabs are not fully visible in the scrolled strip.
struct SessionTabOverflow {
let frames: [UUID: ClosedRange<CGFloat>]
let offset: CGFloat
let viewport: CGFloat

func hides(_ id: UUID) -> Bool {
guard viewport > 0, let frame = frames[id] else { return false }
return frame.lowerBound - offset < -1 || frame.upperBound - offset > viewport + 1
}
var hiddenBefore: Bool { viewport > 0 && frames.values.contains { $0.lowerBound - offset < -1 } }
var hiddenAfter: Bool { viewport > 0 && frames.values.contains { $0.upperBound - offset > viewport + 1 } }
}

/// The count of hidden session tabs; choosing one selects it.
private struct SessionTabOverflowMenu: View {
@Environment(\.mica) private var theme
let hidden: [SessionTab]
/// Each tab's ⌘ number, shown as the item's shortcut. The item handles
/// it while it's here, so it must select what Show Tab N would: on top,
Expand All @@ -560,13 +541,7 @@ private struct SessionTabOverflowMenu: View {
.keyboardShortcut(numbers.tabs[tab.id].map { KeyboardShortcut(KeyEquivalent(Character("\($0)")), modifiers: .command) })
}
} label: {
Text("+\(hidden.count)")
.font(.kit(size: 11, design: .monospaced))
.foregroundStyle(theme.muted)
.padding(.horizontal, 7)
.frame(height: 22)
.overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(theme.border))
.contentShape(Rectangle())
TabOverflowCount(count: hidden.count)
}
.menuStyle(.button)
.buttonStyle(.plain)
Expand Down
65 changes: 65 additions & 0 deletions apps/macos/Sources/Kit/Views/TabStripOverflow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import SwiftUI

/// Which tabs of a scrolled strip are not fully visible, and where to
/// scroll so one is. Frames are spans in the strip's content, so they don't
/// change as it scrolls.
struct TabStripOverflow<ID: Hashable> {
let frames: [ID: ClosedRange<CGFloat>]
let offset: CGFloat
let viewport: CGFloat

func hides(_ id: ID) -> Bool {
guard viewport > 0, let frame = frames[id] else { return false }
return frame.lowerBound - offset < -1 || frame.upperBound - offset > viewport + 1
}
var hiddenBefore: Bool { viewport > 0 && frames.values.contains { $0.lowerBound - offset < -1 } }
var hiddenAfter: Bool { viewport > 0 && frames.values.contains { $0.upperBound - offset > viewport + 1 } }

/// The offset that brings `span` into view clear of the edge fades,
/// moving as little as possible; nil when it is already in view. At the
/// content's ends nothing is hidden, so no fade needs clearing.
static func revealOffset(_ span: ClosedRange<CGFloat>, offset: CGFloat, viewport: CGFloat,
content: CGFloat, leading: CGFloat, trailing: CGFloat) -> CGFloat? {
guard viewport > 0 else { return nil }
var target = offset
if span.upperBound + trailing > target + viewport { target = span.upperBound + trailing - viewport }
if span.lowerBound - leading < target { target = span.lowerBound - leading }
target = min(max(target, 0), max(0, content - viewport))
return abs(target - offset) > 0.5 ? target : nil
}
}

/// The count of a strip's hidden tabs, which opens their menu.
struct TabOverflowCount: View {
@Environment(\.mica) private var theme
let count: Int

var body: some View {
Text("+\(count)")
.font(.kit(size: 11, design: .monospaced))
.foregroundStyle(theme.muted)
.padding(.horizontal, 7)
.frame(height: 22)
.overlay(RoundedRectangle(cornerRadius: 3).strokeBorder(theme.border))
.contentShape(Rectangle())
}
}

/// The fades at a scrolled strip's edges, opaque where nothing is hidden.
/// Apply it as the strip's mask.
struct TabStripEdgeMask: View {
let hiddenBefore: Bool
let hiddenAfter: Bool
let leading: CGFloat
let trailing: CGFloat

var body: some View {
HStack(spacing: 0) {
LinearGradient(colors: [.black.opacity(hiddenBefore ? 0 : 1), .black],
startPoint: .leading, endPoint: .trailing).frame(width: leading)
Rectangle()
LinearGradient(colors: [.black, .black.opacity(hiddenAfter ? 0 : 1)],
startPoint: .leading, endPoint: .trailing).frame(width: trailing)
}
}
}
6 changes: 5 additions & 1 deletion apps/macos/Sources/Kit/Views/TileAddMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ struct TileAddButton: View {
@State private var open = false
@State private var hovering = false

/// The button's width, with its padding.
static let width: CGFloat = 22 + 2 * padding
private static let padding: CGFloat = 6

var body: some View {
Button(action: toggle) {
IconView(.plus, size: 12)
Expand All @@ -25,7 +29,7 @@ struct TileAddButton: View {
.buttonStyle(.plain)
.background(ComposerPickerAnchorView(anchor: anchor))
.onHover { hovering = $0 }
.padding(.horizontal, 6)
.padding(.horizontal, Self.padding)
.help("Add Tab")
.accessibilityLabel("Add Tab")
.onDisappear { popover.dismiss() }
Expand Down
74 changes: 74 additions & 0 deletions apps/macos/Sources/Kit/Views/TileTabStrip.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import SwiftUI

/// How a tile's tabs share its strip. Tabs keep their natural width while
/// they fit. Past that the widest shrink first, toward ``minimum``, while
/// the selected tab keeps at least ``selectedMinimum`` so it stays
/// readable. Tabs that still don't fit at those widths scroll.
struct TileTabWidths: Equatable {
/// The narrowest a tab shrinks: its icon and a few characters.
static let minimum: CGFloat = 88
/// The narrowest the selected tab shrinks.
static let selectedMinimum: CGFloat = 160

let widths: [CGFloat]

/// - Parameters:
/// - natural: each tab's width with its whole title, up to its cap.
/// - selected: the selected tab's index.
/// - available: the width the tabs may fill, or nil before the strip
/// is measured.
init(natural: [CGFloat], selected: Int?, available: CGFloat?) {
let floors = natural.indices.map { index in
min(natural[index], index == selected ? Self.selectedMinimum : Self.minimum)
}
guard let available, natural.reduce(0, +) > available else { widths = natural; return }
guard floors.reduce(0, +) < available else { widths = floors; return }
// Every tab wider than the cap shrinks to it, so the tabs fill the
// strip exactly. The total grows piecewise linearly with the cap;
// find the piece where it reaches `available` and solve within it.
func total(_ cap: CGFloat) -> CGFloat {
natural.indices.reduce(0) { $0 + min(natural[$1], max(cap, floors[$1])) }
}
let points = Set(natural + floors).sorted()
var cap = points.last ?? 0
for (low, high) in zip(points, points.dropFirst()) where total(high) >= available {
let growing = natural.indices.filter { floors[$0] <= low && natural[$0] >= high }.count
cap = growing > 0 ? low + (available - total(low)) / CGFloat(growing) : high
break
}
widths = natural.indices.map { min(natural[$0], max(cap, floors[$0])) }
}
}

/// Lays a tile's tabs out in a row at ``TileTabWidths``. It sizes to the
/// tabs, so in a scroll view it is as wide as they are.
struct TileTabRow: Layout {
/// The width the tabs may fill, or nil before the strip is measured.
let available: CGFloat?
let selected: Int?

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let height = proposal.height ?? subviews.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
return CGSize(width: widths(subviews).reduce(0, +), height: height)
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var x = bounds.minX
for (subview, width) in zip(subviews, widths(subviews)) {
subview.place(at: CGPoint(x: x, y: bounds.minY), anchor: .topLeading,
proposal: ProposedViewSize(width: width, height: bounds.height))
x += width
}
}

private func widths(_ subviews: Subviews) -> [CGFloat] {
TileTabWidths(natural: subviews.map { $0.sizeThatFits(.unspecified).width },
selected: selected, available: available).widths
}
}

/// The widths of a tile strip's edge fades. Revealing a tab clears them.
enum TileTabFade {
static let leading: CGFloat = 24
static let trailing: CGFloat = 32
}
Loading
Loading