From 595825a7db17fd624146b112a81817cd3e4cba9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=98=8A=E7=87=83?= <11169285@MacBook-Air-2.local> Date: Tue, 3 Nov 2026 18:55:10 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v1.0.0=20=E2=80=94=20=E7=BD=AE?= =?UTF-8?q?=E9=A1=B6=E7=BC=A9=E6=94=BE=E4=BA=A4=E4=BA=92=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Pins/PinnedImagePanel.swift | 1 + .../Sources/Pins/PinnedImageView.swift | 214 ++++++++--- .../Sources/Pins/PinnedToolbarView.swift | 13 +- .../Sources/Pins/PinnedToolbarWindow.swift | 49 ++- .../Sources/Pins/PinnedWindowManager.swift | 5 +- CHANGELOG.md | 13 +- Docs/ARCHITECTURE.md | 9 +- .../plans/2026-11-03-pinned-resize-ux.md | 97 +++++ .../Sources/PinnedImageGeometry.swift | 331 +++++++++++++++++- .../Sources/PinnedToolbarVisibility.swift | 29 +- .../PinnedImageGeometryTests.swift | 288 ++++++++++++++- .../PinnedToolbarVisibilityTests.swift | 27 ++ README.md | 2 +- project.yml | 2 +- version.txt | 2 +- 15 files changed, 985 insertions(+), 97 deletions(-) create mode 100644 Docs/superpowers/plans/2026-11-03-pinned-resize-ux.md diff --git a/App/SnapGlass/Sources/Pins/PinnedImagePanel.swift b/App/SnapGlass/Sources/Pins/PinnedImagePanel.swift index 0405a53..d875fac 100644 --- a/App/SnapGlass/Sources/Pins/PinnedImagePanel.swift +++ b/App/SnapGlass/Sources/Pins/PinnedImagePanel.swift @@ -19,6 +19,7 @@ final class PinnedImagePanel: NSPanel { hidesOnDeactivate = false isMovableByWindowBackground = true collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + acceptsMouseMovedEvents = true backgroundColor = .clear isOpaque = false hasShadow = true diff --git a/App/SnapGlass/Sources/Pins/PinnedImageView.swift b/App/SnapGlass/Sources/Pins/PinnedImageView.swift index c08bf32..9293542 100644 --- a/App/SnapGlass/Sources/Pins/PinnedImageView.swift +++ b/App/SnapGlass/Sources/Pins/PinnedImageView.swift @@ -19,8 +19,9 @@ final class PinnedImageView: NSView { private var opacity: CGFloat = 1 private var dragStartLocation: CGPoint? private var dragStartFrame: CGRect? - private var isResizing = false + private var activeResizeDirection: PinnedImageResizeDirection? private var isHovering = false + private var hoveredResizeDirection: PinnedImageResizeDirection? private var trackingAreaReference: NSTrackingArea? /// 悬停状态变化回调,驱动悬浮工具条显隐。 @@ -29,6 +30,12 @@ final class PinnedImageView: NSView { /// 不透明度变化回调,用于把滚轮/⌥+滚轮的结果同步回工具条滑杆。 var onOpacityChanged: ((CGFloat) -> Void)? + /// 面板倍率变化回调,用于同步工具条读数。 + var onScaleChanged: ((CGFloat) -> Void)? + + /// 边缘/角落缩放起止回调,用于缩放期间隐藏工具条。 + var onResizeChanged: ((Bool) -> Void)? + /// 当前缩放倍率。 /// /// 由面板 frame 宽度反推,不存在可变副本——`fitToScreen()` 与拖拽缩放只改 frame, @@ -45,11 +52,16 @@ final class PinnedImageView: NSView { /// 当前不透明度。 var currentOpacity: CGFloat { opacity } - private static let closeButtonSize: CGFloat = 20 - private static let resizeHitInset: CGFloat = 14 private static let alphaMaskMaxDimension = 256 private static let alphaHitThreshold: CGFloat = 0.05 private static let minimumResizeWidth: CGFloat = 40 + private static let minimumResizeHeight: CGFloat = 40 + private static let topLeftBottomRightCursor = diagonalResizeCursor( + symbolName: "arrow.up.left.and.arrow.down.right" + ) ?? NSCursor.crosshair + private static let topRightBottomLeftCursor = diagonalResizeCursor( + symbolName: "arrow.up.right.and.arrow.down.left" + ) ?? NSCursor.crosshair init( image: CGImage, @@ -95,24 +107,36 @@ final class PinnedImageView: NSView { override func mouseEntered(with event: NSEvent) { isHovering = true + updatePointerFeedback(at: convert(event.locationInWindow, from: nil)) needsDisplay = true onHoverChanged?(true) } override func mouseExited(with event: NSEvent) { isHovering = false + updateResizeHover(for: .passThrough) needsDisplay = true onHoverChanged?(false) } + override func mouseMoved(with event: NSEvent) { + updatePointerFeedback(at: convert(event.locationInWindow, from: nil)) + } + override func cursorUpdate(with event: NSEvent) { - let point = convert(event.locationInWindow, from: nil) - if resizeHandleRect().contains(point) { - NSCursor.crosshair.set() - } else if closeButtonRect().contains(point) { - NSCursor.arrow.set() - } else { + updatePointerFeedback(at: convert(event.locationInWindow, from: nil)) + } + + private func updatePointerFeedback(at point: CGPoint) { + let interaction = interaction(at: point) + updateResizeHover(for: interaction) + switch interaction { + case let .resize(direction): + Self.resizeCursor(for: direction).set() + case .move: NSCursor.openHand.set() + case .close, .passThrough: + NSCursor.arrow.set() } } @@ -121,12 +145,12 @@ final class PinnedImageView: NSView { /// 关闭按钮与缩放手柄始终可命中——自由圈选截图的角落可能是透明的, /// 否则用户无法点击它们。 override func hitTest(_ point: NSPoint) -> NSView? { - guard bounds.contains(point) else { return nil } - if closeButtonRect().contains(point) || resizeHandleRect().contains(point) { + switch interaction(at: point) { + case .close, .resize, .move: return self + case .passThrough: + return nil } - guard alphaAt(point: point) > Self.alphaHitThreshold else { return nil } - return super.hitTest(point) } override func draw(_ dirtyRect: NSRect) { @@ -137,14 +161,56 @@ final class PinnedImageView: NSView { context.draw(image, in: CGRect(origin: .zero, size: bounds.size)) context.restoreGState() + if let hoveredResizeDirection { + drawResizeHighlight(hoveredResizeDirection, in: context) + } if isHovering { - context.setStrokeColor(NSColor.controlAccentColor.withAlphaComponent(0.8).cgColor) - context.setLineWidth(1) - context.stroke(bounds.insetBy(dx: 0.5, dy: 0.5)) drawCloseButton(in: context) } } + private func drawResizeHighlight(_ direction: PinnedImageResizeDirection, in context: CGContext) { + let minX = bounds.minX + 0.5 + let maxX = bounds.maxX - 0.5 + let minY = bounds.minY + 0.5 + let maxY = bounds.maxY - 0.5 + let segment = min(24, min(bounds.width, bounds.height) / 2) + context.setStrokeColor(NSColor.controlAccentColor.withAlphaComponent(0.9).cgColor) + context.setLineWidth(2) + context.beginPath() + switch direction { + case .left: + context.move(to: CGPoint(x: minX, y: minY)) + context.addLine(to: CGPoint(x: minX, y: maxY)) + case .right: + context.move(to: CGPoint(x: maxX, y: minY)) + context.addLine(to: CGPoint(x: maxX, y: maxY)) + case .top: + context.move(to: CGPoint(x: minX, y: maxY)) + context.addLine(to: CGPoint(x: maxX, y: maxY)) + case .bottom: + context.move(to: CGPoint(x: minX, y: minY)) + context.addLine(to: CGPoint(x: maxX, y: minY)) + case .topLeft: + context.move(to: CGPoint(x: minX, y: maxY - segment)) + context.addLine(to: CGPoint(x: minX, y: maxY)) + context.addLine(to: CGPoint(x: minX + segment, y: maxY)) + case .topRight: + context.move(to: CGPoint(x: maxX - segment, y: maxY)) + context.addLine(to: CGPoint(x: maxX, y: maxY)) + context.addLine(to: CGPoint(x: maxX, y: maxY - segment)) + case .bottomLeft: + context.move(to: CGPoint(x: minX, y: minY + segment)) + context.addLine(to: CGPoint(x: minX, y: minY)) + context.addLine(to: CGPoint(x: minX + segment, y: minY)) + case .bottomRight: + context.move(to: CGPoint(x: maxX - segment, y: minY)) + context.addLine(to: CGPoint(x: maxX, y: minY)) + context.addLine(to: CGPoint(x: maxX, y: minY + segment)) + } + context.strokePath() + } + private func drawCloseButton(in context: CGContext) { let rect = closeButtonRect() context.setFillColor(NSColor.black.withAlphaComponent(0.6).cgColor) @@ -160,39 +226,74 @@ final class PinnedImageView: NSView { } private func closeButtonRect() -> CGRect { - let size = Self.closeButtonSize - return CGRect( - x: bounds.maxX - size - 6, - y: bounds.maxY - size - 6, - width: size, - height: size - ) + PinnedImageGeometry.closeButtonFrame(in: bounds) } - private func resizeHandleRect() -> CGRect { - CGRect( - x: bounds.maxX - Self.resizeHitInset, - y: bounds.minY, - width: Self.resizeHitInset, - height: Self.resizeHitInset + private func interaction(at point: CGPoint) -> PinnedImageInteraction { + PinnedImageGeometry.interaction( + at: point, + in: bounds, + closeButtonFrame: closeButtonRect(), + alpha: alphaAt(point: point), + alphaThreshold: Self.alphaHitThreshold ) } + private func updateResizeHover(for interaction: PinnedImageInteraction) { + if case let .resize(direction) = interaction { + guard hoveredResizeDirection != direction else { return } + hoveredResizeDirection = direction + needsDisplay = true + } else if hoveredResizeDirection != nil { + hoveredResizeDirection = nil + needsDisplay = true + } + } + + private static func resizeCursor(for direction: PinnedImageResizeDirection) -> NSCursor { + switch direction { + case .left, .right: + .resizeLeftRight + case .top, .bottom: + .resizeUpDown + case .topLeft, .bottomRight: + topLeftBottomRightCursor + case .topRight, .bottomLeft: + topRightBottomLeftCursor + } + } + + private static func diagonalResizeCursor(symbolName: String) -> NSCursor? { + guard let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) else { + return nil + } + image.size = NSSize(width: 16, height: 16) + return NSCursor(image: image, hotSpot: CGPoint(x: 8, y: 8)) + } + // MARK: - 拖动与缩放 override func mouseDown(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) - if closeButtonRect().contains(point) { - onRequestClose() - return - } - if event.clickCount == 2 { + switch interaction(at: point) { + case .close: onRequestClose() + case .move: + if event.clickCount == 2 { + onRequestClose() + return + } + dragStartLocation = NSEvent.mouseLocation + dragStartFrame = window?.frame + case let .resize(direction): + guard let window else { return } + dragStartLocation = NSEvent.mouseLocation + dragStartFrame = window.frame + activeResizeDirection = direction + onResizeChanged?(true) + case .passThrough: return } - dragStartLocation = NSEvent.mouseLocation - dragStartFrame = window?.frame - isResizing = resizeHandleRect().contains(point) } override func mouseDragged(with event: NSEvent) { @@ -203,17 +304,19 @@ final class PinnedImageView: NSView { let deltaX = current.x - startLocation.x let deltaY = current.y - startLocation.y - if isResizing { - let newWidth = max(Self.minimumResizeWidth, startFrame.width + deltaX) - let aspect = CGFloat(image.width) / CGFloat(image.height) + if let direction = activeResizeDirection { applyFrame( - CGRect( - x: startFrame.minX, - y: startFrame.minY, - width: newWidth, - height: newWidth / aspect + PinnedImageGeometry.resizedFrame( + from: startFrame, + direction: direction, + translation: CGPoint(x: deltaX, y: deltaY), + minimumSize: CGSize( + width: Self.minimumResizeWidth, + height: Self.minimumResizeHeight + ) ), - window: window + window: window, + anchor: direction.fixedAnchor ) } else { window.setFrameOrigin( @@ -223,9 +326,12 @@ final class PinnedImageView: NSView { } override func mouseUp(with event: NSEvent) { + if activeResizeDirection != nil { + onResizeChanged?(false) + } + activeResizeDirection = nil dragStartLocation = nil dragStartFrame = nil - isResizing = false } /// 滚轮缩放;按住 ⌥ 时改为调节透明度。 @@ -254,6 +360,7 @@ final class PinnedImageView: NSView { guard current > 0, newScale != current else { return } let ratio = newScale / current let frame = window.frame + let normalizedAnchor = CGPoint(x: anchor.x / frame.width, y: anchor.y / frame.height) applyFrame( CGRect( x: frame.minX + anchor.x * (1 - ratio), @@ -261,7 +368,8 @@ final class PinnedImageView: NSView { width: frame.width * ratio, height: frame.height * ratio ), - window: window + window: window, + anchor: normalizedAnchor ) } @@ -269,14 +377,16 @@ final class PinnedImageView: NSView { /// /// 拖拽缩放允许的最小宽度可能对应低于 `minimumScale` 的倍率,若不收敛, /// 工具条读数会落在滑杆量程之外且与画面不一致。 - private func applyFrame(_ frame: CGRect, window: NSWindow) { + private func applyFrame(_ frame: CGRect, window: NSWindow, anchor: CGPoint = .zero) { let limited = PinnedImageGeometry.clampFrameToScaleRange( frame, imagePixelWidth: CGFloat(image.width), - displayScale: displayScale + displayScale: displayScale, + anchor: anchor ) window.setFrame(limited, display: true) needsDisplay = true + onScaleChanged?(currentScale) } /// 恢复到原始点尺寸(1:1)。 @@ -311,13 +421,13 @@ final class PinnedImageView: NSView { onOpacityChanged?(opacity) } - /// 设置缩放倍率(供工具条滑杆使用),以面板中心为锚点。 + /// 设置缩放倍率(供工具条滑杆使用),固定面板左下角。 func setScale(_ newScale: CGFloat) { guard let window else { return } applyScale( PinnedImageGeometry.clampedScale(newScale), window: window, - anchor: CGPoint(x: window.frame.width / 2, y: window.frame.height / 2) + anchor: .zero ) } diff --git a/App/SnapGlass/Sources/Pins/PinnedToolbarView.swift b/App/SnapGlass/Sources/Pins/PinnedToolbarView.swift index 0ee796d..1738a15 100644 --- a/App/SnapGlass/Sources/Pins/PinnedToolbarView.swift +++ b/App/SnapGlass/Sources/Pins/PinnedToolbarView.swift @@ -16,6 +16,9 @@ final class PinnedToolbarView: NSVisualEffectView { /// 不透明度滑杆拖动中(用于抑制工具条自动隐藏)。 var onInteractionChange: ((Bool) -> Void)? + /// 缩放滑杆拖动起止(用于冻结工具条位置)。 + var onScaleInteractionChange: ((Bool) -> Void)? + /// 鼠标进入/离开工具条,用于取消或重启延迟隐藏。 var onPointerEnter: (() -> Void)? var onPointerExit: (() -> Void)? @@ -341,10 +344,12 @@ final class PinnedToolbarView: NSVisualEffectView { /// 绑定两个滑杆的拖拽起止,用于在拖动期间抑制工具条自动隐藏。 private func wireInteractionReporting() { - for slider in [opacitySlider, scaleSlider] { - slider.onDragStateChange = { [weak self] isDragging in - self?.onInteractionChange?(isDragging) - } + opacitySlider.onDragStateChange = { [weak self] isDragging in + self?.onInteractionChange?(isDragging) + } + scaleSlider.onDragStateChange = { [weak self] isDragging in + self?.onInteractionChange?(isDragging) + self?.onScaleInteractionChange?(isDragging) } } } diff --git a/App/SnapGlass/Sources/Pins/PinnedToolbarWindow.swift b/App/SnapGlass/Sources/Pins/PinnedToolbarWindow.swift index f2a2bae..e6ae0fa 100644 --- a/App/SnapGlass/Sources/Pins/PinnedToolbarWindow.swift +++ b/App/SnapGlass/Sources/Pins/PinnedToolbarWindow.swift @@ -60,6 +60,8 @@ final class PinnedToolbarCoordinator { private let toolbarWindow: PinnedToolbarWindow private let toolbarView = PinnedToolbarView(frame: .zero) private var toolbarSize: CGSize = .zero + private var lastMeasuredAvailableWidth: CGFloat? + private var isScaleSliderDragging = false private var visibility = PinnedToolbarVisibility() private var showTask: Task? @@ -127,6 +129,13 @@ final class PinnedToolbarCoordinator { } self.scheduleHide() } + toolbarView.onScaleInteractionChange = { [weak self] isDragging in + guard let self else { return } + self.isScaleSliderDragging = isDragging + if !isDragging { + self.reposition() + } + } toolbarView.onPointerEnter = { [weak self] in guard let self else { return } self.hideTask?.cancel() @@ -172,6 +181,25 @@ final class PinnedToolbarCoordinator { pinnedView.onOpacityChanged = { [weak self] opacity in self?.toolbarView.updateOpacity(opacity) } + pinnedView.onScaleChanged = { [weak self] scale in + self?.toolbarView.updateScale(scale) + } + pinnedView.onResizeChanged = { [weak self] isResizing in + guard let self else { return } + if isResizing { + self.showTask?.cancel() + self.showTask = nil + self.hideTask?.cancel() + self.hideTask = nil + self.visibility.panelResizeBegan() + self.hideImmediately() + } else { + self.visibility.panelResizeEnded() + if self.visibility.shouldShowAfterResize { + self.scheduleShow() + } + } + } } private func observePanelGeometry() { @@ -196,14 +224,14 @@ final class PinnedToolbarCoordinator { showTask?.cancel() let delay = visibility.pendingShowDelay if delay <= 0 { - visibility.applyShow() + guard visibility.applyShow() || visibility.isVisible else { return } present() return } showTask = Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(delay)) guard !Task.isCancelled, let self else { return } - self.visibility.applyShow() + guard self.visibility.applyShow() || self.visibility.isVisible else { return } self.present() } } @@ -227,11 +255,13 @@ final class PinnedToolbarCoordinator { syncReadouts() // 先定档位与尺寸(此刻视图已在窗口层级中),再定位,最后排序。 // 顺序不能颠倒:定位依赖 `toolbarSize`,而排序会重置 `sharingType`。 - updateToolbarSize() + updateToolbarSizeIfNeeded() // 必须先定位再排序:`updateToolbarFrame` 若像 `reposition()` 那样要求 // `isVisible`,首次显示会在定位前被跳过,工具条就落在窗口初始原点 // (屏幕左下),表现为严重偏移。 - updateToolbarFrame() + if !isScaleSliderDragging { + updateToolbarFrame() + } toolbarWindow.orderFront(nil) // 排序可能重置 sharingType,必须在其后重新确认。 toolbarWindow.reassertContentProtection() @@ -249,8 +279,10 @@ final class PinnedToolbarCoordinator { /// 宽度按档位的固定件加两根滑杆算出(滑杆在档位区间内吸收剩余宽度),高度取控件 /// 固有高度并以档位下限兜底——不再硬编码 40pt,也不再只测宽度:旧实现把高度写死 /// 为 40 而实测内容更低,导致窗口比内容矮、控件被圆角裁切而显示不全。 - private func updateToolbarSize() { + private func updateToolbarSizeIfNeeded() { let available = availableToolbarWidth() + guard available != lastMeasuredAvailableWidth else { return } + lastMeasuredAvailableWidth = available let layout = PinnedImageGeometry.toolbarLayout(availableWidth: available) toolbarView.apply(layout: layout) toolbarView.layoutSubtreeIfNeeded() @@ -282,9 +314,10 @@ final class PinnedToolbarCoordinator { /// 面板移动/缩放时的跟随。仅在工具条可见时有意义。 private func reposition() { - guard toolbarWindow.isVisible else { return } - // 面板可能被拖到另一块屏幕,档位与可用宽度随之变化,需重新测量。 - updateToolbarSize() + syncReadouts() + guard toolbarWindow.isVisible, !isScaleSliderDragging else { return } + // 面板可能移动到可用宽度不同的屏幕;同屏几何变化不重复测量工具条。 + updateToolbarSizeIfNeeded() updateToolbarFrame() } diff --git a/App/SnapGlass/Sources/Pins/PinnedWindowManager.swift b/App/SnapGlass/Sources/Pins/PinnedWindowManager.swift index 4afc9f3..48fa37b 100644 --- a/App/SnapGlass/Sources/Pins/PinnedWindowManager.swift +++ b/App/SnapGlass/Sources/Pins/PinnedWindowManager.swift @@ -48,10 +48,7 @@ final class PinnedWindowManager: NSObject, NSWindowDelegate { let offset = PinnedImageGeometry.cascadeOffset(forIndex: panels.count) let candidate = baseFrame.offsetBy(dx: offset.x, dy: offset.y) let screen = Self.screen(containing: candidate) - let frame = PinnedImageGeometry.fitToScreen( - candidate, - screenFrame: screen?.visibleFrame ?? candidate - ) + let frame = screen.map { PinnedImageGeometry.fitToScreen(candidate, screenFrame: $0.visibleFrame) } ?? candidate let panel = PinnedImagePanel(contentRect: frame) panel.delegate = self diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f13db..6df2f91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.0.0] - 2026-11-03 + +### Changed +- 置顶图片支持四边、四角等比拖拽缩放;滑杆与 1:1 固定左下角,滚轮与捏合固定光标锚点。适应屏幕保留 90% 尺寸上限,不强制居中;工具条在缩放交互期间稳定定位并同步倍率读数。 + +### Fixed +- 没有可用屏幕时,初始置顶面板保留候选 frame,不再误把候选 frame 当屏幕边界并缩小 10%。 +- 修复低于拖拽最小尺寸的小面板首次缩放时跳变;小面板关闭按钮缩放并为拖动保留可用区域,边缘悬停光标与高亮保持同步。 + ## [0.9.9] - 2026-10-31 ### Fixed @@ -402,7 +411,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - 移除 GUI App 的 Automation 窗口、`snapglass://` URL Scheme、App Intents 产品依赖和 CLI 构建目标 - 移除临时构建产物目录 `output/`,统一收敛到 `release/` -[Unreleased]: https://github.com/blackkcold/snapocr/compare/v0.9.8...HEAD +[Unreleased]: https://github.com/blackkcold/snapocr/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/blackkcold/snapocr/compare/v0.9.9...v1.0.0 +[0.9.9]: https://github.com/blackkcold/snapocr/compare/v0.9.8...v0.9.9 [0.9.8]: https://github.com/blackkcold/snapocr/compare/v0.9.7...v0.9.8 [0.9.7]: https://github.com/blackkcold/snapocr/compare/v0.9.6...v0.9.7 [0.9.6]: https://github.com/blackkcold/snapocr/compare/v0.9.5...v0.9.6 diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md index bd031af..7795804 100644 --- a/Docs/ARCHITECTURE.md +++ b/Docs/ARCHITECTURE.md @@ -259,14 +259,18 @@ Cocoa 存在两条互不相通的解析路径,两条都必须覆盖: | ⌘W 必须 override `performKeyEquivalent(with:)` | Command 组合键在无边框面板中先走 key equivalent 分发,`keyDown` 不可靠 | | `closeAll()` 只遍历管理器自有数组 | 选区面板与窗口选择面板同样是 `NSPanel`,扫描 `NSApp.windows` 按类型过滤会误关它们 | | 面板需被强引用持有至 `willClose` | `NSPanel` 默认 `isReleasedWhenClosed`,否则会提前释放 | +| 命中优先级与透明内容 | 关闭按钮 > 角落 > 边缘 > 内容/透明区;默认角落命中边长 14pt、边缘宽 6pt,短边小于 48pt 时仅启用按轴自适应角区,关闭按钮也随小面板缩小。透明边缘仍可缩放,透明主体穿透;仅内容移动区双击关闭 | +| 边缘/角落缩放 | 八个方向都按起始面板比例缩放;边缘固定对边中点,角落固定对角,最小尺寸与倍率上下限收敛后仍保持该锚点 | +| 其他缩放入口 | 工具条滑杆与 1:1 固定面板左下角;滚轮与捏合固定指针下的内容,倍率钳制时也保持该锚点 | +| Fit 与初始定位 | Fit 保留 90% 最大占屏尺寸规则,以左下角为缩放锚点,只做保证可见所需的最小平移,不强制居中;初始定位没有可用 `NSScreen` 时保留候选 frame,不把它当屏幕边界再次缩小 | | 缩放倍率取 `image.width / pinRect.width` | 混合缩放多显示器下各屏倍率不同,`backingScaleFactor` 不可靠 | -| 缩放倍率由面板 frame 宽度反推,不缓存可变副本 | `fitToScreen()` 与拖拽右下角手柄只改 frame,缓存倍率会与画面失同步(工具条读数随之失真、滚轮缩放跳变) | +| 缩放倍率由面板 frame 宽度反推,不缓存可变副本 | `fitToScreen()` 与边缘/角落拖拽只改 frame,缓存倍率会与画面失同步(工具条读数随之失真、滚轮缩放跳变) | ### 悬浮工具条(PinnedToolbarWindow / PinnedToolbarView) 悬停置顶面板时显示编辑工具条(不透明度、缩放、1:1、适应屏幕)。位置**默认在面板底部外侧**,面板贴近屏幕底部(下方放不下)时**翻到面板上侧**,上下都放不下(面板几乎占满屏高)才回退到面板底部内侧——避免工具条压住截图内容。工具条是**独立无边框子窗口**而非面板内子视图,原因有二: -1. 面板可被拖拽缩放到 `minimumResizeWidth`(40pt),子视图会被窗口裁剪而装不下工具条; +1. 面板可被拖拽缩放到 `minimumResizeWidth` / `minimumResizeHeight`(均为 40pt,按原图长宽比换算最小宽度),子视图会被窗口裁剪而装不下工具条; 2. 面板透明度由窗口级 `alphaValue` 实现,子视图在 20% 不透明度下会一并变淡而无法操作,独立窗口可完全避开该实现。 | 约束 | 原因 | @@ -287,6 +291,7 @@ Cocoa 存在两条互不相通的解析路径,两条都必须覆盖: | 外边距 ≥ 圆角半径,圆角取 8pt | `masksToBounds` 为圆角所必需,边距不足会把控件圆角区裁掉 | | 工具条尺寸向上取整到整数点(`integralToolbarSize`) | 滑杆宽度常出现半点,取整消除亚像素错位且不会让内容超出窗口 | | `controlSize` 改变后需 `needsLayout` 再读 `fittingSize` | 否则外部测到的是切换前的陈旧固有尺寸 | +| 面板边缘/角落缩放期间隐藏工具条;结束后仅当指针仍在面板内才恢复 | 避免工具条遮挡缩放边缘或在拖动时跳动;缩放滑杆拖动期间冻结工具条位置,面板几何变化后同步读数,仅在屏幕可用宽度变化时重测尺寸 | 置顶历史条目的 `isProtected` 语义:自动清理的四类路径(时限淘汰、数量上限、磁盘配额、分层剥图)均跳过受保护条目;但 `delete(id:)` / `clear()` 等用户显式操作不受影响。置顶路径不运行自动 OCR,故条目文本为空、不可被文本搜索命中——这是「置顶不静默改写剪贴板、OCR 仅在右键显式触发」这一隐私取舍的直接结果。 diff --git a/Docs/superpowers/plans/2026-11-03-pinned-resize-ux.md b/Docs/superpowers/plans/2026-11-03-pinned-resize-ux.md new file mode 100644 index 0000000..5a857de --- /dev/null +++ b/Docs/superpowers/plans/2026-11-03-pinned-resize-ux.md @@ -0,0 +1,97 @@ +# 置顶缩放交互实施计划 + +> **执行方式:**由主会话按任务顺序实施;执行期间不调用 subagent。步骤使用 checkbox 跟踪。 + +**Goal:** 置顶图片可通过四边与四角进行等比缩放,所有缩放入口使用明确且稳定的锚点,工具条在交互期间不漂移、翻边或显示陈旧倍率。 + +**Architecture:** 保留无边框、非激活 `NSPanel` 与 AppKit 手动 frame 更新。把缩放命中区域、方向、等比 frame 推导、锚点感知倍率钳制和屏幕可见范围计算放在可单测的 `CaptureCore` 纯几何层;`PinnedImageView` 负责单点交互分类和事件派发,工具条协调器负责交互期稳定定位与读数同步。 + +**Tech Stack:** Swift 6、AppKit、CoreGraphics、Swift Testing、XcodeGen + SPM。 + +**Spec:** 2026-11-03 会话中用户确认的置顶缩放方案;覆盖边/角、工具条滑杆、1:1、滚轮/捏合、适应屏幕与初始置顶定位。 + +## Global Constraints + +- 最低系统仍为 macOS 13;继续使用 borderless / nonactivating panel,不启用系统 `.resizable`。 +- 缩放比例维持 `0.1...8`;四边/四角等比;倍率 clamp 后仍须保持当前操作的固定锚点。 +- 命中区:边缘 6 pt、角落最多 14 pt;短边小于 48 pt 时只开放自适应角区;关闭按钮优先。 +- 滑杆与 1:1 固定左下锚点;滚轮/捏合固定光标锚点且不追加平移;Fit 保留原有 90% 最大占屏尺寸规则,但不强制居中,仅做最小可见性平移。 +- 无新增界面文案、依赖、权限、CI 或仓库根目录生成的 `.xcodeproj` 修改;若根项目文件缺失,仅可在忽略的 `.superpowers/sdd/2026-11-03-pinned-resize-ux/generated/` 下生成临时构建工程;不改原工作区的未跟踪 `release/`,不提交、不推送。 +- 按 `Docs/AGENTS.md` 更新 `CHANGELOG.md` 与架构说明;新增 public API 补齐 DocC 注释。 + +## Review Focus + +- 最小/最大倍率钳制后,被拖动的对边/对角与光标锚点仍固定;由几何测试覆盖。 +- 超宽/超高图片及最小面板尺寸不破坏原始比例或让面板退化到无法操作;由几何测试与手动检查覆盖。 +- 小面板、透明边缘、右上关闭按钮和双击关闭之间无误命中;由命中区域纯函数测试与手动检查覆盖。 +- 左下锚点缩放时滑杆留在指针下;边缘缩放时工具条不遮挡、翻边或闪烁;由工具条状态/布局测试与手动检查覆盖。 +- Fit 的 90% 尺寸规则、无屏幕回退、屏幕非零原点和已在可见区 frame 的位置稳定;由几何测试覆盖。 + +--- + +### Task 1: 可测试的锚点几何 + +**Files:** +- Modify: `Packages/CaptureCore/Sources/PinnedImageGeometry.swift` +- Modify: `Packages/CaptureCore/Sources/PinnedToolbarVisibility.swift` +- Test: `Packages/CaptureCore/Tests/CaptureCoreTests/PinnedImageGeometryTests.swift` +- Test: `Packages/CaptureCore/Tests/CaptureCoreTests/PinnedToolbarVisibilityTests.swift` + +**Interfaces:** +- Produces `public enum PinnedImageResizeDirection`: `left`, `right`, `top`, `bottom`, `topLeft`, `topRight`, `bottomLeft`, `bottomRight`;提供相对 frame 的固定锚点。 +- Produces `PinnedImageGeometry.resizeDirection(at:in:edgeHitSize:cornerHitSize:smallPanelThreshold:) -> PinnedImageResizeDirection?`;角落优先,短边小于阈值时只返回角落。 +- Produces `PinnedImageGeometry.resizedFrame(from:direction:translation:minimumSize:) -> CGRect`;按起始 frame 比例计算,固定对边/对角,统一最小宽高。 +- Extends `clampFrameToScaleRange(_:imagePixelWidth:displayScale:anchor:)` with normalized `anchor` defaulting to `.zero`, preserving existing callers while retaining the specified anchor at both scale limits. +- Produces `PinnedImageGeometry.clampFrameToVisibleBounds(_:screenFrame:) -> CGRect` for translation-only visibility correction. +- Keeps `fitToScreen(_:screenFrame:)` signature; retains the 90% fit cap, keeps the lower-left anchor before only-minimal visibility correction, and never centers. +- Produces `PinnedToolbarVisibility.panelResizeBegan()`, `panelResizeEnded()` and `shouldShowAfterResize` so the toolbar's temporary hide/restore policy is pure and testable. + +- [x] **Step 1: Write failing package tests** for all 8 resize zones, small-panel corner-only hit zones, four-edge/four-corner anchor preservation, aspect ratio, minimum dimensions, anchor-aware min/max scale clamping, fit-cap behavior, origin stability, minimum screen translation and toolbar resize visibility transitions. +- [x] **Step 2: Run the targeted tests and confirm expected failures** with `swift test --package-path Packages/CaptureCore --filter 'PinnedImageGeometryTests|PinnedToolbarVisibilityTests'`. +- [x] **Step 3: Implement the documented pure geometry APIs** in `PinnedImageGeometry.swift`, preserving current public API behavior when the new anchor argument is omitted. +- [x] **Step 4: Implement the resize visibility transitions** in `PinnedToolbarVisibility.swift` with tests proving the toolbar stays hidden while resizing and restores only when the pointer remains inside. +- [x] **Step 5: Run the targeted tests, then the full CaptureCore package suite** with `swift test --package-path Packages/CaptureCore`. + +### Task 2: AppKit hit testing, resize feedback and toolbar stability + +**Files:** +- Modify: `Packages/CaptureCore/Sources/PinnedImageGeometry.swift` +- Test: `Packages/CaptureCore/Tests/CaptureCoreTests/PinnedImageGeometryTests.swift` +- Modify: `App/SnapGlass/Sources/Pins/PinnedImageView.swift` +- Modify: `App/SnapGlass/Sources/Pins/PinnedToolbarView.swift` +- Modify: `App/SnapGlass/Sources/Pins/PinnedToolbarWindow.swift` +- Modify: `App/SnapGlass/Sources/Pins/PinnedWindowManager.swift` + +**Interfaces:** +- Consumes Task 1's direction classifier, resize geometry, anchor-aware clamp and visible-bounds helper. +- Adds a pure `PinnedImageInteraction` classifier in `CaptureCore` so close-button priority, transparent-edge resize and transparent-body pass-through are unit tested without editing the XcodeGen test-target configuration. +- `PinnedImageView` uses one `interaction(at:)` classification for hit testing, cursor selection and mouse dispatch, with priority `close > corner > edge > move/transparent`; only an interior move double-click closes the pin. +- `PinnedImageView` reports resize begin/end to `PinnedToolbarCoordinator`; it keeps aspect ratio from the current start frame to avoid a first-drag jump caused by pixel-integral crop rounding. +- `PinnedToolbarView` reports scale-slider drag begin/end separately from generic toolbar interaction. +- `PinnedToolbarCoordinator` freezes toolbar placement while the scale slider is dragged, hides the toolbar only during edge/corner resize, refreshes readouts after panel geometry changes, and only remeasures toolbar size when available screen width changes. + +- [ ] **Step 1: Implement shared interaction classification, transparent-edge resize hits, direction cursors, edge-only hover accent and gesture routing.** Preserve close-button hit priority and existing body-to-move behavior. +- [ ] **Step 2: Wire toolbar behavior**: hide during edge/corner resize, restore only if the pointer remains on the pin, freeze toolbar frame during scale-slider drag, refresh readouts on geometry changes and remeasure only when available screen width changes. +- [ ] **Step 3: Fix initial pin fallback** so absence of a real `NSScreen` does not treat the candidate frame as a screen and shrink it by 10%. +- [ ] **Step 4: Run `swift test --package-path Packages/CaptureCore` and build the macOS app.** If the generated project is absent, create it only in the plan's ignored `generated/` folder with `xcodegen --project-root "$PWD"`; manually verify all directions, transparent edges, tiny panels, close behavior, live slider tracking, toolbar visibility/readouts, and mixed-screen fit behavior. + +### Task 3: Project behavior documentation + +**Files:** +- Modify: `Docs/ARCHITECTURE.md` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Update the pin interaction contract** to describe all edge/corner anchors, slider/1:1 lower-left anchoring, cursor-anchored wheel/pinch and the non-centering 90% Fit behavior, including initial placement. +- [ ] **Step 2: Add an Unreleased changelog entry** without changing version numbers. +- [ ] **Step 3: Run Swift formatting/lint, localization check (no new strings expected), and review the complete diff.** + +## Verification Commands + +- `swift test --package-path Packages/CaptureCore` +- `xcodegen generate --spec project.yml --project .superpowers/sdd/2026-11-03-pinned-resize-ux/generated --project-root "$PWD"` +- `xcodebuild -project .superpowers/sdd/2026-11-03-pinned-resize-ux/generated/SnapGlass.xcodeproj -scheme SnapGlass -configuration Debug -derivedDataPath /private/var/folders/ks/flqyrh0j5wvf2t7v4s7lcnz0b5tfzv/T/opencode/snapocr-pin-resize-ux build CODE_SIGNING_ALLOWED=NO` +- `swift-format lint -p --configuration .swift-format` +- `swiftlint lint --config .swiftlint.yml` +- `bash scripts/check-localization.sh` + +No commit is part of this task. AppKit drag-event integration has no current UI automation target; the listed manual checks are required after build, and any unperformed UI check must be reported explicitly. diff --git a/Packages/CaptureCore/Sources/PinnedImageGeometry.swift b/Packages/CaptureCore/Sources/PinnedImageGeometry.swift index d754b2c..9a1df2c 100644 --- a/Packages/CaptureCore/Sources/PinnedImageGeometry.swift +++ b/Packages/CaptureCore/Sources/PinnedImageGeometry.swift @@ -1,5 +1,90 @@ import CoreGraphics +/// 置顶面板的手动缩放方向。 +public enum PinnedImageResizeDirection: Equatable, Sendable { + /// 拖动左边缘。 + case left + + /// 拖动右边缘。 + case right + + /// 拖动上边缘。 + case top + + /// 拖动下边缘。 + case bottom + + /// 拖动左上角。 + case topLeft + + /// 拖动右上角。 + case topRight + + /// 拖动左下角。 + case bottomLeft + + /// 拖动右下角。 + case bottomRight + + /// 相对当前 frame 的固定锚点,坐标范围为 `0...1`。 + public var fixedAnchor: CGPoint { + switch self { + case .left: + CGPoint(x: 1, y: 0.5) + case .right: + CGPoint(x: 0, y: 0.5) + case .top: + CGPoint(x: 0.5, y: 0) + case .bottom: + CGPoint(x: 0.5, y: 1) + case .topLeft: + CGPoint(x: 1, y: 0) + case .topRight: + CGPoint(x: 0, y: 0) + case .bottomLeft: + CGPoint(x: 1, y: 1) + case .bottomRight: + CGPoint(x: 0, y: 1) + } + } + + fileprivate var expansionVector: CGPoint { + switch self { + case .left: + CGPoint(x: -1, y: 0) + case .right: + CGPoint(x: 1, y: 0) + case .top: + CGPoint(x: 0, y: 1) + case .bottom: + CGPoint(x: 0, y: -1) + case .topLeft: + CGPoint(x: -1, y: 1) + case .topRight: + CGPoint(x: 1, y: 1) + case .bottomLeft: + CGPoint(x: -1, y: -1) + case .bottomRight: + CGPoint(x: 1, y: -1) + } + } +} + +/// 置顶图片面板上指针位置对应的交互类型。 +public enum PinnedImageInteraction: Equatable, Sendable { + /// 命中关闭按钮。 + case close + + /// 命中可缩放的边缘或角落。 + case resize(PinnedImageResizeDirection) + + /// 命中不透明图片内容,可拖动面板。 + case move + + /// 命中透明内容,应将事件交给下层窗口。 + case passThrough +} + /// 置顶面板的几何计算。 /// /// 全部以 AppKit 全局点坐标为单位(左下原点、Y 轴向上),与 `NSPanel.frame` 一致。 @@ -24,6 +109,17 @@ public enum PinnedImageGeometry { /// 悬浮工具条与面板边缘的间距(点)。 public static let toolbarSpacing: CGFloat = 8 + /// 边缘缩放命中区的默认宽度(点)。 + public static let resizeEdgeHitSize: CGFloat = 6 + + /// 角落缩放命中区的默认边长(点)。 + public static let resizeCornerHitSize: CGFloat = 14 + + /// 短边小于该值时,只启用自适应角落命中区(点)。 + public static let smallPanelResizeThreshold: CGFloat = 48 + + private static let minimumSmallPanelMoveCoreSize: CGFloat = 12 + /// 计算置顶面板的初始 frame。 /// /// 选区有效时直接使用原选区点矩形,实现「原位原尺寸」;选区无效时回退为 @@ -49,6 +145,152 @@ public enum PinnedImageGeometry { return appKitRect } + /// 根据指针所在位置识别手动缩放方向。 + /// + /// 角落命中优先于边缘;当面板短边小于 `smallPanelThreshold` 时关闭边缘热区, + /// 并按两个轴分别缩小角落热区,避免缩放热区覆盖大部分面板中心。 + /// + /// - Parameters: + /// - point: 面板局部坐标中的指针位置。 + /// - frame: 面板局部坐标中的边界矩形。 + /// - edgeHitSize: 边缘命中宽度。 + /// - cornerHitSize: 角落命中区边长。 + /// - smallPanelThreshold: 关闭边缘热区的短边阈值。 + /// - Returns: 命中的缩放方向;面板内部非热区或无效输入返回 `nil`。 + public static func resizeDirection( + at point: CGPoint, + in frame: CGRect, + edgeHitSize: CGFloat = resizeEdgeHitSize, + cornerHitSize: CGFloat = resizeCornerHitSize, + smallPanelThreshold: CGFloat = smallPanelResizeThreshold + ) -> PinnedImageResizeDirection? { + guard frame.width > 0, frame.height > 0, + frame.width.isFinite, frame.height.isFinite, + frame.minX.isFinite, frame.minY.isFinite, + point.x.isFinite, point.y.isFinite else { return nil } + + let distanceFromLeft = point.x - frame.minX + let distanceFromRight = frame.maxX - point.x + let distanceFromBottom = point.y - frame.minY + let distanceFromTop = frame.maxY - point.y + guard distanceFromLeft >= 0, distanceFromRight >= 0, + distanceFromBottom >= 0, distanceFromTop >= 0 else { return nil } + + let cornerSize = max(0, cornerHitSize) + if min(frame.width, frame.height) < max(0, smallPanelThreshold) { + let horizontalHitSize = min( + cornerSize, + max(0, (frame.width - minimumSmallPanelMoveCoreSize) / 2) + ) + let verticalHitSize = min( + cornerSize, + max(0, (frame.height - minimumSmallPanelMoveCoreSize) / 2) + ) + return cornerDirection( + left: distanceFromLeft, + right: distanceFromRight, + bottom: distanceFromBottom, + top: distanceFromTop, + hitSize: CGSize(width: horizontalHitSize, height: verticalHitSize) + ) + } + + if let corner = cornerDirection( + left: distanceFromLeft, + right: distanceFromRight, + bottom: distanceFromBottom, + top: distanceFromTop, + hitSize: CGSize(width: cornerSize, height: cornerSize) + ) { + return corner + } + + let edgeSize = max(0, edgeHitSize) + if distanceFromLeft <= edgeSize { return .left } + if distanceFromRight <= edgeSize { return .right } + if distanceFromBottom <= edgeSize { return .bottom } + if distanceFromTop <= edgeSize { return .top } + return nil + } + + /// 按关闭按钮、缩放热区与图片透明度的优先级分类指针交互。 + /// + /// 关闭按钮优先于重叠的角落热区,热区优先于 alpha 掩码;因此透明边缘仍可缩放, + /// 只有不透明的面板内部才作为移动区域。 + /// + /// - Parameters: + /// - point: 面板局部坐标中的指针位置。 + /// - frame: 面板局部坐标中的边界矩形。 + /// - closeButtonFrame: 关闭按钮的面板局部矩形。 + /// - alpha: 指针下方图片内容的不透明度,范围通常为 `0...1`。 + /// - alphaThreshold: 低于或等于该值时视为透明内容。 + /// - Returns: 对应的面板交互类型。 + public static func interaction( + at point: CGPoint, + in frame: CGRect, + closeButtonFrame: CGRect, + alpha: CGFloat, + alphaThreshold: CGFloat = 0.05 + ) -> PinnedImageInteraction { + guard frame.width > 0, frame.height > 0, + point.x >= frame.minX, point.x <= frame.maxX, + point.y >= frame.minY, point.y <= frame.maxY else { return .passThrough } + if ellipseContains(point, in: closeButtonFrame) { return .close } + if let direction = resizeDirection(at: point, in: frame) { return .resize(direction) } + return alpha.isFinite && alpha > alphaThreshold ? .move : .passThrough + } + + /// 按拖动方向计算等比缩放后的面板 frame。 + /// + /// 边缘拖动只使用法线方向位移;角落拖动将指针位移投影到保持原始长宽比的方向。 + /// 每个方向都固定对边中点或对角,最小尺寸也按相同比例收敛。 + /// + /// - Parameters: + /// - frame: 鼠标按下时的起始 frame。 + /// - direction: 当前拖动的边缘或角落。 + /// - translation: 从鼠标按下点累计的 AppKit 坐标位移。 + /// - minimumSize: 允许的最小宽高。 + /// - Returns: 保持起始长宽比并固定锚点的 frame。 + public static func resizedFrame( + from frame: CGRect, + direction: PinnedImageResizeDirection, + translation: CGPoint, + minimumSize: CGSize + ) -> CGRect { + guard frame.width > 0, frame.height > 0, + frame.width.isFinite, frame.height.isFinite, + frame.minX.isFinite, frame.minY.isFinite, + translation.x.isFinite, translation.y.isFinite, + minimumSize.width.isFinite, minimumSize.height.isFinite else { return frame } + + let aspectRatio = frame.width / frame.height + let movement = direction.expansionVector + let widthDelta: CGFloat + switch direction { + case .left, .right: + widthDelta = translation.x * movement.x + case .top, .bottom: + widthDelta = translation.y * movement.y * aspectRatio + case .topLeft, .topRight, .bottomLeft, .bottomRight: + widthDelta = (translation.x * movement.x + translation.y * movement.y / aspectRatio) + / (1 + 1 / (aspectRatio * aspectRatio)) + } + + let minimumWidth = max(max(0, minimumSize.width), max(0, minimumSize.height) * aspectRatio) + let effectiveMinimumWidth = min(minimumWidth, frame.width) + let width = max(effectiveMinimumWidth, frame.width + widthDelta) + let height = width / aspectRatio + let anchor = direction.fixedAnchor + let anchorX = frame.minX + frame.width * anchor.x + let anchorY = frame.minY + frame.height * anchor.y + return CGRect( + x: anchorX - width * anchor.x, + y: anchorY - height * anchor.y, + width: width, + height: height + ) + } + /// 根据图片像素宽与选区点宽计算真实缩放倍率。 /// /// - Parameters: @@ -71,7 +313,7 @@ public enum PinnedImageGeometry { /// 将 frame 收敛到屏幕可见区域内。 /// - /// 超出屏幕时按比例缩小并居中,避免面板初始位置落在屏幕之外。 + /// 超出尺寸上限时按比例缩小,保留左下锚点,再以最小平移保证完整可见;不强制居中。 /// /// - Parameters: /// - frame: 期望的 frame(AppKit 点坐标)。 @@ -79,17 +321,27 @@ public enum PinnedImageGeometry { /// - Returns: 适配后的 frame。 public static func fitToScreen(_ frame: CGRect, screenFrame: CGRect) -> CGRect { guard screenFrame.width > 0, screenFrame.height > 0, - frame.width > 0, frame.height > 0 else { return frame } + frame.width > 0, frame.height > 0 else { return frame } let maxWidth = screenFrame.width * 0.9 let maxHeight = screenFrame.height * 0.9 let ratio = min(1, min(maxWidth / frame.width, maxHeight / frame.height)) let size = CGSize(width: frame.width * ratio, height: frame.height * ratio) - let origin = CGPoint( - x: screenFrame.midX - size.width / 2, - y: screenFrame.midY - size.height / 2 - ) - return CGRect(origin: origin, size: size) + return clampFrameToVisibleBounds(CGRect(origin: frame.origin, size: size), screenFrame: screenFrame) + } + + /// 只平移 frame,使其完整落入给定可见边界,不改变尺寸。 + /// + /// 若 frame 大于边界,原点贴到边界左下角并保留原尺寸。 + /// + /// - Parameters: + /// - frame: 期望的 frame(AppKit 点坐标)。 + /// - screenFrame: 目标屏幕的可见 frame(AppKit 点坐标)。 + /// - Returns: 仅平移后的 frame;任一输入尺寸无效时返回原 frame。 + public static func clampFrameToVisibleBounds(_ frame: CGRect, screenFrame: CGRect) -> CGRect { + guard frame.width > 0, frame.height > 0, + screenFrame.width > 0, screenFrame.height > 0 else { return frame } + return clamped(frame, to: screenFrame) } /// 将缩放倍率限制到允许区间。 @@ -138,11 +390,13 @@ public enum PinnedImageGeometry { /// - frame: 期望的面板 frame(AppKit 点坐标)。 /// - imagePixelWidth: 图片的像素宽度。 /// - displayScale: 图片像素宽与原始选区点宽的比值。 + /// - anchor: frame 内保持不动的归一化锚点,坐标会钳制到 `0...1`。 /// - Returns: 收敛后的 frame;原 frame 已在区间内时原样返回。 public static func clampFrameToScaleRange( _ frame: CGRect, imagePixelWidth: CGFloat, - displayScale: CGFloat + displayScale: CGFloat, + anchor: CGPoint = .zero ) -> CGRect { guard frame.width > 0, frame.height > 0 else { return frame } let current = scale( @@ -153,11 +407,40 @@ public enum PinnedImageGeometry { let limited = clampedScale(current) guard limited != current else { return frame } let ratio = limited / current + let normalizedAnchor = CGPoint( + x: min(max(anchor.x, 0), 1), + y: min(max(anchor.y, 0), 1) + ) + let anchorX = frame.minX + frame.width * normalizedAnchor.x + let anchorY = frame.minY + frame.height * normalizedAnchor.y + let width = frame.width * ratio + let height = frame.height * ratio + return CGRect( + x: anchorX - width * normalizedAnchor.x, + y: anchorY - height * normalizedAnchor.y, + width: width, + height: height + ) + } + + /// 计算置顶面板右上角圆形关闭按钮的 frame。 + /// + /// 面板较小时按短边比例缩小按钮,避免关闭区域挤占中心移动区域。 + /// + /// - Parameter frame: 置顶面板的局部边界矩形。 + /// - Returns: 关闭按钮的局部矩形;输入尺寸无效或非有限时返回零矩形。 + public static func closeButtonFrame(in frame: CGRect) -> CGRect { + guard frame.width > 0, frame.height > 0, + frame.width.isFinite, frame.height.isFinite, + frame.minX.isFinite, frame.minY.isFinite, + frame.maxX.isFinite, frame.maxY.isFinite else { return .zero } + let size = min(20, min(frame.width, frame.height) * 0.4) + let inset = min(6, size * 0.3) return CGRect( - x: frame.minX, - y: frame.minY, - width: frame.width * ratio, - height: frame.height * ratio + x: frame.maxX - size - inset, + y: frame.maxY - size - inset, + width: size, + height: size ) } @@ -317,6 +600,30 @@ public enum PinnedImageGeometry { height: rect.height ) } + + private static func cornerDirection( + left: CGFloat, + right: CGFloat, + bottom: CGFloat, + top: CGFloat, + hitSize: CGSize + ) -> PinnedImageResizeDirection? { + if left <= hitSize.width, top <= hitSize.height { return .topLeft } + if right <= hitSize.width, top <= hitSize.height { return .topRight } + if left <= hitSize.width, bottom <= hitSize.height { return .bottomLeft } + if right <= hitSize.width, bottom <= hitSize.height { return .bottomRight } + return nil + } + + private static func ellipseContains(_ point: CGPoint, in rect: CGRect) -> Bool { + guard rect.width > 0, rect.height > 0, + rect.width.isFinite, rect.height.isFinite, + rect.minX.isFinite, rect.minY.isFinite else { return false } + let normalizedX = (point.x - rect.midX) / (rect.width / 2) + let normalizedY = (point.y - rect.midY) / (rect.height / 2) + let normalizedDistance = normalizedX * normalizedX + normalizedY * normalizedY + return normalizedX.isFinite && normalizedY.isFinite && normalizedDistance <= 1 + } } extension CGRect { diff --git a/Packages/CaptureCore/Sources/PinnedToolbarVisibility.swift b/Packages/CaptureCore/Sources/PinnedToolbarVisibility.swift index 5520af4..87423e9 100644 --- a/Packages/CaptureCore/Sources/PinnedToolbarVisibility.swift +++ b/Packages/CaptureCore/Sources/PinnedToolbarVisibility.swift @@ -17,6 +17,8 @@ public struct PinnedToolbarVisibility: Equatable, Sendable { private var isPointerInside: Bool private var isInteracting: Bool + private var isPanelResizing: Bool + private var hasCompletedPanelResize: Bool private var showDelay: TimeInterval private var hideDelay: TimeInterval @@ -32,6 +34,8 @@ public struct PinnedToolbarVisibility: Equatable, Sendable { self.isVisible = false self.isPointerInside = false self.isInteracting = false + self.isPanelResizing = false + self.hasCompletedPanelResize = false self.showDelay = max(0, showDelay) self.hideDelay = max(0, hideDelay) } @@ -49,10 +53,15 @@ public struct PinnedToolbarVisibility: Equatable, Sendable { /// /// - Returns: 延迟秒数;不应隐藏时返回 `nil`。 public var pendingHideDelay: TimeInterval? { - guard isPointerInside == false, isInteracting == false else { return nil } + guard isPointerInside == false, isInteracting == false, isPanelResizing == false else { return nil } return isVisible ? hideDelay : nil } + /// 面板缩放结束后,指针仍在面板内时是否应显示工具条。 + public var shouldShowAfterResize: Bool { + hasCompletedPanelResize && !isPanelResizing && isPointerInside + } + /// 记录鼠标进入面板或工具条。 public mutating func pointerEntered() { isPointerInside = true @@ -73,12 +82,28 @@ public struct PinnedToolbarVisibility: Equatable, Sendable { isInteracting = false } + /// 记录边缘或角落缩放开始,并立即隐藏工具条。 + public mutating func panelResizeBegan() { + isPanelResizing = true + hasCompletedPanelResize = false + isVisible = false + } + + /// 记录面板缩放结束;若指针仍在面板内,则恢复工具条。 + public mutating func panelResizeEnded() { + isPanelResizing = false + hasCompletedPanelResize = true + if shouldShowAfterResize { + isVisible = true + } + } + /// 应用延迟计时结束后的结果:显示工具条。 /// /// - Returns: 状态是否实际发生变化。 @discardableResult public mutating func applyShow() -> Bool { - guard !isVisible else { return false } + guard !isVisible, !isPanelResizing else { return false } isVisible = true return true } diff --git a/Packages/CaptureCore/Tests/CaptureCoreTests/PinnedImageGeometryTests.swift b/Packages/CaptureCore/Tests/CaptureCoreTests/PinnedImageGeometryTests.swift index 603cdc9..2f12e19 100644 --- a/Packages/CaptureCore/Tests/CaptureCoreTests/PinnedImageGeometryTests.swift +++ b/Packages/CaptureCore/Tests/CaptureCoreTests/PinnedImageGeometryTests.swift @@ -53,16 +53,13 @@ struct PinnedImageGeometryTests { #expect(PinnedImageGeometry.cascadeOffset(forIndex: -5) == .zero) } - @Test func fitToScreenShrinksOversizedFrameAndCentersIt() { - let screen = CGRect(x: 0, y: 0, width: 1000, height: 800) - let oversized = CGRect(x: 0, y: 0, width: 4000, height: 2000) + @Test func fitToScreenShrinksOversizedFrameAndKeepsItsLowerLeftAnchor() { + let screen = CGRect(x: 100, y: 50, width: 1000, height: 800) + let oversized = CGRect(x: 30, y: 20, width: 4000, height: 2000) let fitted = PinnedImageGeometry.fitToScreen(oversized, screenFrame: screen) - #expect(fitted.width <= screen.width * 0.9) - #expect(fitted.height <= screen.height * 0.9) - #expect(fitted.midX == screen.midX) - #expect(fitted.midY == screen.midY) + #expect(fitted == CGRect(x: 100, y: 50, width: 900, height: 450)) #expect(abs(fitted.width / fitted.height - 2) < 0.0001) } @@ -72,8 +69,7 @@ struct PinnedImageGeometryTests { let fitted = PinnedImageGeometry.fitToScreen(inside, screenFrame: screen) - #expect(fitted.width == inside.width) - #expect(fitted.height == inside.height) + #expect(fitted == inside) } @Test func fitToScreenReturnsFrameWhenScreenIsInvalid() { @@ -81,6 +77,165 @@ struct PinnedImageGeometryTests { #expect(PinnedImageGeometry.fitToScreen(frame, screenFrame: .zero) == frame) } + @Test func resizeDirectionRecognizesAllEdgesAndCorners() { + let frame = CGRect(x: 100, y: 200, width: 100, height: 80) + let hits: [(CGPoint, PinnedImageResizeDirection)] = [ + (CGPoint(x: 102, y: 240), .left), + (CGPoint(x: 198, y: 240), .right), + (CGPoint(x: 150, y: 202), .bottom), + (CGPoint(x: 150, y: 278), .top), + (CGPoint(x: 102, y: 202), .bottomLeft), + (CGPoint(x: 198, y: 202), .bottomRight), + (CGPoint(x: 102, y: 278), .topLeft), + (CGPoint(x: 198, y: 278), .topRight), + ] + + for (point, expected) in hits { + #expect(PinnedImageGeometry.resizeDirection(at: point, in: frame) == expected) + } + } + + @Test func resizeDirectionPrioritizesCornerHotZoneOverEdge() { + let frame = CGRect(x: 100, y: 200, width: 100, height: 80) + + #expect( + PinnedImageGeometry.resizeDirection(at: CGPoint(x: 110, y: 270), in: frame) == .topLeft + ) + } + + @Test func resizeDirectionUsesAxisAdaptiveCornersWithoutEdgeZonesOnSmallPanels() { + let frame = CGRect(x: 100, y: 100, width: 20, height: 40) + + #expect(PinnedImageGeometry.resizeDirection(at: CGPoint(x: 102, y: 108), in: frame) == .bottomLeft) + #expect(PinnedImageGeometry.resizeDirection(at: CGPoint(x: 107, y: 111), in: frame) == nil) + #expect(PinnedImageGeometry.resizeDirection(at: CGPoint(x: 100, y: 120), in: frame) == nil) + } + + @Test func interactionPrioritizesCloseButtonOverCornerResize() { + let frame = CGRect(x: 100, y: 200, width: 100, height: 80) + let closeButton = CGRect(x: 174, y: 254, width: 20, height: 20) + + #expect( + PinnedImageGeometry.interaction( + at: CGPoint(x: 190, y: 270), + in: frame, + closeButtonFrame: closeButton, + alpha: 1 + ) == .close + ) + } + + @Test func interactionPrioritizesTransparentEdgeResize() { + let frame = CGRect(x: 100, y: 200, width: 100, height: 80) + + #expect( + PinnedImageGeometry.interaction( + at: CGPoint(x: 102, y: 240), + in: frame, + closeButtonFrame: .zero, + alpha: 0 + ) == .resize(.left) + ) + } + + @Test func interactionPassesThroughTransparentInteriorAndMovesOpaqueInterior() { + let frame = CGRect(x: 100, y: 200, width: 100, height: 80) + let point = CGPoint(x: 150, y: 240) + + #expect( + PinnedImageGeometry.interaction( + at: point, + in: frame, + closeButtonFrame: .zero, + alpha: 0 + ) == .passThrough + ) + #expect( + PinnedImageGeometry.interaction( + at: point, + in: frame, + closeButtonFrame: .zero, + alpha: 0.5 + ) == .move + ) + } + + @Test func resizedFramePreservesTheOppositeAnchorForEveryDirection() { + struct ResizeCase { + let direction: PinnedImageResizeDirection + let translation: CGPoint + let expected: CGRect + } + + let start = CGRect(x: 100, y: 200, width: 200, height: 100) + let minimumSize = CGSize(width: 40, height: 20) + let cases = [ + ResizeCase( + direction: .left, + translation: CGPoint(x: -40, y: 0), + expected: CGRect(x: 60, y: 190, width: 240, height: 120) + ), + ResizeCase( + direction: .right, + translation: CGPoint(x: 40, y: 0), + expected: CGRect(x: 100, y: 190, width: 240, height: 120) + ), + ResizeCase( + direction: .bottom, + translation: CGPoint(x: 0, y: -20), + expected: CGRect(x: 80, y: 180, width: 240, height: 120) + ), + ResizeCase( + direction: .top, + translation: CGPoint(x: 0, y: 20), + expected: CGRect(x: 80, y: 200, width: 240, height: 120) + ), + ResizeCase( + direction: .topLeft, + translation: CGPoint(x: -40, y: 20), + expected: CGRect(x: 60, y: 200, width: 240, height: 120) + ), + ResizeCase( + direction: .topRight, + translation: CGPoint(x: 40, y: 20), + expected: CGRect(x: 100, y: 200, width: 240, height: 120) + ), + ResizeCase( + direction: .bottomLeft, + translation: CGPoint(x: -40, y: -20), + expected: CGRect(x: 60, y: 180, width: 240, height: 120) + ), + ResizeCase( + direction: .bottomRight, + translation: CGPoint(x: 40, y: -20), + expected: CGRect(x: 100, y: 180, width: 240, height: 120) + ), + ] + + for testCase in cases { + #expect( + PinnedImageGeometry.resizedFrame( + from: start, + direction: testCase.direction, + translation: testCase.translation, + minimumSize: minimumSize + ) == testCase.expected + ) + } + } + + @Test func resizedFrameEnforcesMinimumSizeWithoutBreakingAspectRatio() { + let start = CGRect(x: 100, y: 200, width: 200, height: 100) + let resized = PinnedImageGeometry.resizedFrame( + from: start, + direction: .bottomRight, + translation: CGPoint(x: -160, y: 80), + minimumSize: CGSize(width: 80, height: 30) + ) + + #expect(resized == CGRect(x: 100, y: 260, width: 80, height: 40)) + } + @Test func clampedScaleRespectsBounds() { #expect(PinnedImageGeometry.clampedScale(0.01) == PinnedImageGeometry.minimumScale) #expect(PinnedImageGeometry.clampedScale(100) == PinnedImageGeometry.maximumScale) @@ -164,6 +319,52 @@ struct PinnedImageGeometryTests { ) } + @Test func clampFrameToScaleRangePreservesNormalizedCenterAnchorAtMinimumScale() { + let frame = CGRect(x: 10, y: 20, width: 40, height: 20) + let clamped = PinnedImageGeometry.clampFrameToScaleRange( + frame, + imagePixelWidth: 1000, + displayScale: 2, + anchor: CGPoint(x: 0.5, y: 0.5) + ) + + #expect(clamped == CGRect(x: 5, y: 17.5, width: 50, height: 25)) + } + + @Test func clampFrameToScaleRangePreservesNormalizedTopRightAnchorAtMaximumScale() { + let frame = CGRect(x: 10, y: 20, width: 5000, height: 2500) + let clamped = PinnedImageGeometry.clampFrameToScaleRange( + frame, + imagePixelWidth: 1000, + displayScale: 2, + anchor: CGPoint(x: 1, y: 1) + ) + + #expect(clamped == CGRect(x: 1010, y: 520, width: 4000, height: 2000)) + } + + @Test func clampFrameToVisibleBoundsUsesOnlyTheTranslationNeededToFit() { + let screen = CGRect(x: 100, y: 50, width: 800, height: 600) + let partlyOutsideAtLowerLeft = CGRect(x: 70, y: 20, width: 200, height: 100) + let partlyOutsideAtUpperRight = CGRect(x: 850, y: 550, width: 200, height: 100) + + #expect( + PinnedImageGeometry.clampFrameToVisibleBounds(partlyOutsideAtLowerLeft, screenFrame: screen) + == CGRect(x: 100, y: 50, width: 200, height: 100) + ) + #expect( + PinnedImageGeometry.clampFrameToVisibleBounds(partlyOutsideAtUpperRight, screenFrame: screen) + == CGRect(x: 700, y: 550, width: 200, height: 100) + ) + } + + @Test func clampFrameToVisibleBoundsLeavesAnAlreadyVisibleFrameUnchanged() { + let screen = CGRect(x: 100, y: 50, width: 800, height: 600) + let frame = CGRect(x: 120, y: 80, width: 200, height: 100) + + #expect(PinnedImageGeometry.clampFrameToVisibleBounds(frame, screenFrame: screen) == frame) + } + // MARK: - 对数滑杆映射 @Test func logScalePositionMapsEndpointsToRange() { @@ -323,3 +524,72 @@ struct PinnedImageGeometryTests { #expect(frame == .zero) } } + +struct PinnedImageResizeRegressionTests { + @Test func interactionLeavesAMovableCoreOnMinimumScalePanel() { + let frame = CGRect(x: 0, y: 0, width: 40, height: 20) + let closeButton = PinnedImageGeometry.closeButtonFrame(in: frame) + + #expect(closeButton == CGRect(x: 29.6, y: 9.6, width: 8, height: 8)) + #expect( + PinnedImageGeometry.interaction( + at: CGPoint(x: 20, y: 10), + in: frame, + closeButtonFrame: closeButton, + alpha: 1 + ) == .move + ) + #expect( + PinnedImageGeometry.interaction( + at: CGPoint(x: 12, y: 2), + in: frame, + closeButtonFrame: closeButton, + alpha: 1 + ) == .resize(.bottomLeft) + ) + #expect( + PinnedImageGeometry.interaction( + at: CGPoint(x: 34, y: 14), + in: frame, + closeButtonFrame: closeButton, + alpha: 1 + ) == .close + ) + } + + @Test func interactionOnlyClosesInsideTheVisibleCloseEllipse() { + let frame = CGRect(x: 0, y: 0, width: 40, height: 40) + let closeButton = PinnedImageGeometry.closeButtonFrame(in: frame) + let outsideEllipse = CGPoint(x: 20, y: 20) + + #expect(closeButton.contains(outsideEllipse)) + #expect( + PinnedImageGeometry.interaction( + at: outsideEllipse, + in: frame, + closeButtonFrame: closeButton, + alpha: 1 + ) == .move + ) + #expect( + PinnedImageGeometry.interaction( + at: CGPoint(x: closeButton.midX, y: closeButton.midY), + in: frame, + closeButtonFrame: closeButton, + alpha: 1 + ) == .close + ) + } + + @Test func resizedFrameDoesNotJumpToMinimumSizeWhenCurrentFrameIsSmaller() { + let start = CGRect(x: 0, y: 0, width: 40, height: 20) + let resized = PinnedImageGeometry.resizedFrame( + from: start, + direction: .right, + translation: CGPoint(x: 2, y: 0), + minimumSize: CGSize(width: 40, height: 40) + ) + + #expect(resized == CGRect(x: 0, y: -0.5, width: 42, height: 21)) + } +} diff --git a/Packages/CaptureCore/Tests/CaptureCoreTests/PinnedToolbarVisibilityTests.swift b/Packages/CaptureCore/Tests/CaptureCoreTests/PinnedToolbarVisibilityTests.swift index 7481620..a2452d2 100644 --- a/Packages/CaptureCore/Tests/CaptureCoreTests/PinnedToolbarVisibilityTests.swift +++ b/Packages/CaptureCore/Tests/CaptureCoreTests/PinnedToolbarVisibilityTests.swift @@ -81,6 +81,33 @@ struct PinnedToolbarVisibilityTests { #expect(state.isVisible == false) } + @Test func panelResizeHidesAndRestoresToolbarWhenPointerRemainsInside() { + var state = PinnedToolbarVisibility() + state.pointerEntered() + state.applyShow() + + state.panelResizeBegan() + #expect(state.isVisible == false) + #expect(state.shouldShowAfterResize == false) + + state.panelResizeEnded() + #expect(state.shouldShowAfterResize) + #expect(state.isVisible) + } + + @Test func panelResizeDoesNotRestoreToolbarAfterPointerLeaves() { + var state = PinnedToolbarVisibility() + state.pointerEntered() + state.applyShow() + state.panelResizeBegan() + state.pointerExited() + + state.panelResizeEnded() + + #expect(state.shouldShowAfterResize == false) + #expect(state.isVisible == false) + } + @Test func hideDoesNothingWhenAlreadyHidden() { var state = PinnedToolbarVisibility() #expect(state.pendingHideDelay == nil) diff --git a/README.md b/README.md index abe13ab..0d9042e 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@

- Download + Download

diff --git a/project.yml b/project.yml index 22ce498..6e6efa2 100644 --- a/project.yml +++ b/project.yml @@ -15,7 +15,7 @@ settings: SWIFT_VERSION: "6.0" MACOSX_DEPLOYMENT_TARGET: "13.0" ALWAYS_SEARCH_USER_PATHS: NO - MARKETING_VERSION: "0.9.9" + MARKETING_VERSION: "1.0.0" CURRENT_PROJECT_VERSION: "1" configs: Debug: diff --git a/version.txt b/version.txt index 7e310ba..3eefcb9 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.9.9 +1.0.0