//go:build darwin package main /* #cgo CFLAGS: -x objective-c -fobjc-arc -fblocks #cgo LDFLAGS: -framework AppKit -framework CoreGraphics -framework QuartzCore #include #include #import #import #import extern void nativeOverlayConfirm(int x, int y, int w, int h, const char *annotationsJSON); extern void nativeOverlayCopy(int x, int y, int w, int h, const char *annotationsJSON); extern void nativeOverlaySave(int x, int y, int w, int h, const char *annotationsJSON, const char *dir); extern void nativeOverlaySaveRemote(int x, int y, int w, int h, const char *annotationsJSON); extern void nativeOverlayUploadFTP(int x, int y, int w, int h, const char *annotationsJSON); extern void nativeOverlaySummarize(int x, int y, int w, int h, const char *annotationsJSON); extern void nativeOverlayOCR(int x, int y, int w, int h, const char *annotationsJSON); extern void nativeOverlayCancel(void); static NSWindow *nativeOverlayWindow = nil; static id nativeOverlayKeyMonitor = nil; @interface SnipNativeOverlayPanel : NSPanel @end @implementation SnipNativeOverlayPanel - (BOOL)canBecomeKeyWindow { return YES; } - (BOOL)canBecomeMainWindow { return YES; } @end // SnipHoverButton — NSButton subclass that animates its tint color on hover. // // Design rationale: // - NSButton has no built-in hover state; we install an NSTrackingArea that // tracks mouseEntered/mouseExited regardless of focus state. // - When the cursor enters, the content tint color animates from `baseColor` // to `hoverColor` over 0.2s using NSAnimationContext, matching the spec. // When the cursor leaves, the color reverts immediately (no animation). // - `baseColor`/`hoverColor` are configurable per-instance so each action // button can keep a shared white default but expose its own hover hue // (cancel = red, others = blue). @interface SnipHoverButton : NSButton @property(strong) NSColor *baseColor; @property(strong) NSColor *hoverColor; @property(strong) NSTrackingArea *hoverTrackingArea; @end @implementation SnipHoverButton - (void)updateTrackingAreas { [super updateTrackingAreas]; if (_hoverTrackingArea) { [self removeTrackingArea:_hoverTrackingArea]; } NSTrackingAreaOptions opts = NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways | NSTrackingInVisibleRect; _hoverTrackingArea = [[NSTrackingArea alloc] initWithRect:NSZeroRect options:opts owner:self userInfo:nil]; [self addTrackingArea:_hoverTrackingArea]; } - (void)mouseEntered:(NSEvent *)event { if (@available(macOS 10.14, *)) { NSColor *target = _hoverColor ?: [NSColor whiteColor]; // Animate the tint color transition over 0.2s for a soft fade-in. [NSAnimationContext runAnimationGroup:^(NSAnimationContext *ctx) { [ctx setDuration:0.2]; [ctx setTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]]; [[self animator] setContentTintColor:target]; } completionHandler:nil]; } } - (void)mouseExited:(NSEvent *)event { if (@available(macOS 10.14, *)) { NSColor *base = _baseColor ?: [NSColor whiteColor]; // Snap back to the base color instantly (no animation) per spec. [self setContentTintColor:base]; } } @end @interface SnipToolSettingsView : NSView @property CGFloat arrowX; @end @implementation SnipToolSettingsView - (BOOL)isFlipped { return YES; } - (void)drawRect:(NSRect)dirtyRect { [[NSColor clearColor] setFill]; NSRectFill(dirtyRect); CGFloat arrowHalf = 8; CGFloat arrowH = 9; CGFloat radius = 8; NSRect body = NSMakeRect(0, arrowH, self.bounds.size.width, self.bounds.size.height - arrowH); CGFloat arrowX = MAX(18, MIN(_arrowX, self.bounds.size.width - 18)); NSBezierPath *path = [NSBezierPath bezierPath]; [path moveToPoint:NSMakePoint(NSMinX(body) + radius, NSMinY(body))]; [path lineToPoint:NSMakePoint(arrowX - arrowHalf, NSMinY(body))]; [path lineToPoint:NSMakePoint(arrowX, 0)]; [path lineToPoint:NSMakePoint(arrowX + arrowHalf, NSMinY(body))]; [path lineToPoint:NSMakePoint(NSMaxX(body) - radius, NSMinY(body))]; [path curveToPoint:NSMakePoint(NSMaxX(body), NSMinY(body) + radius) controlPoint1:NSMakePoint(NSMaxX(body) - radius / 2, NSMinY(body)) controlPoint2:NSMakePoint(NSMaxX(body), NSMinY(body) + radius / 2)]; [path lineToPoint:NSMakePoint(NSMaxX(body), NSMaxY(body) - radius)]; [path curveToPoint:NSMakePoint(NSMaxX(body) - radius, NSMaxY(body)) controlPoint1:NSMakePoint(NSMaxX(body), NSMaxY(body) - radius / 2) controlPoint2:NSMakePoint(NSMaxX(body) - radius / 2, NSMaxY(body))]; [path lineToPoint:NSMakePoint(NSMinX(body) + radius, NSMaxY(body))]; [path curveToPoint:NSMakePoint(NSMinX(body), NSMaxY(body) - radius) controlPoint1:NSMakePoint(NSMinX(body) + radius / 2, NSMaxY(body)) controlPoint2:NSMakePoint(NSMinX(body), NSMaxY(body) - radius / 2)]; [path lineToPoint:NSMakePoint(NSMinX(body), NSMinY(body) + radius)]; [path curveToPoint:NSMakePoint(NSMinX(body) + radius, NSMinY(body)) controlPoint1:NSMakePoint(NSMinX(body), NSMinY(body) + radius / 2) controlPoint2:NSMakePoint(NSMinX(body) + radius / 2, NSMinY(body))]; [path closePath]; [[NSColor colorWithCalibratedWhite:1 alpha:0.96] setFill]; [path fill]; } @end @interface SnipNativeOverlayView : NSView @property BOOL hasSelection; @property BOOL creating; @property BOOL moving; @property BOOL resizing; @property BOOL annotating; @property NSRect selection; @property NSPoint anchor; @property NSPoint moveOffset; @property NSRect resizeStart; @property NSString *resizeHandle; @property NSString *activeTool; @property(strong) NSColor *activeColor; @property CGFloat activeStrokeWidth; @property CGFloat activeFontSize; @property CGFloat mosaicBrushWidth; @property NSString *mosaicMode; @property(strong) NSMutableArray *annotations; @property(strong) NSMutableDictionary *draftAnnotation; // A downsampled snapshot of the screen beneath the overlay. Drawing this // image back at full size with interpolation disabled produces a genuine // pixel-block preview instead of a translucent placeholder stroke. @property(strong) NSImage *mosaicPreviewImage; @property(strong) NSButton *cancelButton; // Use `clipboardButton` (not `copyButton`) to avoid ARC's "copy" method // family ownership rule which would otherwise treat the synthesized getter // as returning a +1 retained object. @property(strong) NSButton *clipboardButton; @property(strong) NSButton *saveButton; @property(strong) NSButton *saveRemoteButton; @property(strong) NSButton *ftpButton; @property(strong) NSButton *ocrButton; @property(strong) NSButton *summaryButton; @property(strong) NSButton *uploadButton; @property(strong) NSButton *penButton; @property(strong) NSButton *lineButton; @property(strong) NSButton *arrowButton; @property(strong) NSButton *rectButton; @property(strong) NSButton *ellipseButton; @property(strong) NSButton *textButton; @property(strong) NSButton *mosaicButton; @property(strong) NSButton *undoButton; @property(strong) SnipToolSettingsView *toolSettingsView; @property(strong) NSView *settingsDivider; @property(strong) NSMutableArray *strokeSettingButtons; @property(strong) NSMutableArray *fontSettingButtons; @property(strong) NSMutableArray *colorSettingButtons; @property(strong) NSMutableArray *mosaicModeButtons; @property(strong) NSMutableArray *mosaicWidthButtons; @property(strong) NSView *actionToolbarBg; @property(strong) NSTextField *textEditor; @property NSPoint textEditorLocalPoint; @property NSInteger textEditorAnnotationIndex; @property(strong) NSColor *textEditorColor; @property NSInteger selectedTextAnnotationIndex; @property BOOL draggingTextAnnotation; @property NSPoint textDragStartLocalPoint; @property NSPoint textDragOriginalLocalPoint; @property(strong) NSTextField *sizeLabel; @property(strong) NSTextField *hintLabel; - (void)syncControls; - (void)styleControls; - (void)selectText; - (void)confirmSelection; - (void)copySelection; - (void)saveSelection; - (void)saveRemoteSelection; - (void)uploadFTPSelection; - (void)ocrSelection; - (void)summarizeSelection; - (void)cancelSelection; - (void)beginTextAnnotationAtPoint:(NSPoint)localPoint; - (void)beginEditingTextAnnotationAtIndex:(NSInteger)index; - (NSInteger)textAnnotationIndexAtPoint:(NSPoint)p; - (NSRect)textAnnotationRectForItem:(NSDictionary *)item; - (NSPoint)clampedTextLocalPoint:(NSPoint)localPoint text:(NSString *)text fontSize:(CGFloat)fontSize; - (void)selectStrokeWidth:(NSButton *)sender; - (void)selectFontSize:(NSButton *)sender; - (void)selectToolColor:(NSButton *)sender; - (void)selectMosaicMode:(NSButton *)sender; - (void)selectMosaicWidth:(NSButton *)sender; - (BOOL)isEditingText; - (void)commitTextEditor; - (void)cancelTextEditor; @end @implementation SnipNativeOverlayView - (BOOL)isFlipped { return YES; } - (BOOL)acceptsFirstResponder { return YES; } - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; if (self) { [self setWantsLayer:YES]; [[self layer] setBackgroundColor:[[NSColor clearColor] CGColor]]; _activeTool = nil; _activeColor = [NSColor colorWithCalibratedRed:239.0/255.0 green:68.0/255.0 blue:68.0/255.0 alpha:1.0]; _activeStrokeWidth = 3.0; _activeFontSize = 20.0; _mosaicBrushWidth = 24.0; _mosaicMode = @"brush"; _annotations = [NSMutableArray array]; _textEditorAnnotationIndex = -1; _selectedTextAnnotationIndex = -1; // Use SnipHoverButton for the action buttons so we get hover-color // animation. Annotation buttons stay as plain NSButton because they // already have explicit on/off "active" styling. _cancelButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(cancelSelection)]; _clipboardButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(copySelection)]; _saveButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(saveSelection)]; _saveRemoteButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(saveRemoteSelection)]; _ftpButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(uploadFTPSelection)]; _ocrButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(ocrSelection)]; _summaryButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(summarizeSelection)]; _uploadButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(confirmSelection)]; // Tooltip strings shown on hover for each action button. // Localized in Chinese to match the rest of the action UI surface. [_cancelButton setToolTip:@"取消截图"]; [_clipboardButton setToolTip:@"复制图片"]; [_saveButton setToolTip:@"保存本地"]; [_saveRemoteButton setToolTip:@"上传 SSH/SCP"]; [_ftpButton setToolTip:@"上传 FTP/SFTP"]; [_ocrButton setToolTip:@"提取文字"]; [_summaryButton setToolTip:@"复制总结"]; [_uploadButton setToolTip:@"上传 S3"]; _penButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectPen)]; _lineButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectLine)]; _arrowButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectArrow)]; _rectButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectRect)]; _ellipseButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectEllipse)]; _textButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectText)]; _mosaicButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectMosaic)]; _undoButton = [NSButton buttonWithTitle:@"" target:self action:@selector(undoAnnotation)]; _toolSettingsView = [[SnipToolSettingsView alloc] initWithFrame:NSZeroRect]; _settingsDivider = [[NSView alloc] initWithFrame:NSZeroRect]; _strokeSettingButtons = [NSMutableArray array]; _fontSettingButtons = [NSMutableArray array]; _colorSettingButtons = [NSMutableArray array]; _mosaicModeButtons = [NSMutableArray array]; _mosaicWidthButtons = [NSMutableArray array]; // Dark rounded background container behind the action icon buttons, // mirroring the look of `.action-toolbar` in the Vue overlay. _actionToolbarBg = [[NSView alloc] initWithFrame:NSZeroRect]; [_actionToolbarBg setWantsLayer:YES]; [[_actionToolbarBg layer] setCornerRadius:8]; [[_actionToolbarBg layer] setBackgroundColor:[[NSColor colorWithCalibratedWhite:0.11 alpha:0.94] CGColor]]; _sizeLabel = [NSTextField labelWithString:@""]; _hintLabel = [NSTextField labelWithString:@"Drag to select an area · Esc to cancel"]; [self styleControls]; // Add the action toolbar background BEFORE the buttons so it sits // behind them in the view hierarchy (AppKit z-order = subview order). [self addSubview:_actionToolbarBg]; for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _ftpButton, _ocrButton, _summaryButton, _uploadButton, _penButton, _lineButton, _arrowButton, _rectButton, _ellipseButton, _textButton, _mosaicButton, _undoButton, _toolSettingsView, _sizeLabel, _hintLabel]) { [self addSubview:view]; } for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _ftpButton, _ocrButton, _summaryButton, _uploadButton, _penButton, _lineButton, _arrowButton, _rectButton, _ellipseButton, _textButton, _mosaicButton, _undoButton, _toolSettingsView, _actionToolbarBg]) { [view setHidden:YES]; } [_sizeLabel setHidden:YES]; [_sizeLabel setTextColor:[NSColor whiteColor]]; [_sizeLabel setBackgroundColor:[NSColor colorWithCalibratedWhite:0.08 alpha:0.78]]; [_sizeLabel setDrawsBackground:YES]; [_sizeLabel setBezeled:NO]; [_sizeLabel setAlignment:NSTextAlignmentCenter]; [_hintLabel setTextColor:[NSColor whiteColor]]; [_hintLabel setBackgroundColor:[NSColor colorWithCalibratedWhite:0.0 alpha:0.5]]; [_hintLabel setDrawsBackground:YES]; [_hintLabel setBezeled:NO]; [_hintLabel setAlignment:NSTextAlignmentCenter]; [_hintLabel setFrame:NSMakeRect((frame.size.width - 320) / 2, 24, 320, 24)]; } return self; } - (NSString *)hexForColor:(NSColor *)color { NSColor *rgb = [color colorUsingColorSpace:[NSColorSpace sRGBColorSpace]]; NSInteger r = (NSInteger)llround([rgb redComponent] * 255.0); NSInteger g = (NSInteger)llround([rgb greenComponent] * 255.0); NSInteger b = (NSInteger)llround([rgb blueComponent] * 255.0); return [NSString stringWithFormat:@"#%02lx%02lx%02lx", (long)r, (long)g, (long)b]; } - (NSImage *)iconFromSVG:(NSString *)svg { NSData *data = [svg dataUsingEncoding:NSUTF8StringEncoding]; NSImage *image = [[NSImage alloc] initWithData:data]; [image setTemplate:YES]; return image; } - (void)styleButton:(NSButton *)button background:(NSColor *)background foreground:(NSColor *)foreground { [button setBordered:NO]; [button setBezelStyle:NSBezelStyleRegularSquare]; [button setWantsLayer:YES]; [[button layer] setCornerRadius:5]; [[button layer] setBackgroundColor:[background CGColor]]; NSMutableAttributedString *title = [[NSMutableAttributedString alloc] initWithString:[button title]]; [title addAttribute:NSForegroundColorAttributeName value:foreground range:NSMakeRange(0, [title length])]; [title addAttribute:NSFontAttributeName value:[NSFont systemFontOfSize:12 weight:NSFontWeightMedium] range:NSMakeRange(0, [title length])]; [button setAttributedTitle:title]; } - (void)styleIconButton:(NSButton *)button image:(NSImage *)image { [button setBordered:NO]; [button setBezelStyle:NSBezelStyleRegularSquare]; [button setImagePosition:NSImageOnly]; [button setImageScaling:NSImageScaleProportionallyDown]; if (@available(macOS 10.14, *)) { [button setContentTintColor:[NSColor whiteColor]]; } [button setImage:image]; [button setWantsLayer:YES]; [[button layer] setCornerRadius:5]; [[button layer] setBackgroundColor:[[NSColor clearColor] CGColor]]; } - (void)styleControls { // Cancel / Copy / Save / SSH / FTP / OCR / Summary / S3 — square icon buttons // rendered from the user-supplied SVG assets. All actions default to white // and animate to a per-action accent color on hover (cancel = red, // others = blue). The base "white default" replaces the previous blue // upload tint so the toolbar reads as a uniform icon row. NSImage *cancelIcon = [self iconFromSVG:@""]; NSImage *copyIcon = [self iconFromSVG:@""]; NSImage *saveIcon = [self iconFromSVG:@""]; // save-remote.svg — provided by the user; its content is the same arrow // icon as the previous upload, so we reuse it verbatim here. NSImage *saveRemoteIcon = [self iconFromSVG:@""]; NSImage *ftpIcon = [self iconFromSVG:@""]; NSImage *ocrIcon = [self iconFromSVG:@""]; NSImage *summaryIcon = [self iconFromSVG:@""]; // upload.svg — newly replaced "send/cloud" icon supplied by the user. NSImage *uploadIcon = [self iconFromSVG:@""]; [self styleIconButton:_cancelButton image:cancelIcon]; [self styleIconButton:_clipboardButton image:copyIcon]; [self styleIconButton:_saveButton image:saveIcon]; [self styleIconButton:_saveRemoteButton image:saveRemoteIcon]; [self styleIconButton:_ftpButton image:ftpIcon]; [self styleIconButton:_ocrButton image:ocrIcon]; [self styleIconButton:_summaryButton image:summaryIcon]; [self styleIconButton:_uploadButton image:uploadIcon]; // Configure hover colors for the action buttons. White is the shared // base; cancel turns red on hover and the rest turn blue, providing a // glance-able cue for destructive vs. constructive actions. NSColor *baseWhite = [NSColor whiteColor]; NSColor *hoverRed = [NSColor colorWithCalibratedRed:248.0/255.0 green:113.0/255.0 blue:113.0/255.0 alpha:1.0]; NSColor *hoverBlue = [NSColor colorWithCalibratedRed:96.0/255.0 green:165.0/255.0 blue:250.0/255.0 alpha:1.0]; SnipHoverButton *cancelHB = (SnipHoverButton *)_cancelButton; SnipHoverButton *clipboardHB = (SnipHoverButton *)_clipboardButton; SnipHoverButton *saveHB = (SnipHoverButton *)_saveButton; SnipHoverButton *saveRemoteHB = (SnipHoverButton *)_saveRemoteButton; SnipHoverButton *ftpHB = (SnipHoverButton *)_ftpButton; SnipHoverButton *ocrHB = (SnipHoverButton *)_ocrButton; SnipHoverButton *summaryHB = (SnipHoverButton *)_summaryButton; SnipHoverButton *uploadHB = (SnipHoverButton *)_uploadButton; cancelHB.baseColor = baseWhite; cancelHB.hoverColor = hoverRed; clipboardHB.baseColor = baseWhite; clipboardHB.hoverColor = hoverBlue; saveHB.baseColor = baseWhite; saveHB.hoverColor = hoverBlue; saveRemoteHB.baseColor = baseWhite; saveRemoteHB.hoverColor = hoverBlue; ftpHB.baseColor = baseWhite; ftpHB.hoverColor = hoverBlue; ocrHB.baseColor = baseWhite; ocrHB.hoverColor = hoverBlue; summaryHB.baseColor = baseWhite; summaryHB.hoverColor = hoverBlue; uploadHB.baseColor = baseWhite; uploadHB.hoverColor = hoverBlue; [self styleIconButton:_penButton image:[self iconFromSVG:@""]]; [self styleIconButton:_lineButton image:[self iconFromSVG:@""]]; [self styleIconButton:_arrowButton image:[self iconFromSVG:@""]]; [_penButton setToolTip:@"画笔标记"]; [_lineButton setToolTip:@"直线标记"]; [_arrowButton setToolTip:@"箭头标记"]; [self styleIconButton:_rectButton image:[self iconFromSVG:@""]]; [self styleIconButton:_ellipseButton image:[self iconFromSVG:@""]]; [self styleIconButton:_textButton image:[self iconFromSVG:@""]]; [self styleIconButton:_mosaicButton image:[self iconFromSVG:@""]]; [_mosaicButton setToolTip:@"马赛克标记"]; [self styleIconButton:_undoButton image:[self iconFromSVG:@""]]; [_toolSettingsView setWantsLayer:NO]; [_settingsDivider setWantsLayer:YES]; [[_settingsDivider layer] setBackgroundColor:[[NSColor colorWithCalibratedWhite:0.82 alpha:1] CGColor]]; [_toolSettingsView addSubview:_settingsDivider]; for (NSNumber *width in @[@2, @4, @6]) { NSButton *button = [NSButton buttonWithTitle:@"●" target:self action:@selector(selectStrokeWidth:)]; [button setTag:[width integerValue]]; [button setBordered:NO]; [button setWantsLayer:YES]; [[button layer] setCornerRadius:6]; NSMutableAttributedString *title = [[NSMutableAttributedString alloc] initWithString:@"●"]; [title addAttribute:NSFontAttributeName value:[NSFont systemFontOfSize:8 + [width doubleValue] * 2 weight:NSFontWeightBold] range:NSMakeRange(0, title.length)]; [button setAttributedTitle:title]; [_strokeSettingButtons addObject:button]; [_toolSettingsView addSubview:button]; } for (NSNumber *size in @[@16, @20, @28, @36]) { NSButton *button = [NSButton buttonWithTitle:[NSString stringWithFormat:@"%ld", (long)[size integerValue]] target:self action:@selector(selectFontSize:)]; [button setTag:[size integerValue]]; [button setBordered:NO]; [button setWantsLayer:YES]; [[button layer] setCornerRadius:6]; NSMutableAttributedString *title = [[NSMutableAttributedString alloc] initWithString:[button title]]; [title addAttribute:NSForegroundColorAttributeName value:[NSColor colorWithCalibratedWhite:0.12 alpha:1] range:NSMakeRange(0, title.length)]; [title addAttribute:NSFontAttributeName value:[NSFont systemFontOfSize:13 weight:NSFontWeightBold] range:NSMakeRange(0, title.length)]; [button setAttributedTitle:title]; [_fontSettingButtons addObject:button]; [_toolSettingsView addSubview:button]; } for (NSUInteger i = 0; i < 2; i++) { NSButton *button = [NSButton buttonWithTitle:@"" target:self action:@selector(selectMosaicMode:)]; [button setTag:(NSInteger)i]; [button setBordered:NO]; [button setWantsLayer:YES]; [[button layer] setCornerRadius:5]; NSString *svg = i == 0 ? @"" : @""; [button setImage:[self iconFromSVG:svg]]; [button setImagePosition:NSImageOnly]; [button setImageScaling:NSImageScaleProportionallyDown]; NSString *accessibilityLabel = i == 0 ? @"涂抹马赛克" : @"框选马赛克"; [button setToolTip:accessibilityLabel]; [button setAccessibilityLabel:accessibilityLabel]; [_mosaicModeButtons addObject:button]; [_toolSettingsView addSubview:button]; } for (NSNumber *width in @[@16, @24, @36, @52]) { NSButton *button = [NSButton buttonWithTitle:@"●" target:self action:@selector(selectMosaicWidth:)]; [button setTag:[width integerValue]]; [button setBordered:NO]; [button setWantsLayer:YES]; [[button layer] setCornerRadius:6]; NSMutableAttributedString *title = [[NSMutableAttributedString alloc] initWithString:@"●"]; [title addAttribute:NSFontAttributeName value:[NSFont systemFontOfSize:MIN(22, 6 + [width doubleValue] / 3) weight:NSFontWeightBold] range:NSMakeRange(0, title.length)]; [button setAttributedTitle:title]; [_mosaicWidthButtons addObject:button]; [_toolSettingsView addSubview:button]; } NSArray *colors = @[ [NSColor colorWithCalibratedRed:239.0/255.0 green:68.0/255.0 blue:68.0/255.0 alpha:1.0], [NSColor colorWithCalibratedRed:250.0/255.0 green:204.0/255.0 blue:21.0/255.0 alpha:1.0], [NSColor colorWithCalibratedRed:34.0/255.0 green:197.0/255.0 blue:94.0/255.0 alpha:1.0], [NSColor colorWithCalibratedRed:59.0/255.0 green:130.0/255.0 blue:246.0/255.0 alpha:1.0], [NSColor colorWithCalibratedWhite:0.0 alpha:1.0], [NSColor colorWithCalibratedRed:156.0/255.0 green:163.0/255.0 blue:175.0/255.0 alpha:1.0], [NSColor whiteColor] ]; for (NSUInteger i = 0; i < colors.count; i++) { NSButton *button = [NSButton buttonWithTitle:@"" target:self action:@selector(selectToolColor:)]; [button setTag:(NSInteger)i]; [button setBordered:NO]; [button setWantsLayer:YES]; [[button layer] setCornerRadius:4]; [[button layer] setBorderWidth:1]; [[button layer] setBorderColor:[[NSColor colorWithCalibratedWhite:0.12 alpha:0.22] CGColor]]; [[button layer] setBackgroundColor:[colors[i] CGColor]]; [_colorSettingButtons addObject:button]; [_toolSettingsView addSubview:button]; } objc_setAssociatedObject(_toolSettingsView, "snapgoColors", colors, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } - (void)drawRect:(NSRect)dirtyRect { [[NSColor colorWithCalibratedWhite:0.0 alpha:0.48] setFill]; if (_hasSelection) { NSRect top = NSMakeRect(0, 0, self.bounds.size.width, _selection.origin.y); NSRect left = NSMakeRect(0, _selection.origin.y, _selection.origin.x, _selection.size.height); NSRect right = NSMakeRect(NSMaxX(_selection), _selection.origin.y, self.bounds.size.width - NSMaxX(_selection), _selection.size.height); NSRect bottom = NSMakeRect(0, NSMaxY(_selection), self.bounds.size.width, self.bounds.size.height - NSMaxY(_selection)); NSRectFill(top); NSRectFill(left); NSRectFill(right); NSRectFill(bottom); NSBezierPath *border = [NSBezierPath bezierPathWithRect:NSInsetRect(_selection, 0.75, 0.75)]; [border setLineWidth:1.5]; [[NSColor colorWithCalibratedRed:59.0/255.0 green:130.0/255.0 blue:246.0/255.0 alpha:1.0] setStroke]; [border stroke]; CGFloat corner = 8; NSBezierPath *corners = [NSBezierPath bezierPath]; [corners moveToPoint:NSMakePoint(NSMinX(_selection), NSMinY(_selection) + corner)]; [corners lineToPoint:NSMakePoint(NSMinX(_selection), NSMinY(_selection))]; [corners lineToPoint:NSMakePoint(NSMinX(_selection) + corner, NSMinY(_selection))]; [corners moveToPoint:NSMakePoint(NSMaxX(_selection) - corner, NSMinY(_selection))]; [corners lineToPoint:NSMakePoint(NSMaxX(_selection), NSMinY(_selection))]; [corners lineToPoint:NSMakePoint(NSMaxX(_selection), NSMinY(_selection) + corner)]; [corners moveToPoint:NSMakePoint(NSMaxX(_selection), NSMaxY(_selection) - corner)]; [corners lineToPoint:NSMakePoint(NSMaxX(_selection), NSMaxY(_selection))]; [corners lineToPoint:NSMakePoint(NSMaxX(_selection) - corner, NSMaxY(_selection))]; [corners moveToPoint:NSMakePoint(NSMinX(_selection) + corner, NSMaxY(_selection))]; [corners lineToPoint:NSMakePoint(NSMinX(_selection), NSMaxY(_selection))]; [corners lineToPoint:NSMakePoint(NSMinX(_selection), NSMaxY(_selection) - corner)]; [corners setLineWidth:3]; [corners stroke]; [self drawAnnotations]; [self drawResizeHandles]; } else { NSRectFill(self.bounds); } } - (void)drawResizeHandles { NSArray *points = @[ [NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMinY(_selection))], [NSValue valueWithPoint:NSMakePoint(NSMidX(_selection), NSMinY(_selection))], [NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMinY(_selection))], [NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMidY(_selection))], [NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMaxY(_selection))], [NSValue valueWithPoint:NSMakePoint(NSMidX(_selection), NSMaxY(_selection))], [NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMaxY(_selection))], [NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMidY(_selection))] ]; [[NSColor whiteColor] setStroke]; [[NSColor colorWithCalibratedRed:59.0/255.0 green:130.0/255.0 blue:246.0/255.0 alpha:1.0] setFill]; for (NSValue *value in points) { NSPoint p = [value pointValue]; NSRect handleRect = NSMakeRect(p.x - 5, p.y - 5, 10, 10); NSBezierPath *path = [NSBezierPath bezierPathWithRect:handleRect]; [path fill]; [path stroke]; } } - (CGFloat)fontSizeForTextItem:(NSDictionary *)item { NSNumber *fontSize = item[@"fontSize"]; if (fontSize != nil && [fontSize doubleValue] > 0) { return [fontSize doubleValue]; } return 20.0; } - (CGFloat)strokeWidthForItem:(NSDictionary *)item { NSNumber *strokeWidth = item[@"strokeWidth"]; if (strokeWidth != nil && [strokeWidth doubleValue] > 0) { return [strokeWidth doubleValue]; } return 3.0; } - (NSDictionary *)textAttributesWithFontSize:(CGFloat)fontSize color:(NSColor *)color { return @{ NSFontAttributeName: [NSFont systemFontOfSize:fontSize weight:NSFontWeightSemibold], NSForegroundColorAttributeName: color ?: [NSColor whiteColor] }; } - (NSSize)textSizeForString:(NSString *)text fontSize:(CGFloat)fontSize { NSString *measured = text.length > 0 ? text : @" "; NSDictionary *attrs = [self textAttributesWithFontSize:fontSize color:[NSColor whiteColor]]; NSRect bounds = [measured boundingRectWithSize:NSMakeSize(CGFLOAT_MAX, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading attributes:attrs]; return NSMakeSize(ceil(MAX(1.0, bounds.size.width)), ceil(MAX(fontSize * 1.2, bounds.size.height))); } - (NSSize)textEditorSizeForString:(NSString *)text fontSize:(CGFloat)fontSize { NSSize textSize = [self textSizeForString:text fontSize:fontSize]; return NSMakeSize(MAX(180, MIN(420, textSize.width + 28)), MAX(30, ceil(fontSize * 1.6))); } - (NSRect)textEditorFrameForLocalPoint:(NSPoint)localPoint text:(NSString *)text fontSize:(CGFloat)fontSize { NSSize editorSize = [self textEditorSizeForString:text fontSize:fontSize]; NSPoint clampedLocal = [self clampedTextLocalPoint:localPoint text:text fontSize:fontSize]; NSRect frame = NSMakeRect(_selection.origin.x + clampedLocal.x, _selection.origin.y + clampedLocal.y, editorSize.width, editorSize.height); CGFloat maxX = self.bounds.size.width - frame.size.width - 8; CGFloat maxY = self.bounds.size.height - frame.size.height - 8; frame.origin.x = MAX(8, MIN(frame.origin.x, maxX)); frame.origin.y = MAX(8, MIN(frame.origin.y, maxY)); return frame; } - (void)resizeTextEditorForCurrentFont { if (_textEditor == nil) { return; } NSString *text = [_textEditor stringValue] ?: @""; NSRect frame = [self textEditorFrameForLocalPoint:_textEditorLocalPoint text:text fontSize:_activeFontSize]; [_textEditor setFrame:frame]; _textEditorLocalPoint = [self localPoint:frame.origin]; } - (NSRect)textAnnotationRectForItem:(NSDictionary *)item { if (![item[@"tool"] isEqualToString:@"text"]) { return NSZeroRect; } NSArray *points = item[@"points"]; NSString *text = item[@"text"]; if (points.count == 0 || text.length == 0) { return NSZeroRect; } NSPoint p = [points[0] pointValue]; CGFloat fontSize = [self fontSizeForTextItem:item]; NSSize size = [self textSizeForString:text fontSize:fontSize]; return NSMakeRect(_selection.origin.x + p.x, _selection.origin.y + p.y, size.width, size.height); } - (NSPoint)clampedTextLocalPoint:(NSPoint)localPoint text:(NSString *)text fontSize:(CGFloat)fontSize { NSSize size = [self textSizeForString:text fontSize:fontSize]; CGFloat maxX = MAX(0, _selection.size.width - size.width); CGFloat maxY = MAX(0, _selection.size.height - size.height); return NSMakePoint(MAX(0, MIN(localPoint.x, maxX)), MAX(0, MIN(localPoint.y, maxY))); } - (NSInteger)textAnnotationIndexAtPoint:(NSPoint)p { for (NSInteger i = (NSInteger)_annotations.count - 1; i >= 0; i--) { NSDictionary *item = _annotations[(NSUInteger)i]; if (![item[@"tool"] isEqualToString:@"text"]) { continue; } NSRect hitRect = NSInsetRect([self textAnnotationRectForItem:item], -6, -6); if (!NSIsEmptyRect(hitRect) && NSPointInRect(p, hitRect)) { return i; } } return -1; } - (void)drawAnnotations { NSMutableArray *items = [NSMutableArray arrayWithArray:_annotations]; if (_draftAnnotation != nil) { [items addObject:_draftAnnotation]; } for (NSUInteger index = 0; index < items.count; index++) { NSDictionary *item = items[index]; NSString *tool = item[@"tool"]; NSColor *color = item[@"nsColor"]; NSArray *points = item[@"points"]; if (points.count == 0) { continue; } if ([tool isEqualToString:@"text"]) { NSString *text = item[@"text"]; if (text.length == 0) { continue; } NSPoint p = [points[0] pointValue]; CGFloat fontSize = [self fontSizeForTextItem:item]; NSDictionary *attrs = [self textAttributesWithFontSize:fontSize color:color]; [text drawAtPoint:NSMakePoint(_selection.origin.x + p.x, _selection.origin.y + p.y) withAttributes:attrs]; if ((NSInteger)index == _selectedTextAnnotationIndex && index < _annotations.count) { NSRect rect = NSInsetRect([self textAnnotationRectForItem:item], -4, -3); NSBezierPath *selectionPath = [NSBezierPath bezierPathWithRoundedRect:rect xRadius:4 yRadius:4]; [selectionPath setLineWidth:1.5]; CGFloat dash[] = {4.0, 3.0}; [selectionPath setLineDash:dash count:2 phase:0]; [[NSColor colorWithCalibratedRed:59.0/255.0 green:130.0/255.0 blue:246.0/255.0 alpha:0.95] setStroke]; [selectionPath stroke]; } continue; } if ([tool hasPrefix:@"mosaic-"]) { [NSGraphicsContext saveGraphicsState]; NSBezierPath *clipPath = nil; if ([tool isEqualToString:@"mosaic-brush"]) { clipPath = [NSBezierPath bezierPath]; CGFloat radius = [self strokeWidthForItem:item] / 2.0; for (NSUInteger i = 0; i < points.count; i++) { NSPoint local = [points[i] pointValue]; NSPoint p = NSMakePoint(_selection.origin.x + local.x, _selection.origin.y + local.y); [clipPath appendBezierPathWithOvalInRect:NSMakeRect(p.x - radius, p.y - radius, radius * 2, radius * 2)]; if (i == 0) continue; NSPoint previousLocal = [points[i - 1] pointValue]; NSPoint previous = NSMakePoint(_selection.origin.x + previousLocal.x, _selection.origin.y + previousLocal.y); CGFloat dx = p.x - previous.x, dy = p.y - previous.y; CGFloat length = hypot(dx, dy); if (length <= 0.001) continue; CGFloat nx = -dy / length * radius, ny = dx / length * radius; NSBezierPath *segment = [NSBezierPath bezierPath]; [segment moveToPoint:NSMakePoint(previous.x + nx, previous.y + ny)]; [segment lineToPoint:NSMakePoint(p.x + nx, p.y + ny)]; [segment lineToPoint:NSMakePoint(p.x - nx, p.y - ny)]; [segment lineToPoint:NSMakePoint(previous.x - nx, previous.y - ny)]; [segment closePath]; [clipPath appendBezierPath:segment]; } } else if (points.count >= 2) { NSPoint a = [points[0] pointValue], b = [[points lastObject] pointValue]; NSRect r = NSMakeRect(_selection.origin.x + MIN(a.x, b.x), _selection.origin.y + MIN(a.y, b.y), fabs(b.x-a.x), fabs(b.y-a.y)); clipPath = [NSBezierPath bezierPathWithRect:r]; } if (clipPath != nil) { [clipPath addClip]; if (_mosaicPreviewImage != nil) { NSDictionary *hints = @{NSImageHintInterpolation: @(NSImageInterpolationNone)}; [_mosaicPreviewImage drawInRect:self.bounds fromRect:NSMakeRect(0, 0, _mosaicPreviewImage.size.width, _mosaicPreviewImage.size.height) operation:NSCompositingOperationCopy fraction:1.0 respectFlipped:YES hints:hints]; } else { // Screen capture can be unavailable before Screen Recording // permission is granted. A checkerboard fallback still reads // as pixelation and never resembles a marker stroke. CGFloat tile = 8.0; for (CGFloat y = 0; y < self.bounds.size.height; y += tile) { for (CGFloat x = 0; x < self.bounds.size.width; x += tile) { BOOL alternate = (((NSInteger)(x / tile) + (NSInteger)(y / tile)) % 2) == 0; [[NSColor colorWithCalibratedWhite:(alternate ? 0.42 : 0.68) alpha:1.0] setFill]; NSRectFill(NSMakeRect(x, y, tile, tile)); } } } } [NSGraphicsContext restoreGraphicsState]; continue; } [color setStroke]; CGFloat strokeWidth = [self strokeWidthForItem:item]; NSBezierPath *path = [NSBezierPath bezierPath]; [path setLineWidth:strokeWidth]; [path setLineCapStyle:NSLineCapStyleRound]; [path setLineJoinStyle:NSLineJoinStyleRound]; if ([tool isEqualToString:@"pen"]) { NSPoint first = [points[0] pointValue]; [path moveToPoint:NSMakePoint(_selection.origin.x + first.x, _selection.origin.y + first.y)]; for (NSUInteger i = 1; i < points.count; i++) { NSPoint point = [points[i] pointValue]; [path lineToPoint:NSMakePoint(_selection.origin.x + point.x, _selection.origin.y + point.y)]; } [path stroke]; } else if (points.count >= 2) { NSPoint a = [points[0] pointValue]; NSPoint b = [[points lastObject] pointValue]; NSPoint start = NSMakePoint(_selection.origin.x + a.x, _selection.origin.y + a.y); NSPoint end = NSMakePoint(_selection.origin.x + b.x, _selection.origin.y + b.y); NSRect r = NSMakeRect( _selection.origin.x + MIN(a.x, b.x), _selection.origin.y + MIN(a.y, b.y), fabs(b.x - a.x), fabs(b.y - a.y)); if ([tool isEqualToString:@"line"] || [tool isEqualToString:@"arrow"]) { NSBezierPath *linePath = [NSBezierPath bezierPath]; [linePath moveToPoint:start]; [linePath lineToPoint:end]; [linePath setLineWidth:strokeWidth]; [linePath setLineCapStyle:NSLineCapStyleRound]; [linePath setLineJoinStyle:NSLineJoinStyleRound]; if ([tool isEqualToString:@"arrow"]) { CGFloat dx = end.x - start.x, dy = end.y - start.y; CGFloat length = hypot(dx, dy); if (length >= 1.0) { CGFloat headLength = MIN(length * 0.45, MAX(10.0, strokeWidth * 4.0)); CGFloat angle = atan2(dy, dx), spread = M_PI / 6.0; NSPoint left = NSMakePoint(end.x - headLength * cos(angle - spread), end.y - headLength * sin(angle - spread)); NSPoint right = NSMakePoint(end.x - headLength * cos(angle + spread), end.y - headLength * sin(angle + spread)); [linePath moveToPoint:left]; [linePath lineToPoint:end]; [linePath lineToPoint:right]; } } [linePath stroke]; } else if ([tool isEqualToString:@"rect"]) { NSBezierPath *rectPath = [NSBezierPath bezierPathWithRect:r]; [rectPath setLineWidth:strokeWidth]; [rectPath stroke]; } else if ([tool isEqualToString:@"ellipse"]) { NSBezierPath *ellipsePath = [NSBezierPath bezierPathWithOvalInRect:r]; [ellipsePath setLineWidth:strokeWidth]; [ellipsePath stroke]; } } } } - (NSString *)resizeHandleAtPoint:(NSPoint)p { if (!_hasSelection) { return nil; } CGFloat t = 9; NSDictionary *handles = @{ @"nw": [NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMinY(_selection))], @"n": [NSValue valueWithPoint:NSMakePoint(NSMidX(_selection), NSMinY(_selection))], @"ne": [NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMinY(_selection))], @"e": [NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMidY(_selection))], @"se": [NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMaxY(_selection))], @"s": [NSValue valueWithPoint:NSMakePoint(NSMidX(_selection), NSMaxY(_selection))], @"sw": [NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMaxY(_selection))], @"w": [NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMidY(_selection))] }; for (NSString *key in handles) { NSPoint hp = [handles[key] pointValue]; if (fabs(p.x - hp.x) <= t && fabs(p.y - hp.y) <= t) { return key; } } BOOL nearX = p.x >= NSMinX(_selection) - t && p.x <= NSMaxX(_selection) + t; BOOL nearY = p.y >= NSMinY(_selection) - t && p.y <= NSMaxY(_selection) + t; if (nearX && fabs(p.y - NSMinY(_selection)) <= t) return @"n"; if (nearX && fabs(p.y - NSMaxY(_selection)) <= t) return @"s"; if (nearY && fabs(p.x - NSMinX(_selection)) <= t) return @"w"; if (nearY && fabs(p.x - NSMaxX(_selection)) <= t) return @"e"; return nil; } - (NSPoint)localPoint:(NSPoint)p { return NSMakePoint( MAX(0, MIN(p.x - _selection.origin.x, _selection.size.width)), MAX(0, MIN(p.y - _selection.origin.y, _selection.size.height))); } // Translate a selection rectangle expressed in view-local coordinates into // the global top-left screen coordinate system used by // `/usr/sbin/screencapture -R`. // // Why this is needed: // - The overlay window is created with the host screen's AppKit frame and // its flipped content view yields top-left local coordinates relative // to that screen only. // - macOS combines all attached displays into a single virtual coordinate // space whose origin is the top-left corner of the primary display // (Y grows downward). `screencapture -R` interprets its arguments in // that space. // - Without this translation, a selection on a secondary display is sent // as if it were on the primary, so screencapture crops the wrong area. // // Translation steps: // 1. globalX = screenFrame.origin.x + sel.origin.x // (NSScreen frame origin.x is already in primary-anchored global X.) // 2. globalY = (primaryHeight - (screenFrame.origin.y + screenFrame.size.height)) + sel.origin.y // Converts the screen's AppKit bottom-left origin to a top-left global // origin, then adds the already-top-left local Y inside the view. - (NSRect)globalRectForSelection:(NSRect)sel { NSScreen *screen = [nativeOverlayWindow screen]; if (screen == nil) { screen = [NSScreen mainScreen]; } if (screen == nil) { return sel; } NSRect screenFrame = [screen frame]; NSScreen *primary = [[NSScreen screens] firstObject]; CGFloat primaryHeight = primary != nil ? [primary frame].size.height : screenFrame.size.height; CGFloat globalX = screenFrame.origin.x + sel.origin.x; CGFloat globalY = (primaryHeight - (screenFrame.origin.y + screenFrame.size.height)) + sel.origin.y; return NSMakeRect(globalX, globalY, sel.size.width, sel.size.height); } - (void)mouseDown:(NSEvent *)event { NSPoint p = [self convertPoint:[event locationInWindow] fromView:nil]; if (_textEditor != nil) { [self commitTextEditor]; } _draggingTextAnnotation = NO; NSString *handle = [self resizeHandleAtPoint:p]; if (handle != nil) { _resizing = YES; _creating = NO; _moving = NO; _annotating = NO; _resizeHandle = handle; _resizeStart = _selection; [self syncControls]; return; } NSInteger textIndex = _hasSelection ? [self textAnnotationIndexAtPoint:p] : -1; if (textIndex >= 0) { _selectedTextAnnotationIndex = textIndex; NSDictionary *item = _annotations[(NSUInteger)textIndex]; _activeTool = @"text"; _activeColor = item[@"nsColor"] ?: _activeColor; _activeFontSize = [self fontSizeForTextItem:item]; _creating = NO; _moving = NO; _resizing = NO; _annotating = NO; if ([event clickCount] >= 2) { [self beginEditingTextAnnotationAtIndex:textIndex]; [self syncControls]; [self setNeedsDisplay:YES]; return; } NSArray *points = item[@"points"]; if (points.count > 0) { _draggingTextAnnotation = YES; _textDragStartLocalPoint = [self localPoint:p]; _textDragOriginalLocalPoint = [points[0] pointValue]; } [self syncControls]; [self setNeedsDisplay:YES]; return; } _selectedTextAnnotationIndex = -1; if (_hasSelection && NSPointInRect(p, _selection) && [_activeTool isEqualToString:@"text"]) { _creating = NO; _moving = NO; _resizing = NO; _annotating = NO; [self beginTextAnnotationAtPoint:[self localPoint:p]]; [self syncControls]; return; } if ([event clickCount] == 2 && _hasSelection && NSPointInRect(p, _selection)) { [self copySelection]; return; } if (_hasSelection && NSPointInRect(p, _selection) && _activeTool != nil) { _annotating = YES; _creating = NO; _moving = NO; _resizing = NO; NSPoint local = [self localPoint:p]; _draftAnnotation = [@{ @"tool": _activeTool, @"color": [self hexForColor:_activeColor], @"nsColor": _activeColor, @"strokeWidth": @([_activeTool isEqualToString:@"mosaic-brush"] ? _mosaicBrushWidth : _activeStrokeWidth), @"points": [NSMutableArray arrayWithObject:[NSValue valueWithPoint:local]] } mutableCopy]; } else if (_hasSelection && NSPointInRect(p, _selection)) { _moving = YES; _creating = NO; _resizing = NO; _annotating = NO; _moveOffset = NSMakePoint(p.x - _selection.origin.x, p.y - _selection.origin.y); } else { _creating = YES; _moving = NO; _resizing = NO; _annotating = NO; _hasSelection = YES; _anchor = p; _selection = NSMakeRect(p.x, p.y, 0, 0); [_annotations removeAllObjects]; _draftAnnotation = nil; _selectedTextAnnotationIndex = -1; } [self syncControls]; } - (void)mouseDragged:(NSEvent *)event { NSPoint p = [self convertPoint:[event locationInWindow] fromView:nil]; if (_creating) { CGFloat x = MIN(_anchor.x, p.x); CGFloat y = MIN(_anchor.y, p.y); _selection = NSMakeRect(x, y, fabs(p.x - _anchor.x), fabs(p.y - _anchor.y)); } else if (_moving) { CGFloat x = p.x - _moveOffset.x; CGFloat y = p.y - _moveOffset.y; x = MAX(0, MIN(x, self.bounds.size.width - _selection.size.width)); y = MAX(0, MIN(y, self.bounds.size.height - _selection.size.height)); _selection.origin = NSMakePoint(x, y); } else if (_resizing) { CGFloat left = NSMinX(_resizeStart); CGFloat top = NSMinY(_resizeStart); CGFloat right = NSMaxX(_resizeStart); CGFloat bottom = NSMaxY(_resizeStart); if ([_resizeHandle containsString:@"w"]) left = p.x; if ([_resizeHandle containsString:@"e"]) right = p.x; if ([_resizeHandle containsString:@"n"]) top = p.y; if ([_resizeHandle containsString:@"s"]) bottom = p.y; left = MAX(0, MIN(left, self.bounds.size.width)); right = MAX(0, MIN(right, self.bounds.size.width)); top = MAX(0, MIN(top, self.bounds.size.height)); bottom = MAX(0, MIN(bottom, self.bounds.size.height)); _selection = NSMakeRect(MIN(left, right), MIN(top, bottom), fabs(right - left), fabs(bottom - top)); } else if (_draggingTextAnnotation && _selectedTextAnnotationIndex >= 0 && _selectedTextAnnotationIndex < (NSInteger)_annotations.count) { NSMutableDictionary *item = (NSMutableDictionary *)_annotations[(NSUInteger)_selectedTextAnnotationIndex]; NSMutableArray *points = (NSMutableArray *)item[@"points"]; if (points.count > 0) { NSPoint local = [self localPoint:p]; NSPoint delta = NSMakePoint(local.x - _textDragStartLocalPoint.x, local.y - _textDragStartLocalPoint.y); NSPoint next = NSMakePoint(_textDragOriginalLocalPoint.x + delta.x, _textDragOriginalLocalPoint.y + delta.y); NSString *text = item[@"text"] ?: @""; CGFloat fontSize = [self fontSizeForTextItem:item]; points[0] = [NSValue valueWithPoint:[self clampedTextLocalPoint:next text:text fontSize:fontSize]]; } } else if (_annotating && _draftAnnotation != nil) { NSMutableArray *points = _draftAnnotation[@"points"]; NSPoint local = [self localPoint:p]; if ([_activeTool isEqualToString:@"pen"] || [_activeTool isEqualToString:@"mosaic-brush"]) { [points addObject:[NSValue valueWithPoint:local]]; } else { if (points.count == 1) { [points addObject:[NSValue valueWithPoint:local]]; } else { points[1] = [NSValue valueWithPoint:local]; } } } [self syncControls]; [self setNeedsDisplay:YES]; } - (void)mouseUp:(NSEvent *)event { _creating = NO; _moving = NO; _resizing = NO; _draggingTextAnnotation = NO; if (_annotating && _draftAnnotation != nil) { [_annotations addObject:_draftAnnotation]; _draftAnnotation = nil; } _annotating = NO; if (_hasSelection && (_selection.size.width < 4 || _selection.size.height < 4)) { _hasSelection = NO; } [self syncControls]; [self setNeedsDisplay:YES]; } - (void)keyDown:(NSEvent *)event { if ([self isEditingText]) { if ([event keyCode] == 53 || [[event charactersIgnoringModifiers] isEqualToString:@"\033"]) { [self cancelTextEditor]; return; } if ([event keyCode] == 36 || [[event charactersIgnoringModifiers] isEqualToString:@"\r"]) { [self commitTextEditor]; return; } } if ([event keyCode] == 53 || [[event charactersIgnoringModifiers] isEqualToString:@"\033"]) { [self cancelSelection]; } else if (([event keyCode] == 36 || [[event charactersIgnoringModifiers] isEqualToString:@"\r"]) && _hasSelection) { [self confirmSelection]; } else { [super keyDown:event]; } } - (void)rightMouseDown:(NSEvent *)event { [self cancelSelection]; } - (void)syncControls { BOOL visible = _hasSelection && _selection.size.width >= 4 && _selection.size.height >= 4; [_cancelButton setHidden:!visible]; [_clipboardButton setHidden:!visible]; [_saveButton setHidden:!visible]; [_saveRemoteButton setHidden:!visible]; [_ftpButton setHidden:!visible]; [_ocrButton setHidden:!visible]; [_summaryButton setHidden:!visible]; [_uploadButton setHidden:!visible]; [_actionToolbarBg setHidden:!visible]; [_penButton setHidden:!visible]; [_lineButton setHidden:!visible]; [_arrowButton setHidden:!visible]; [_rectButton setHidden:!visible]; [_ellipseButton setHidden:!visible]; [_textButton setHidden:!visible]; [_mosaicButton setHidden:!visible]; [_undoButton setHidden:!visible]; [_toolSettingsView setHidden:!visible || _activeTool == nil]; [_sizeLabel setHidden:!visible]; [_hintLabel setHidden:_hasSelection]; if (!visible) { return; } [_sizeLabel setStringValue:[NSString stringWithFormat:@"%.0f × %.0f", _selection.size.width, _selection.size.height]]; [_sizeLabel setFrame:NSMakeRect(_selection.origin.x, MAX(0, _selection.origin.y - 26), 110, 22)]; // Action toolbar layout — 8 icon buttons, each 28x28, separated by 8px, // with 4px outer padding. Total = 8*28 + 7*8 + 2*4 = 288px wide. CGFloat actionBtnSize = 28; CGFloat actionGap = 8; CGFloat actionPad = 4; NSInteger actionCount = 8; CGFloat toolbarW = actionPad * 2 + actionBtnSize * actionCount + actionGap * (actionCount - 1); CGFloat toolbarH = 40; CGFloat x = _selection.origin.x + _selection.size.width - toolbarW; CGFloat y = _selection.origin.y + _selection.size.height + 8; if (y + toolbarH > self.bounds.size.height) { y = _selection.origin.y + _selection.size.height - toolbarH - 8; } x = MAX(0, MIN(x, self.bounds.size.width - toolbarW)); [_actionToolbarBg setFrame:NSMakeRect(x, y, toolbarW, toolbarH)]; CGFloat btnY = y + (toolbarH - actionBtnSize) / 2; CGFloat baseX = x + actionPad; [_cancelButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 0, btnY, actionBtnSize, actionBtnSize)]; [_clipboardButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 1, btnY, actionBtnSize, actionBtnSize)]; [_saveButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 2, btnY, actionBtnSize, actionBtnSize)]; [_saveRemoteButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 3, btnY, actionBtnSize, actionBtnSize)]; [_ftpButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 4, btnY, actionBtnSize, actionBtnSize)]; [_ocrButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 5, btnY, actionBtnSize, actionBtnSize)]; [_summaryButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 6, btnY, actionBtnSize, actionBtnSize)]; [_uploadButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 7, btnY, actionBtnSize, actionBtnSize)]; // Mark toolbar sits to the LEFT of the action toolbar (same row) with an // 8px gap between the two groups, so they never overlap on tiny selections. CGFloat markW = 280; CGFloat markGap = 8; CGFloat markX = MAX(0, x - markGap - markW); [_penButton setFrame:NSMakeRect(markX + 4, y + 4, 28, 28)]; [_lineButton setFrame:NSMakeRect(markX + 38, y + 4, 28, 28)]; [_arrowButton setFrame:NSMakeRect(markX + 72, y + 4, 28, 28)]; [_rectButton setFrame:NSMakeRect(markX + 106, y + 4, 28, 28)]; [_ellipseButton setFrame:NSMakeRect(markX + 140, y + 4, 28, 28)]; [_textButton setFrame:NSMakeRect(markX + 174, y + 4, 28, 28)]; [_mosaicButton setFrame:NSMakeRect(markX + 208, y + 4, 28, 28)]; [_undoButton setFrame:NSMakeRect(markX + 246, y + 4, 28, 28)]; [_undoButton setEnabled:YES]; [[_undoButton layer] setOpacity:_annotations.count > 0 ? 1.0 : 0.35]; if (_activeTool != nil) { BOOL isText = [_activeTool isEqualToString:@"text"]; BOOL isMosaic = [_activeTool hasPrefix:@"mosaic-"]; BOOL mosaicBrush = [_mosaicMode isEqualToString:@"brush"]; CGFloat settingsW = isMosaic ? (mosaicBrush ? 304 : 112) : (isText ? 462 : 398); CGFloat settingsH = 58; CGFloat settingsX = MAX(8, MIN(markX, self.bounds.size.width - settingsW - 8)); CGFloat settingsY = y + toolbarH + 4; if (settingsY + settingsH > self.bounds.size.height) { settingsY = y - settingsH - 8; } CGFloat activeCenter = markX + 18; if ([_activeTool isEqualToString:@"line"]) activeCenter = markX + 52; if ([_activeTool isEqualToString:@"arrow"]) activeCenter = markX + 86; if ([_activeTool isEqualToString:@"rect"]) activeCenter = markX + 120; if ([_activeTool isEqualToString:@"ellipse"]) activeCenter = markX + 154; if ([_activeTool isEqualToString:@"text"]) activeCenter = markX + 188; if (isMosaic) activeCenter = markX + 222; [_toolSettingsView setFrame:NSMakeRect(settingsX, settingsY, settingsW, settingsH)]; [_toolSettingsView setArrowX:MAX(18, MIN(activeCenter - settingsX, settingsW - 18))]; [_toolSettingsView setNeedsDisplay:YES]; CGFloat contentY = 18; CGFloat xCursor = 14; for (NSButton *button in _strokeSettingButtons) { [button setHidden:isText || isMosaic]; [button setFrame:NSMakeRect(xCursor, contentY, 32, 32)]; xCursor += 42; } xCursor = 14; for (NSButton *button in _fontSettingButtons) { [button setHidden:!isText || isMosaic]; [button setFrame:NSMakeRect(xCursor, contentY, 40, 32)]; xCursor += 48; } for (NSUInteger i = 0; i < _mosaicModeButtons.count; i++) { NSButton *button = _mosaicModeButtons[i]; [button setHidden:!isMosaic]; [button setFrame:NSMakeRect(14 + i * 38, contentY + 1, 36, 30)]; } for (NSUInteger i = 0; i < _mosaicWidthButtons.count; i++) { NSButton *button = _mosaicWidthButtons[i]; [button setHidden:!isMosaic || !mosaicBrush]; [button setFrame:NSMakeRect(122 + i * 42, contentY, 32, 32)]; } CGFloat groupW = isText ? (4 * 40 + 3 * 8) : (3 * 32 + 2 * 10); CGFloat dividerX = 14 + groupW + 12; [_settingsDivider setHidden:isMosaic && !mosaicBrush]; if (isMosaic) dividerX = 102; [_settingsDivider setFrame:NSMakeRect(dividerX, contentY + 3, 1, 26)]; CGFloat colorX = dividerX + 18; for (NSButton *button in _colorSettingButtons) { [button setHidden:isMosaic]; [button setFrame:NSMakeRect(colorX, contentY + 5, 22, 22)]; colorX += 34; } } [self updateToolButtonStates]; } - (void)updateToolButtonStates { NSDictionary *buttons = @{@"pen": _penButton, @"line": _lineButton, @"arrow": _arrowButton, @"rect": _rectButton, @"ellipse": _ellipseButton, @"text": _textButton}; for (NSString *tool in buttons) { NSButton *button = buttons[tool]; NSColor *bg = [tool isEqualToString:_activeTool] ? [NSColor colorWithCalibratedWhite:1.0 alpha:0.14] : [NSColor clearColor]; [[button layer] setBackgroundColor:[bg CGColor]]; } BOOL mosaicActive = [_activeTool hasPrefix:@"mosaic-"]; [[_mosaicButton layer] setBackgroundColor:[(mosaicActive ? [NSColor colorWithCalibratedWhite:1.0 alpha:0.14] : [NSColor clearColor]) CGColor]]; [self updateToolSettingsStates]; } - (void)updateToolSettingsStates { NSColor *activeBg = [NSColor colorWithCalibratedRed:234.0/255.0 green:241.0/255.0 blue:1.0 alpha:1.0]; NSColor *clear = [NSColor clearColor]; NSColor *activeFg = [NSColor colorWithCalibratedRed:37.0/255.0 green:99.0/255.0 blue:235.0/255.0 alpha:1.0]; NSColor *normalFg = [NSColor colorWithCalibratedWhite:0.12 alpha:1.0]; for (NSButton *button in _strokeSettingButtons) { BOOL active = fabs((CGFloat)button.tag - _activeStrokeWidth) < 0.1; [[button layer] setBackgroundColor:[(active ? activeBg : clear) CGColor]]; NSMutableAttributedString *title = [[button attributedTitle] mutableCopy]; if (title.length > 0) { [title addAttribute:NSForegroundColorAttributeName value:(active ? activeFg : normalFg) range:NSMakeRange(0, title.length)]; [button setAttributedTitle:title]; } } for (NSButton *button in _fontSettingButtons) { BOOL active = fabs((CGFloat)button.tag - _activeFontSize) < 0.1; [[button layer] setBackgroundColor:[(active ? activeBg : clear) CGColor]]; NSMutableAttributedString *title = [[button attributedTitle] mutableCopy]; if (title.length > 0) { [title addAttribute:NSForegroundColorAttributeName value:(active ? activeFg : normalFg) range:NSMakeRange(0, title.length)]; [button setAttributedTitle:title]; } } for (NSButton *button in _mosaicModeButtons) { BOOL active = (button.tag == 0 && [_mosaicMode isEqualToString:@"brush"]) || (button.tag == 1 && [_mosaicMode isEqualToString:@"rect"]); [self styleButton:button background:(active ? [NSColor whiteColor] : clear) foreground:(active ? activeFg : normalFg)]; if (@available(macOS 10.14, *)) { [button setContentTintColor:(active ? activeFg : normalFg)]; } [button setAccessibilityValue:(active ? @"已选择" : @"未选择")]; } for (NSButton *button in _mosaicWidthButtons) { BOOL active = fabs((CGFloat)button.tag - _mosaicBrushWidth) < 0.1; [[button layer] setBackgroundColor:[(active ? activeBg : clear) CGColor]]; NSMutableAttributedString *title = [[button attributedTitle] mutableCopy]; if (title.length > 0) { [title addAttribute:NSForegroundColorAttributeName value:(active ? activeFg : normalFg) range:NSMakeRange(0, title.length)]; [button setAttributedTitle:title]; } } NSString *activeHex = [self hexForColor:_activeColor]; NSArray *colors = objc_getAssociatedObject(_toolSettingsView, "snapgoColors"); for (NSUInteger i = 0; i < _colorSettingButtons.count; i++) { NSButton *button = _colorSettingButtons[i]; NSString *hex = i < colors.count ? [self hexForColor:colors[i]] : @""; BOOL active = [hex isEqualToString:activeHex]; [[button layer] setBorderWidth:active ? 2 : 1]; [[button layer] setBorderColor:[(active ? activeFg : [NSColor colorWithCalibratedWhite:0.12 alpha:0.22]) CGColor]]; } } - (void)selectTool:(NSString *)tool { _activeTool = tool; [self syncControls]; } - (void)selectPen { [self selectTool:@"pen"]; } - (void)selectLine { [self selectTool:@"line"]; } - (void)selectArrow { [self selectTool:@"arrow"]; } - (void)selectRect { [self selectTool:@"rect"]; } - (void)selectEllipse { [self selectTool:@"ellipse"]; } - (void)selectText { [self selectTool:@"text"]; } - (void)selectMosaic { [self selectTool:[_mosaicMode isEqualToString:@"brush"] ? @"mosaic-brush" : @"mosaic-rect"]; } - (void)undoAnnotation { if (_annotations.count == 0 && _textEditor == nil) { return; } [self cancelTextEditor]; if (_annotations.count == 0) { [self syncControls]; [self setNeedsDisplay:YES]; return; } [_annotations removeLastObject]; if (_selectedTextAnnotationIndex >= (NSInteger)_annotations.count) { _selectedTextAnnotationIndex = -1; } [self syncControls]; [self setNeedsDisplay:YES]; } - (void)beginTextAnnotationAtPoint:(NSPoint)localPoint { [self beginTextEditorAtLocalPoint:localPoint text:@"" color:_activeColor annotationIndex:-1]; } - (void)beginEditingTextAnnotationAtIndex:(NSInteger)index { if (index < 0 || index >= (NSInteger)_annotations.count) { return; } NSDictionary *item = _annotations[(NSUInteger)index]; if (![item[@"tool"] isEqualToString:@"text"]) { return; } NSArray *points = item[@"points"]; if (points.count == 0) { return; } NSColor *color = item[@"nsColor"] ?: _activeColor; _activeColor = color; _activeFontSize = [self fontSizeForTextItem:item]; [self beginTextEditorAtLocalPoint:[points[0] pointValue] text:item[@"text"] ?: @"" color:color annotationIndex:index]; } - (void)beginTextEditorAtLocalPoint:(NSPoint)localPoint text:(NSString *)text color:(NSColor *)color annotationIndex:(NSInteger)index { [self cancelTextEditor]; CGFloat fontSize = _activeFontSize; if (index >= 0 && index < (NSInteger)_annotations.count) { fontSize = [self fontSizeForTextItem:_annotations[(NSUInteger)index]]; _activeFontSize = fontSize; } NSPoint clampedLocal = [self clampedTextLocalPoint:localPoint text:text fontSize:fontSize]; _textEditorLocalPoint = clampedLocal; _textEditorAnnotationIndex = index; _textEditorColor = color ?: _activeColor; NSRect frame = [self textEditorFrameForLocalPoint:clampedLocal text:text fontSize:fontSize]; _textEditorLocalPoint = [self localPoint:frame.origin]; _textEditor = [[NSTextField alloc] initWithFrame:frame]; [_textEditor setBezeled:YES]; [_textEditor setBordered:YES]; [_textEditor setDrawsBackground:YES]; [_textEditor setBackgroundColor:[NSColor colorWithCalibratedWhite:0.08 alpha:0.78]]; [_textEditor setTextColor:_textEditorColor]; [_textEditor setFont:[NSFont systemFontOfSize:fontSize weight:NSFontWeightSemibold]]; [_textEditor setStringValue:text ?: @""]; [_textEditor setFocusRingType:NSFocusRingTypeNone]; [_textEditor setTarget:self]; [_textEditor setAction:@selector(commitTextEditorFromSender:)]; [self addSubview:_textEditor]; [[self window] makeFirstResponder:_textEditor]; [_textEditor selectText:nil]; } - (BOOL)isEditingText { return _textEditor != nil; } - (void)commitTextEditorFromSender:(id)sender { [self commitTextEditor]; } - (void)commitTextEditor { if (_textEditor == nil) { return; } NSString *text = [[_textEditor stringValue] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; NSColor *textColor = _textEditorColor ?: _activeColor; if (_textEditorAnnotationIndex >= 0 && _textEditorAnnotationIndex < (NSInteger)_annotations.count) { if (text.length > 0) { NSMutableDictionary *item = (NSMutableDictionary *)_annotations[(NSUInteger)_textEditorAnnotationIndex]; item[@"color"] = [self hexForColor:textColor]; item[@"nsColor"] = textColor; item[@"points"] = [NSMutableArray arrayWithObject:[NSValue valueWithPoint:_textEditorLocalPoint]]; item[@"text"] = text; item[@"fontSize"] = @(_activeFontSize); _selectedTextAnnotationIndex = _textEditorAnnotationIndex; } else { [_annotations removeObjectAtIndex:(NSUInteger)_textEditorAnnotationIndex]; _selectedTextAnnotationIndex = -1; } } else if (text.length > 0) { [_annotations addObject:[@{ @"tool": @"text", @"color": [self hexForColor:textColor], @"nsColor": textColor, @"points": [NSMutableArray arrayWithObject:[NSValue valueWithPoint:_textEditorLocalPoint]], @"text": text, @"fontSize": @(_activeFontSize) } mutableCopy]]; _selectedTextAnnotationIndex = (NSInteger)_annotations.count - 1; } [_textEditor removeFromSuperview]; _textEditor = nil; _textEditorAnnotationIndex = -1; _textEditorColor = nil; [[self window] makeFirstResponder:self]; [self syncControls]; [self setNeedsDisplay:YES]; } - (void)cancelTextEditor { if (_textEditor == nil) { return; } [_textEditor removeFromSuperview]; _textEditor = nil; _textEditorAnnotationIndex = -1; _textEditorColor = nil; [[self window] makeFirstResponder:self]; [self setNeedsDisplay:YES]; } - (void)selectStrokeWidth:(NSButton *)sender { _activeStrokeWidth = MAX(1.0, (CGFloat)sender.tag); [self updateToolSettingsStates]; } - (void)selectMosaicMode:(NSButton *)sender { _mosaicMode = sender.tag == 0 ? @"brush" : @"rect"; [self selectMosaic]; } - (void)selectMosaicWidth:(NSButton *)sender { _mosaicBrushWidth = MAX(8.0, (CGFloat)sender.tag); [self updateToolSettingsStates]; } - (void)selectFontSize:(NSButton *)sender { _activeFontSize = MAX(8.0, (CGFloat)sender.tag); if (_textEditor != nil) { [_textEditor setFont:[NSFont systemFontOfSize:_activeFontSize weight:NSFontWeightSemibold]]; [self resizeTextEditorForCurrentFont]; } else if ([_activeTool isEqualToString:@"text"] && _selectedTextAnnotationIndex >= 0 && _selectedTextAnnotationIndex < (NSInteger)_annotations.count) { NSMutableDictionary *item = (NSMutableDictionary *)_annotations[(NSUInteger)_selectedTextAnnotationIndex]; item[@"fontSize"] = @(_activeFontSize); NSMutableArray *points = (NSMutableArray *)item[@"points"]; if (points.count > 0) { NSString *text = item[@"text"] ?: @""; NSPoint p = [points[0] pointValue]; points[0] = [NSValue valueWithPoint:[self clampedTextLocalPoint:p text:text fontSize:_activeFontSize]]; } [self setNeedsDisplay:YES]; } [self syncControls]; } - (void)selectToolColor:(NSButton *)sender { NSArray *colors = objc_getAssociatedObject(_toolSettingsView, "snapgoColors"); if (sender.tag >= 0 && sender.tag < (NSInteger)colors.count) { _activeColor = colors[(NSUInteger)sender.tag]; if (_textEditor != nil) { _textEditorColor = _activeColor; [_textEditor setTextColor:_activeColor]; } else if ([_activeTool isEqualToString:@"text"] && _selectedTextAnnotationIndex >= 0 && _selectedTextAnnotationIndex < (NSInteger)_annotations.count) { NSMutableDictionary *item = (NSMutableDictionary *)_annotations[(NSUInteger)_selectedTextAnnotationIndex]; item[@"color"] = [self hexForColor:_activeColor]; item[@"nsColor"] = _activeColor; [self setNeedsDisplay:YES]; } [self updateToolSettingsStates]; } } - (NSString *)annotationsJSON { [self commitTextEditor]; NSMutableArray *payload = [NSMutableArray array]; for (NSDictionary *item in _annotations) { NSMutableArray *points = [NSMutableArray array]; for (NSValue *value in item[@"points"]) { NSPoint p = [value pointValue]; [points addObject:@{@"x": @(p.x), @"y": @(p.y)}]; } NSMutableDictionary *entry = [@{@"tool": item[@"tool"], @"color": item[@"color"], @"points": points} mutableCopy]; NSString *text = item[@"text"]; if (text.length > 0) { entry[@"text"] = text; entry[@"fontSize"] = item[@"fontSize"] ?: @20.0; } else { entry[@"strokeWidth"] = item[@"strokeWidth"] ?: @3.0; } [payload addObject:entry]; } NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil]; if (data == nil) { return @"[]"; } return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; } - (void)closeOverlayWindow { [nativeOverlayWindow orderOut:nil]; if (nativeOverlayKeyMonitor != nil) { [NSEvent removeMonitor:nativeOverlayKeyMonitor]; nativeOverlayKeyMonitor = nil; } nativeOverlayWindow = nil; } - (void)confirmSelection { if (!_hasSelection) { return; } NSRect r = [self globalRectForSelection:_selection]; NSString *json = [self annotationsJSON]; [self closeOverlayWindow]; nativeOverlayConfirm((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]); } - (void)copySelection { if (!_hasSelection) { return; } NSRect r = [self globalRectForSelection:_selection]; NSString *json = [self annotationsJSON]; [self closeOverlayWindow]; nativeOverlayCopy((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]); } - (void)saveSelection { if (!_hasSelection) { return; } NSRect r = [self globalRectForSelection:_selection]; NSString *json = [self annotationsJSON]; [self closeOverlayWindow]; NSOpenPanel *panel = [NSOpenPanel openPanel]; [panel setTitle:@"Save screenshot to folder"]; [panel setCanChooseFiles:NO]; [panel setCanChooseDirectories:YES]; [panel setAllowsMultipleSelection:NO]; [panel setCanCreateDirectories:YES]; [panel beginWithCompletionHandler:^(NSModalResponse result) { if (result != NSModalResponseOK || [panel URL] == nil) { nativeOverlayCancel(); return; } NSString *dir = [[panel URL] path]; nativeOverlaySave((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String], [dir UTF8String]); }]; } - (void)cancelSelection { [self cancelTextEditor]; [self closeOverlayWindow]; nativeOverlayCancel(); } // `saveRemoteSelection` triggers the SCP upload flow. The overlay closes // immediately so the user can keep working while the transfer runs in the // background; success / failure surfaces through the regular Toast events. - (void)saveRemoteSelection { if (!_hasSelection) { return; } NSRect r = [self globalRectForSelection:_selection]; NSString *json = [self annotationsJSON]; [self closeOverlayWindow]; nativeOverlaySaveRemote((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]); } // `uploadFTPSelection` dispatches the dedicated FTP/SFTP upload action. - (void)uploadFTPSelection { if (!_hasSelection) { return; } NSRect r = [self globalRectForSelection:_selection]; NSString *json = [self annotationsJSON]; [self closeOverlayWindow]; nativeOverlayUploadFTP((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]); } // `ocrSelection` sends the screenshot to the configured OCR provider and // copies the extracted text. - (void)ocrSelection { if (!_hasSelection) { return; } NSRect r = [self globalRectForSelection:_selection]; NSString *json = [self annotationsJSON]; [self closeOverlayWindow]; nativeOverlayOCR((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]); } // `summarizeSelection` uploads the screenshot to S3, sends it to the // configured multimodal model, and copies the generated summary. - (void)summarizeSelection { if (!_hasSelection) { return; } NSRect r = [self globalRectForSelection:_selection]; NSString *json = [self annotationsJSON]; [self closeOverlayWindow]; nativeOverlaySummarize((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]); } @end // screenContainingCursor returns the NSScreen the cursor is currently on, // falling back to the main screen and then the first attached screen. // // Rationale: when an external display is attached, users expect the capture // overlay to appear on whichever display they were just working on. // `[NSEvent mouseLocation]` reports the cursor in the same global Cocoa // (bottom-left-origin) screen space used by `NSScreen.frame`, so a simple // `NSPointInRect` test against each screen's frame yields the right one. // Iteration over `[NSScreen screens]` is cheap (typically <=4 displays). static NSScreen *snipScreenContainingCursor(void) { NSPoint cursor = [NSEvent mouseLocation]; for (NSScreen *candidate in [NSScreen screens]) { if (NSPointInRect(cursor, [candidate frame])) { return candidate; } } NSScreen *fallback = [NSScreen mainScreen]; if (fallback != nil) { return fallback; } return [[NSScreen screens] firstObject]; } // snipMosaicPreviewImage captures the target display before the transparent // overlay becomes visible, then downsamples it to one sample per 10 logical // points. The view scales this tiny image back with nearest-neighbour // interpolation inside each mosaic mask, yielding stable, screen-derived // pixel blocks while the pointer is moving. static NSImage *snipMosaicPreviewImage(NSScreen *screen) { NSSize fullSize = [screen frame].size; NSUInteger screenIndex = [[NSScreen screens] indexOfObjectIdenticalTo:screen]; if (screenIndex == NSNotFound) screenIndex = 0; NSString *temporaryPath = [NSTemporaryDirectory() stringByAppendingPathComponent: [NSString stringWithFormat:@"snapgo-mosaic-%@.png", [[NSUUID UUID] UUIDString]]]; NSTask *captureTask = [[NSTask alloc] init]; [captureTask setExecutableURL:[NSURL fileURLWithPath:@"/usr/sbin/screencapture"]]; [captureTask setArguments:@[@"-x", @"-D", [NSString stringWithFormat:@"%lu", (unsigned long)screenIndex + 1], temporaryPath]]; NSError *launchError = nil; if (![captureTask launchAndReturnError:&launchError]) return nil; [captureTask waitUntilExit]; NSImage *fullImage = [[NSImage alloc] initWithContentsOfFile:temporaryPath]; [[NSFileManager defaultManager] removeItemAtPath:temporaryPath error:nil]; if ([captureTask terminationStatus] != 0 || fullImage == nil) return nil; NSInteger pixelW = MAX(1, (NSInteger)ceil(fullSize.width / 10.0)); NSInteger pixelH = MAX(1, (NSInteger)ceil(fullSize.height / 10.0)); NSBitmapImageRep *bitmap = [[NSBitmapImageRep alloc] initWithBitmapDataPlanes:NULL pixelsWide:pixelW pixelsHigh:pixelH bitsPerSample:8 samplesPerPixel:4 hasAlpha:YES isPlanar:NO colorSpaceName:NSCalibratedRGBColorSpace bytesPerRow:0 bitsPerPixel:0]; if (bitmap == nil) return nil; NSGraphicsContext *context = [NSGraphicsContext graphicsContextWithBitmapImageRep:bitmap]; [NSGraphicsContext saveGraphicsState]; [NSGraphicsContext setCurrentContext:context]; [context setImageInterpolation:NSImageInterpolationHigh]; [fullImage drawInRect:NSMakeRect(0, 0, pixelW, pixelH) fromRect:NSMakeRect(0, 0, fullSize.width, fullSize.height) operation:NSCompositingOperationCopy fraction:1.0 respectFlipped:NO hints:nil]; [context flushGraphics]; [NSGraphicsContext restoreGraphicsState]; NSImage *result = [[NSImage alloc] initWithSize:NSMakeSize(pixelW, pixelH)]; [result addRepresentation:bitmap]; return result; } static void snipShowNativeOverlay(int width, int height) { dispatch_async(dispatch_get_main_queue(), ^{ NSScreen *screen = snipScreenContainingCursor(); if (screen == nil) { nativeOverlayCancel(); return; } NSRect frame = [screen frame]; NSImage *mosaicPreview = snipMosaicPreviewImage(screen); [NSApp activateIgnoringOtherApps:YES]; nativeOverlayWindow = [[SnipNativeOverlayPanel alloc] initWithContentRect:frame styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO]; [nativeOverlayWindow setOpaque:NO]; [nativeOverlayWindow setBackgroundColor:[NSColor clearColor]]; [nativeOverlayWindow setLevel:NSScreenSaverWindowLevel]; [nativeOverlayWindow setHidesOnDeactivate:NO]; [(NSPanel *)nativeOverlayWindow setFloatingPanel:YES]; [nativeOverlayWindow setCollectionBehavior: NSWindowCollectionBehaviorCanJoinAllSpaces | NSWindowCollectionBehaviorFullScreenAuxiliary | NSWindowCollectionBehaviorStationary]; [nativeOverlayWindow setIgnoresMouseEvents:NO]; SnipNativeOverlayView *view = [[SnipNativeOverlayView alloc] initWithFrame:NSMakeRect(0, 0, frame.size.width, frame.size.height)]; [view setMosaicPreviewImage:mosaicPreview]; [nativeOverlayWindow setContentView:view]; [nativeOverlayWindow makeKeyAndOrderFront:nil]; [nativeOverlayWindow makeFirstResponder:view]; nativeOverlayKeyMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskKeyDown handler:^NSEvent *(NSEvent *event) { if ([event keyCode] == 53) { if ([view isEditingText]) { [view cancelTextEditor]; return nil; } [view cancelSelection]; return nil; } if ([event keyCode] == 36 && [view hasSelection]) { if ([view isEditingText]) { [view commitTextEditor]; return nil; } [view confirmSelection]; return nil; } return event; }]; }); } */ import "C" import ( "sync" "github.com/mmmy/snapgo/internal/infrastructure/display" ) var nativeOverlayState struct { sync.Mutex app *App } // showNativeCaptureOverlay uses a macOS-native transparent panel instead of // the Wails WebView overlay, avoiding WKWebView's opaque grey backing layer. func showNativeCaptureOverlay(app *App, info display.Info) bool { nativeOverlayState.Lock() nativeOverlayState.app = app nativeOverlayState.Unlock() C.snipShowNativeOverlay(C.int(info.CSSWidth), C.int(info.CSSHeight)) return true }