mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 18:21:23 +00:00
* feat(macos): add Quick Chat capture context * docs(macos): describe Quick Chat capture context * fix(macos): bound the AX text walk with deadline, cancellation, and per-message timeout; keep distinct repeated text * fix(macos): ancestry-scoped echo suppression and adapter-wide AX timeouts * fix(macos): hard outer timeout abandons a hung AX walk * fix(macos): truly abandon hung AX walks; hide the bar synchronously before area selection * chore(i18n): refresh native locale artifacts for Quick Chat capture strings
145 lines
5.3 KiB
Swift
145 lines
5.3 KiB
Swift
import AppKit
|
||
|
||
enum QuickChatAreaPickerLogic {
|
||
static let minimumSelectionDimension: CGFloat = 8
|
||
|
||
static func normalizedSelection(from start: CGPoint, to end: CGPoint) -> CGRect? {
|
||
let rect = CGRect(
|
||
x: min(start.x, end.x),
|
||
y: min(start.y, end.y),
|
||
width: abs(end.x - start.x),
|
||
height: abs(end.y - start.y))
|
||
// Tiny drags are almost always clicks or hand jitter; treating them as cancel
|
||
// avoids surprising one-pixel captures and invalid capture-area requests.
|
||
guard rect.width >= Self.minimumSelectionDimension,
|
||
rect.height >= Self.minimumSelectionDimension
|
||
else { return nil }
|
||
return rect
|
||
}
|
||
|
||
/// Converts AppKit's bottom-left global screen space into the top-left global
|
||
/// display coordinates consumed by Peekaboo captureArea/SCDisplay.frame.
|
||
static func globalDisplayRect(
|
||
appKitRect: CGRect,
|
||
screenFrame: CGRect,
|
||
displayBounds: CGRect) -> CGRect
|
||
{
|
||
CGRect(
|
||
x: displayBounds.minX + appKitRect.minX - screenFrame.minX,
|
||
y: displayBounds.minY + screenFrame.maxY - appKitRect.maxY,
|
||
width: appKitRect.width,
|
||
height: appKitRect.height)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class QuickChatAreaPickerView: NSView {
|
||
private let onSelect: (CGRect) -> Void
|
||
private let onCancel: () -> Void
|
||
private var dragStart: CGPoint?
|
||
private var selection: CGRect?
|
||
|
||
init(frame: CGRect, onSelect: @escaping (CGRect) -> Void, onCancel: @escaping () -> Void) {
|
||
self.onSelect = onSelect
|
||
self.onCancel = onCancel
|
||
super.init(frame: frame)
|
||
self.wantsLayer = true
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder _: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool {
|
||
true
|
||
}
|
||
|
||
override func resetCursorRects() {
|
||
self.addCursorRect(self.bounds, cursor: .crosshair)
|
||
}
|
||
|
||
override func mouseDown(with event: NSEvent) {
|
||
let point = self.clamped(self.convert(event.locationInWindow, from: nil))
|
||
self.dragStart = point
|
||
self.selection = nil
|
||
self.needsDisplay = true
|
||
}
|
||
|
||
override func mouseDragged(with event: NSEvent) {
|
||
guard let dragStart else { return }
|
||
let point = self.clamped(self.convert(event.locationInWindow, from: nil))
|
||
self.selection = self.normalizedRect(from: dragStart, to: point)
|
||
self.needsDisplay = true
|
||
}
|
||
|
||
override func mouseUp(with event: NSEvent) {
|
||
guard let dragStart else {
|
||
self.onCancel()
|
||
return
|
||
}
|
||
let point = self.clamped(self.convert(event.locationInWindow, from: nil))
|
||
self.dragStart = nil
|
||
guard let selection = QuickChatAreaPickerLogic.normalizedSelection(from: dragStart, to: point) else {
|
||
self.onCancel()
|
||
return
|
||
}
|
||
self.onSelect(selection)
|
||
}
|
||
|
||
override func draw(_ dirtyRect: NSRect) {
|
||
super.draw(dirtyRect)
|
||
let dimmingPath = NSBezierPath(rect: self.bounds)
|
||
if let selection {
|
||
dimmingPath.appendRect(selection)
|
||
}
|
||
dimmingPath.windingRule = .evenOdd
|
||
NSColor.black.withAlphaComponent(0.35).setFill()
|
||
dimmingPath.fill()
|
||
|
||
guard let selection, selection.width > 0, selection.height > 0 else { return }
|
||
let scale = self.window?.backingScaleFactor ?? 1
|
||
let border = NSBezierPath(rect: selection.insetBy(dx: 0.5 / scale, dy: 0.5 / scale))
|
||
border.lineWidth = 1 / scale
|
||
NSColor.controlAccentColor.setStroke()
|
||
border.stroke()
|
||
self.drawSizeBadge(for: selection)
|
||
}
|
||
|
||
private func drawSizeBadge(for selection: CGRect) {
|
||
let text = "\(Int(selection.width.rounded())) × \(Int(selection.height.rounded()))" as NSString
|
||
let attributes: [NSAttributedString.Key: Any] = [
|
||
.font: NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium),
|
||
.foregroundColor: NSColor.white,
|
||
]
|
||
let textSize = text.size(withAttributes: attributes)
|
||
let badgeSize = CGSize(width: textSize.width + 14, height: textSize.height + 8)
|
||
let preferredY = selection.maxY + 7
|
||
let fallbackY = selection.minY - badgeSize.height - 7
|
||
let y = preferredY + badgeSize.height <= self.bounds.maxY ? preferredY : max(self.bounds.minY + 4, fallbackY)
|
||
let x = min(
|
||
max(self.bounds.minX + 4, selection.minX),
|
||
self.bounds.maxX - badgeSize.width - 4)
|
||
let badgeRect = CGRect(origin: CGPoint(x: x, y: y), size: badgeSize)
|
||
NSColor.black.withAlphaComponent(0.78).setFill()
|
||
NSBezierPath(roundedRect: badgeRect, xRadius: 6, yRadius: 6).fill()
|
||
text.draw(
|
||
at: CGPoint(x: badgeRect.minX + 7, y: badgeRect.minY + 4),
|
||
withAttributes: attributes)
|
||
}
|
||
|
||
private func normalizedRect(from start: CGPoint, to end: CGPoint) -> CGRect {
|
||
CGRect(
|
||
x: min(start.x, end.x),
|
||
y: min(start.y, end.y),
|
||
width: abs(end.x - start.x),
|
||
height: abs(end.y - start.y))
|
||
}
|
||
|
||
private func clamped(_ point: CGPoint) -> CGPoint {
|
||
CGPoint(
|
||
x: min(max(point.x, self.bounds.minX), self.bounds.maxX),
|
||
y: min(max(point.y, self.bounds.minY), self.bounds.maxY))
|
||
}
|
||
}
|