Files
SnapGo/native_overlay_darwin.go

1770 lines
89 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build darwin
package main
/*
#cgo CFLAGS: -x objective-c -fobjc-arc -fblocks
#cgo LDFLAGS: -framework AppKit -framework CoreGraphics -framework QuartzCore
#include <math.h>
#include <dispatch/dispatch.h>
#import <objc/runtime.h>
#import <AppKit/AppKit.h>
#import <QuartzCore/QuartzCore.h>
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<NSDictionary *> *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<NSButton *> *strokeSettingButtons;
@property(strong) NSMutableArray<NSButton *> *fontSettingButtons;
@property(strong) NSMutableArray<NSButton *> *colorSettingButtons;
@property(strong) NSMutableArray<NSButton *> *mosaicModeButtons;
@property(strong) NSMutableArray<NSButton *> *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:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M512 85.333333c235.648 0 426.666667 191.018667 426.666667 426.666667s-191.018667 426.666667-426.666667 426.666667S85.333333 747.648 85.333333 512 276.352 85.333333 512 85.333333zM170.666667 512a341.333333 341.333333 0 0 0 550.613333 269.653333L242.304 302.762667A339.84 339.84 0 0 0 170.666667 512z m341.333333-341.333333c-78.848 0-151.466667 26.752-209.28 71.68l478.976 478.933333A341.333333 341.333333 0 0 0 512 170.666667z' fill='white'/></svg>"];
NSImage *copyIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M768 256a85.333333 85.333333 0 0 1 85.333333 85.333333v512a85.333333 85.333333 0 0 1-85.333333 85.333334h-341.333333a85.333333 85.333333 0 0 1-85.333334-85.333334V341.333333a85.333333 85.333333 0 0 1 85.333334-85.333333h341.333333z m0 85.333333h-341.333333v512h341.333333V341.333333z m-128-256a42.666667 42.666667 0 0 1 42.666667 42.666667l-0.042667 42.666667H256l-0.042667 597.333333H213.333333a42.666667 42.666667 0 0 1-42.666666-42.666667V170.666667a85.333333 85.333333 0 0 1 85.333333-85.333334h384z' fill='white'/></svg>"];
NSImage *saveIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M896 810.666667a85.333333 85.333333 0 0 1-85.333333 85.333333H213.333333a85.333333 85.333333 0 0 1-85.333333-85.333333V213.333333a85.333333 85.333333 0 0 1 85.333333-85.333333h250.368a85.333333 85.333333 0 0 1 73.173334 41.429333L563.2 213.333333H810.666667a85.333333 85.333333 0 0 1 85.333333 85.333334v512z m-85.333333-341.333334H213.333333v341.333334h597.333334v-341.333334z m-346.965334-256H213.333333v170.666667h597.333334V298.666667h-271.616a42.666667 42.666667 0 0 1-36.608-20.736L463.701333 213.333333z' fill='white'/></svg>"];
// 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:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M481.749333 353.792c16.682667-16.64 43.690667-16.64 60.373334 0l120.917333 120.832 3.541333 4.010667a42.666667 42.666667 0 0 1-63.872 56.32L554.666667 486.954667V896a42.666667 42.666667 0 0 1-85.333334 0v-409.173333l-48.170666 48.128a42.666667 42.666667 0 0 1-60.330667 0l-3.541333-4.010667a42.666667 42.666667 0 0 1 3.541333-56.32zM512 85.333333c122.538667 0 227.84 73.813333 273.92 179.370667A213.333333 213.333333 0 0 1 725.333333 682.666667a42.666667 42.666667 0 0 1-4.992-85.034667L725.333333 597.333333a128 128 0 0 0 36.394667-250.794666l-38.144-11.264-15.914667-36.437334a213.376 213.376 0 0 0-407.296 58.026667l-7.381333 58.368-57.173333 13.824A85.418667 85.418667 0 0 0 256 597.333333h42.666667a42.666667 42.666667 0 0 1 0 85.333334H256a170.666667 170.666667 0 0 1-40.277333-336.554667A298.709333 298.709333 0 0 1 512 85.333333z' fill='white'/></svg>"];
NSImage *ftpIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M128 128h768v256H128V128zm64 64v128h640V192H192zm64 48h64v32h-64v-32zM128 448h416v64H192v192h352v64H128V448zm560-32 160 160-45.248 45.248L720 538.496V832h-64V538.496l-82.752 82.752L528 576l160-160z' fill='white'/></svg>"];
NSImage *ocrIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M192 160h192v64H256v128h-64V160zM640 160h192v192h-64V224H640v-64zM256 672v128h128v64H192V672h64zM832 672v192H640v-64h128V672h64zM469.333333 288h85.333334l170.666666 448h-78.933333l-39.253333-106.666667H416.853333L377.6 736H298.666667l170.666666-448zM439.466667 565.333333h144.896L512 368.981333 439.466667 565.333333z' fill='white'/></svg>"];
NSImage *summaryIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M256 128h448l192 192v576H256V128z m64 64v640h512V352H672V192H320z m416 45.248V288h50.752L736 237.248zM384 448h384v64H384v-64z m0 128h384v64H384v-64z m0 128h256v64H384v-64zM192 256v704h512v64H128V256h64z' fill='white'/></svg>"];
// upload.svg — newly replaced "send/cloud" icon supplied by the user.
NSImage *uploadIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M550.528 544.128L640 539.776V678.4l178.005333-178.005333L640 322.389333v129.024l-72.618667 10.922667c-155.52 23.424-251.733333 73.557333-312.874666 164.437333 84.906667-50.602667 178.261333-76.928 296.021333-82.645333zM554.666667 629.376c-27.392 1.322667-53.034667 3.968-77.141334 7.808l-8.192 1.365333c-136.704 23.637333-224.810667 87.168-310.954666 176.128-26.154667 27.008-51.882667 59.648-51.882667 59.648-14.848 18.176-24.021333 14.037333-20.352-9.173333 0 0 4.394667-31.402667 11.690667-65.792C144.938667 577.066667 250.581333 423.68 554.666667 377.941333V209.066667a38.4 38.4 0 0 1 65.536-27.136l291.328 291.285333a38.4 38.4 0 0 1 0 54.314667l-291.328 291.285333A38.4 38.4 0 0 1 554.666667 791.637333v-162.261333z' fill='white'/></svg>"];
[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:@"<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><g fill='none' stroke='white' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='m14.5 5.5 4-4 4 4-4 4'/><path d='m17.5 8.5-7.8 7.8'/><path d='M11 14.8c.8 2.8-.7 5.5-3.7 6.3-1.8.5-3.7.1-5.3-.9 2.1-.4 2.8-1.5 3.1-3.3.4-2.5 3.3-3.8 5.9-2.1Z'/></g></svg>"]];
[self styleIconButton:_lineButton image:[self iconFromSVG:@"<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><path d='M5 19 19 5' fill='none' stroke='white' stroke-width='2' stroke-linecap='round'/></svg>"]];
[self styleIconButton:_arrowButton image:[self iconFromSVG:@"<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><g fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M5 19 19 5'/><path d='M11 5h8v8'/></g></svg>"]];
[_penButton setToolTip:@"画笔标记"];
[_lineButton setToolTip:@"直线标记"];
[_arrowButton setToolTip:@"箭头标记"];
[self styleIconButton:_rectButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M96 96h832v832H96V96z m32 32v768h768V128H128z' fill='white'/></svg>"]];
[self styleIconButton:_ellipseButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M512 928c229.76 0 416-186.24 416-416S741.76 96 512 96 96 282.24 96 512s186.24 416 416 416z m0-32C299.936 896 128 724.064 128 512S299.936 128 512 128s384 171.936 384 384-171.936 384-384 384z' fill='white'/></svg>"]];
[self styleIconButton:_textButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M224 160h576v96H560v608h-96V256H224V160zM384 864h256v64H384v-64z' fill='white'/></svg>"]];
[self styleIconButton:_mosaicButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M128 128h320v320H128V128zm448 0h320v320H576V128zM128 576h320v320H128V576zm448 0h320v320H576V576z' fill='white'/></svg>"]];
[_mosaicButton setToolTip:@"马赛克标记"];
[self styleIconButton:_undoButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><g transform='translate(1024 0) scale(-1 1)'><path d='M838.976 288l-169.344-162.56a16 16 0 1 1 22.144-23.04l213.632 204.992-213.312 215.84a16 16 0 0 1-22.784-22.464L847.936 320H454.56c-164.192 0-297.28 128.96-297.28 288s133.088 288 297.28 288H704v32h-242.784C266.88 928 128 784.736 128 608S266.88 288 461.248 288h377.728z' fill='white'/></g></svg>"]];
[_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
? @"<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><g fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='M9 11V5.5a2 2 0 0 1 4 0V10'/><path d='M13 10V8a2 2 0 0 1 4 0v3'/><path d='M17 10a2 2 0 0 1 4 0v4.5c0 4-2.5 6.5-6.5 6.5h-1.2a6 6 0 0 1-4.7-2.3L4 13.2a1.9 1.9 0 0 1 2.8-2.5L9 12.5'/></g></svg>"
: @"<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><g fill='none' stroke='black' stroke-width='1.8' stroke-linejoin='round'><rect x='2.5' y='2.5' width='19' height='19' rx='2'/></g><g fill='black'><rect x='6' y='6' width='4' height='4' rx='.5'/><rect x='14' y='6' width='4' height='4' rx='.5'/><rect x='6' y='14' width='4' height='4' rx='.5'/><rect x='14' y='14' width='4' height='4' rx='.5'/></g></svg>";
[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)));
}
// Annotations use selection-local coordinates. When a top or left resize
// moves the selection origin, offset every local point in the opposite
// direction so the annotation remains attached to the same screen pixels.
- (void)preserveAnnotationScreenPositionsFromOrigin:(NSPoint)previousOrigin toOrigin:(NSPoint)nextOrigin {
CGFloat dx = previousOrigin.x - nextOrigin.x;
CGFloat dy = previousOrigin.y - nextOrigin.y;
if (fabs(dx) <= 0.001 && fabs(dy) <= 0.001) {
return;
}
for (NSDictionary *annotation in _annotations) {
NSMutableArray *points = (NSMutableArray *)annotation[@"points"];
for (NSUInteger i = 0; i < points.count; i++) {
NSPoint point = [points[i] pointValue];
points[i] = [NSValue valueWithPoint:NSMakePoint(point.x + dx, point.y + dy)];
}
}
}
// 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));
NSRect nextSelection = NSMakeRect(MIN(left, right), MIN(top, bottom), fabs(right - left), fabs(bottom - top));
[self preserveAnnotationScreenPositionsFromOrigin:_selection.origin toOrigin:nextSelection.origin];
_selection = nextSelection;
} 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
}