From 1d52d154fd43fb432ddb1444f8cd306468dc73e4 Mon Sep 17 00:00:00 2001 From: mamamiyear Date: Thu, 4 Jun 2026 01:05:28 +0800 Subject: [PATCH] feat(app): support local screenshot actions --- app.go | 257 +++++++++++++++--- frontend/src/App.vue | 48 ++++ frontend/src/views/CaptureOverlay.vue | 53 +++- frontend/wailsjs/go/main/App.d.ts | 10 + frontend/wailsjs/go/main/App.js | 20 ++ frontend/wailsjs/go/models.ts | 50 ++-- internal/application/capture_actions.go | 88 ++++++ internal/application/capture_actions_test.go | 72 +++++ .../infrastructure/clipboard/clipboard.go | 18 +- native_overlay_callbacks_darwin.go | 64 +++-- native_overlay_darwin.go | 90 ++++-- 11 files changed, 662 insertions(+), 108 deletions(-) create mode 100644 internal/application/capture_actions.go create mode 100644 internal/application/capture_actions_test.go diff --git a/app.go b/app.go index 55e3f08..3e74c89 100644 --- a/app.go +++ b/app.go @@ -88,6 +88,13 @@ type CaptureResult struct { Annotations []application.Annotation `json:"annotations"` } +// CaptureActionResult tells the frontend whether an action was completed or +// the user cancelled a secondary choice such as the save destination dialog. +type CaptureActionResult struct { + Completed bool `json:"completed"` + Path string `json:"path,omitempty"` +} + // NewApp creates a new App with collaborators already initialised. func NewApp() *App { store, err := config.NewFileStore() @@ -169,17 +176,13 @@ func (a *App) runInteractiveCapture() { cfg := a.cfg a.mu.RUnlock() - if !cfg.IsS3Configured() { - a.surfaceWindow() - wruntime.EventsEmit(a.ctx, "upload:failure", - "S3 is not configured yet — open settings first") - return - } - provider, err := oss.NewS3Provider(cfg.S3) - if err != nil { - a.surfaceWindow() - wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) - return + var provider domain.OSSProvider + if cfg.IsS3Configured() { + var err error + provider, err = oss.NewS3Provider(cfg.S3) + if err != nil { + wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) + } } // Hide the settings window before transforming it into an overlay, so @@ -296,6 +299,58 @@ func (a *App) runUploadPipeline(provider domain.OSSProvider, pngBytes []byte) er return svc.ExecuteWithBytes(a.ctx, pngBytes) } +func (a *App) runCopyImagePipeline(pngBytes []byte) error { + svc := &application.CaptureActionsService{ + Clipboard: a.clip, + Notifier: &runtimeNotifier{ctx: a.ctx}, + } + return svc.CopyImage(a.ctx, pngBytes) +} + +func (a *App) runSaveImagePipeline(pngBytes []byte, dir string) (string, error) { + svc := &application.CaptureActionsService{ + Clipboard: a.clip, + Notifier: &runtimeNotifier{ctx: a.ctx}, + } + return svc.SaveImage(a.ctx, pngBytes, dir) +} + +func (a *App) consumePendingCapture() (*pendingCapture, error) { + a.pendingMu.Lock() + pc := a.pending + a.pending = nil + a.pendingMu.Unlock() + + if pc == nil { + return nil, fmt.Errorf("no pending capture") + } + return pc, nil +} + +func (a *App) captureSelectedPNG(result CaptureResult, pc *pendingCapture) ([]byte, error) { + rect := result.Rect + captureRect := image.Rect(rect.X, rect.Y, rect.X+rect.W, rect.Y+rect.H) + cropped, err := a.capturer.CaptureRegion(captureRect) + if err != nil { + return nil, err + } + if len(result.Annotations) > 0 { + cropped, err = application.ApplyAnnotations(cropped, result.Annotations, pc.Display.Scale) + if err != nil { + return nil, err + } + } + return cropped, nil +} + +func (a *App) chooseSaveDirectory() (string, error) { + return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{ + Title: "Save screenshot to folder", + DefaultDirectory: userPicturesDir(), + CanCreateDirectories: true, + }) +} + // --------------------------------------------------------------------------- // Bound methods (called from the frontend via Wails) // --------------------------------------------------------------------------- @@ -366,77 +421,189 @@ func (a *App) CaptureNow() { // keep showing the screenshot (e.g. for a retry) — currently it just // dismisses regardless and surfaces the error via the upload:failure toast. func (a *App) ConfirmRegion(result CaptureResult) error { - a.pendingMu.Lock() - pc := a.pending - a.pending = nil - a.pendingMu.Unlock() - - if pc == nil { - return fmt.Errorf("no pending capture") + pc, err := a.consumePendingCapture() + if err != nil { + return err } defer func() { a.capturing.Store(false) a.dismissOverlay() }() - rect := result.Rect - captureRect := image.Rect(rect.X, rect.Y, rect.X+rect.W, rect.Y+rect.H) a.dismissOverlay() flushFrame() - cropped, err := a.capturer.CaptureRegion(captureRect) + cropped, err := a.captureSelectedPNG(result, pc) if err != nil { wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) return err } - if len(result.Annotations) > 0 { - cropped, err = application.ApplyAnnotations(cropped, result.Annotations, pc.Display.Scale) - if err != nil { - wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) - return err - } - } if err := a.runUploadPipeline(pc.Provider, cropped); err != nil { return err } return nil } +// CopyRegionImage is invoked by the fallback Wails overlay when the user +// double-clicks the selected area. +func (a *App) CopyRegionImage(result CaptureResult) error { + pc, err := a.consumePendingCapture() + if err != nil { + return err + } + defer func() { + a.capturing.Store(false) + a.dismissOverlay() + }() + + a.dismissOverlay() + flushFrame() + + cropped, err := a.captureSelectedPNG(result, pc) + if err != nil { + wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) + return err + } + return a.runCopyImagePipeline(cropped) +} + +// SaveRegionImage lets the fallback Wails overlay pick a native destination +// folder and writes the selected screenshot PNG there. +func (a *App) SaveRegionImage(result CaptureResult) (CaptureActionResult, error) { + pc, err := a.consumePendingCapture() + if err != nil { + return CaptureActionResult{}, err + } + + a.dismissOverlay() + flushFrame() + + cropped, err := a.captureSelectedPNG(result, pc) + if err != nil { + a.capturing.Store(false) + wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) + return CaptureActionResult{}, err + } + + dir, err := a.chooseSaveDirectory() + if err != nil { + a.capturing.Store(false) + wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) + return CaptureActionResult{}, err + } + if dir == "" { + a.capturing.Store(false) + a.dismissOverlay() + return CaptureActionResult{Completed: false}, nil + } + + path, err := a.runSaveImagePipeline(cropped, dir) + a.capturing.Store(false) + a.dismissOverlay() + if err != nil { + return CaptureActionResult{}, err + } + return CaptureActionResult{Completed: true, Path: path}, nil +} + // ConfirmNativeRegion mirrors ConfirmRegion for the macOS native overlay. // The native AppKit panel has already been closed by the time this method is // called, so we must not dismiss/restore the Wails overlay window here. func (a *App) ConfirmNativeRegion(result CaptureResult) error { - a.pendingMu.Lock() - pc := a.pending - a.pending = nil - a.pendingMu.Unlock() - - if pc == nil { - return fmt.Errorf("no pending capture") + pc, err := a.consumePendingCapture() + if err != nil { + return err } defer func() { a.capturing.Store(false) hideDockIcon() }() - rect := result.Rect - captureRect := image.Rect(rect.X, rect.Y, rect.X+rect.W, rect.Y+rect.H) flushFrame() - cropped, err := a.capturer.CaptureRegion(captureRect) + cropped, err := a.captureSelectedPNG(result, pc) if err != nil { wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) return err } - if len(result.Annotations) > 0 { - cropped, err = application.ApplyAnnotations(cropped, result.Annotations, pc.Display.Scale) - if err != nil { - wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) - return err - } - } return a.runUploadPipeline(pc.Provider, cropped) } +func (a *App) CopyNativeRegionImage(result CaptureResult) error { + pc, err := a.consumePendingCapture() + if err != nil { + return err + } + defer func() { + a.capturing.Store(false) + hideDockIcon() + }() + + flushFrame() + cropped, err := a.captureSelectedPNG(result, pc) + if err != nil { + wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) + return err + } + return a.runCopyImagePipeline(cropped) +} + +func (a *App) SaveNativeRegionImage(result CaptureResult) (CaptureActionResult, error) { + pc, err := a.consumePendingCapture() + if err != nil { + return CaptureActionResult{}, err + } + defer func() { + a.capturing.Store(false) + hideDockIcon() + }() + + flushFrame() + cropped, err := a.captureSelectedPNG(result, pc) + if err != nil { + wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) + return CaptureActionResult{}, err + } + dir, err := a.chooseSaveDirectory() + if err != nil { + wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) + return CaptureActionResult{}, err + } + if dir == "" { + return CaptureActionResult{Completed: false}, nil + } + path, err := a.runSaveImagePipeline(cropped, dir) + if err != nil { + return CaptureActionResult{}, err + } + return CaptureActionResult{Completed: true, Path: path}, nil +} + +func (a *App) SaveNativeRegionImageToDir(result CaptureResult, dir string) (CaptureActionResult, error) { + pc, err := a.consumePendingCapture() + if err != nil { + return CaptureActionResult{}, err + } + defer func() { + a.capturing.Store(false) + hideDockIcon() + }() + + if dir == "" { + return CaptureActionResult{Completed: false}, nil + } + flushFrame() + cropped, err := a.captureSelectedPNG(result, pc) + if err != nil { + wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) + return CaptureActionResult{}, err + } + path, err := a.runSaveImagePipeline(cropped, dir) + if err != nil { + return CaptureActionResult{}, err + } + return CaptureActionResult{Completed: true, Path: path}, nil +} + func parseNativeAnnotations(raw string) []application.Annotation { if raw == "" { return nil diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 10f2001..d3d6fb8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -21,6 +21,8 @@ import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime' import { RetryRegisterHotkey, ConfirmRegion, + CopyRegionImage, + SaveRegionImage, CancelRegion, } from '../wailsjs/go/main/App' @@ -85,6 +87,50 @@ async function onOverlayConfirm(rect: { } } +async function onOverlayCopy(rect: { + rect: { + x: number + y: number + w: number + h: number + } + annotations: Array<{ + tool: string + color: string + points: Array<{ x: number; y: number }> + }> +}) { + mode.value = 'settings' + overlayPayload.value = null + try { + await CopyRegionImage(rect as any) + } catch { + /* Surfaced via upload:failure */ + } +} + +async function onOverlaySave(rect: { + rect: { + x: number + y: number + w: number + h: number + } + annotations: Array<{ + tool: string + color: string + points: Array<{ x: number; y: number }> + }> +}) { + mode.value = 'settings' + overlayPayload.value = null + try { + await SaveRegionImage(rect as any) + } catch { + /* Surfaced via upload:failure */ + } +} + async function onOverlayCancel() { mode.value = 'settings' overlayPayload.value = null @@ -143,6 +189,8 @@ onUnmounted(() => { :width="overlayPayload.cssWidth" :height="overlayPayload.cssHeight" @confirm="onOverlayConfirm" + @copy="onOverlayCopy" + @save="onOverlaySave" @cancel="onOverlayCancel" /> diff --git a/frontend/src/views/CaptureOverlay.vue b/frontend/src/views/CaptureOverlay.vue index 8e2d3ca..3a7624f 100644 --- a/frontend/src/views/CaptureOverlay.vue +++ b/frontend/src/views/CaptureOverlay.vue @@ -30,6 +30,14 @@ const emit = defineEmits<{ e: 'confirm', payload: { rect: Rect; annotations: Annotation[] } ): void + ( + e: 'copy', + payload: { rect: Rect; annotations: Annotation[] } + ): void + ( + e: 'save', + payload: { rect: Rect; annotations: Annotation[] } + ): void (e: 'cancel'): void }>() @@ -92,7 +100,7 @@ const sizeLabel = computed(() => { const rightToolbarPos = computed(() => { if (!rect.value) return null - return placeToolbar(rect.value, 220, 40, 'right') + return placeToolbar(rect.value, 304, 40, 'right') }) const leftToolbarPos = computed(() => { @@ -218,6 +226,11 @@ function onSelectionMouseDown(e: MouseEvent) { if (e.button !== 0 || !rect.value) return e.stopPropagation() paletteOpen.value = false + if (e.detail >= 2) { + removeSinglePointAnnotation() + onCopy() + return + } const p = pointFromEvent(e) const handle = hitHandle(p) if (handle) { @@ -312,11 +325,26 @@ function chooseColor(color: string) { } function onConfirm() { + emitAction('confirm') +} + +function onCopy() { + emitAction('copy') +} + +function onSave() { + emitAction('save') +} + +function emitAction(action: 'confirm' | 'copy' | 'save') { if (!rect.value) return - emit('confirm', { + const payload = { rect: { ...rect.value }, annotations: annotations.value, - }) + } + if (action === 'confirm') emit('confirm', payload) + if (action === 'copy') emit('copy', payload) + if (action === 'save') emit('save', payload) } function onCancel() { @@ -337,6 +365,13 @@ function undoAnnotation() { annotations.value.pop() } +function removeSinglePointAnnotation() { + const last = annotations.value[annotations.value.length - 1] + if (last && last.tool === activeTool.value && last.points.length <= 1) { + annotations.value.pop() + } +} + onMounted(() => { window.addEventListener('keydown', onKeydown) }) @@ -522,6 +557,9 @@ onUnmounted(() => { @mousedown.stop > + @@ -612,7 +650,7 @@ onUnmounted(() => { width: 190px; } .action-toolbar { - width: 220px; + width: 304px; } .btn, @@ -636,6 +674,13 @@ onUnmounted(() => { .btn.cancel:hover { background: rgba(255, 255, 255, 0.08); } +.btn.secondary { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} +.btn.secondary:hover { + background: rgba(255, 255, 255, 0.18); +} .btn.primary { background: #3b82f6; color: #fff; diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index db9b728..a2a236b 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -13,6 +13,10 @@ export function ConfirmNativeRegion(arg1:main.CaptureResult):Promise; export function ConfirmRegion(arg1:main.CaptureResult):Promise; +export function CopyNativeRegionImage(arg1:main.CaptureResult):Promise; + +export function CopyRegionImage(arg1:main.CaptureResult):Promise; + export function GetConfig():Promise; export function QuitApp():Promise; @@ -21,6 +25,12 @@ export function RetryRegisterHotkey():Promise; export function SaveConfig(arg1:domain.AppConfig):Promise; +export function SaveNativeRegionImage(arg1:main.CaptureResult):Promise; + +export function SaveNativeRegionImageToDir(arg1:main.CaptureResult,arg2:string):Promise; + +export function SaveRegionImage(arg1:main.CaptureResult):Promise; + export function ShowWindow():Promise; export function TestConnection(arg1:domain.S3Config):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 3edd6c1..74c01c6 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -22,6 +22,14 @@ export function ConfirmRegion(arg1) { return window['go']['main']['App']['ConfirmRegion'](arg1); } +export function CopyNativeRegionImage(arg1) { + return window['go']['main']['App']['CopyNativeRegionImage'](arg1); +} + +export function CopyRegionImage(arg1) { + return window['go']['main']['App']['CopyRegionImage'](arg1); +} + export function GetConfig() { return window['go']['main']['App']['GetConfig'](); } @@ -38,6 +46,18 @@ export function SaveConfig(arg1) { return window['go']['main']['App']['SaveConfig'](arg1); } +export function SaveNativeRegionImage(arg1) { + return window['go']['main']['App']['SaveNativeRegionImage'](arg1); +} + +export function SaveNativeRegionImageToDir(arg1, arg2) { + return window['go']['main']['App']['SaveNativeRegionImageToDir'](arg1, arg2); +} + +export function SaveRegionImage(arg1) { + return window['go']['main']['App']['SaveRegionImage'](arg1); +} + export function ShowWindow() { return window['go']['main']['App']['ShowWindow'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 3198b1b..1fd929b 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,13 +1,13 @@ export namespace application { - + export class Point { x: number; y: number; - + static createFrom(source: any = {}) { return new Point(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.x = source["x"]; @@ -18,18 +18,18 @@ export namespace application { tool: string; color: string; points: Point[]; - + static createFrom(source: any = {}) { return new Annotation(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.tool = source["tool"]; this.color = source["color"]; this.points = this.convertValues(source["points"], Point); } - + convertValues(a: any, classs: any, asMap: boolean = false): any { if (!a) { return a; @@ -52,7 +52,7 @@ export namespace application { } export namespace domain { - + export class S3Config { endpoint: string; region: string; @@ -62,11 +62,11 @@ export namespace domain { pathPrefix: string; publicUrlBase: string; usePathStyle: boolean; - + static createFrom(source: any = {}) { return new S3Config(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.endpoint = source["endpoint"]; @@ -82,17 +82,17 @@ export namespace domain { export class AppConfig { hotkey: string; s3: S3Config; - + static createFrom(source: any = {}) { return new AppConfig(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.hotkey = source["hotkey"]; this.s3 = this.convertValues(source["s3"], S3Config); } - + convertValues(a: any, classs: any, asMap: boolean = false): any { if (!a) { return a; @@ -115,17 +115,31 @@ export namespace domain { } export namespace main { - + + export class CaptureActionResult { + completed: boolean; + path?: string; + + static createFrom(source: any = {}) { + return new CaptureActionResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.completed = source["completed"]; + this.path = source["path"]; + } + } export class RegionRect { x: number; y: number; w: number; h: number; - + static createFrom(source: any = {}) { return new RegionRect(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.x = source["x"]; @@ -137,17 +151,17 @@ export namespace main { export class CaptureResult { rect: RegionRect; annotations: application.Annotation[]; - + static createFrom(source: any = {}) { return new CaptureResult(source); } - + constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.rect = this.convertValues(source["rect"], RegionRect); this.annotations = this.convertValues(source["annotations"], application.Annotation); } - + convertValues(a: any, classs: any, asMap: boolean = false): any { if (!a) { return a; diff --git a/internal/application/capture_actions.go b/internal/application/capture_actions.go new file mode 100644 index 0000000..2c63021 --- /dev/null +++ b/internal/application/capture_actions.go @@ -0,0 +1,88 @@ +package application + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/mmmy/snapgo/internal/infrastructure/clipboard" +) + +// CaptureActionsService handles non-upload actions on already-produced PNG +// screenshots. Keeping these actions here makes save/copy available across +// overlay implementations without binding them to Wails or a specific OS. +type CaptureActionsService struct { + Clipboard clipboard.Writer + Notifier Notifier +} + +func (s *CaptureActionsService) CopyImage(_ context.Context, pngBytes []byte) error { + if len(pngBytes) == 0 { + err := fmt.Errorf("empty screenshot") + s.notifyFailure(err.Error()) + return err + } + if s.Clipboard == nil { + err := fmt.Errorf("clipboard is not configured") + s.notifyFailure(err.Error()) + return err + } + if err := s.Clipboard.WriteImage(pngBytes); err != nil { + s.notifyFailure("clipboard write failed: " + err.Error()) + return err + } + if s.Notifier != nil { + s.Notifier.NotifySuccess("image copied to clipboard") + } + return nil +} + +func (s *CaptureActionsService) SaveImage(_ context.Context, pngBytes []byte, dir string) (string, error) { + if len(pngBytes) == 0 { + err := fmt.Errorf("empty screenshot") + s.notifyFailure(err.Error()) + return "", err + } + dir = strings.TrimSpace(dir) + if dir == "" { + err := fmt.Errorf("save directory is empty") + s.notifyFailure(err.Error()) + return "", err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + s.notifyFailure("create save directory failed: " + err.Error()) + return "", err + } + path := uniquePNGPath(dir, time.Now()) + if err := os.WriteFile(path, pngBytes, 0o644); err != nil { + s.notifyFailure("save failed: " + err.Error()) + return "", err + } + if s.Notifier != nil { + s.Notifier.NotifySuccess(path) + } + return path, nil +} + +func (s *CaptureActionsService) notifyFailure(reason string) { + if s.Notifier != nil { + s.Notifier.NotifyFailure(reason) + } +} + +func uniquePNGPath(dir string, now time.Time) string { + base := now.Format("20060102-150405") + path := filepath.Join(dir, base+".png") + for i := 1; fileExists(path); i++ { + path = filepath.Join(dir, fmt.Sprintf("%s-%02d.png", base, i)) + } + return path +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/application/capture_actions_test.go b/internal/application/capture_actions_test.go new file mode 100644 index 0000000..f25c135 --- /dev/null +++ b/internal/application/capture_actions_test.go @@ -0,0 +1,72 @@ +package application + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +type fakeClipboard struct { + text string + image []byte +} + +func (f *fakeClipboard) WriteText(s string) error { + f.text = s + return nil +} + +func (f *fakeClipboard) WriteImage(pngBytes []byte) error { + f.image = append([]byte(nil), pngBytes...) + return nil +} + +type fakeNotifier struct { + success string + failure string +} + +func (f *fakeNotifier) NotifySuccess(value string) { f.success = value } +func (f *fakeNotifier) NotifyFailure(reason string) { f.failure = reason } + +func TestCaptureActionsCopyImageWritesPNGToClipboard(t *testing.T) { + clip := &fakeClipboard{} + notifier := &fakeNotifier{} + svc := &CaptureActionsService{Clipboard: clip, Notifier: notifier} + + err := svc.CopyImage(context.Background(), []byte("png")) + if err != nil { + t.Fatalf("copy image: %v", err) + } + if string(clip.image) != "png" { + t.Fatalf("expected image bytes copied, got %q", string(clip.image)) + } + if notifier.success != "image copied to clipboard" { + t.Fatalf("expected copy success notification, got %q", notifier.success) + } +} + +func TestCaptureActionsSaveImageWritesUniquePNG(t *testing.T) { + dir := t.TempDir() + svc := &CaptureActionsService{Notifier: &fakeNotifier{}} + + path, err := svc.SaveImage(context.Background(), []byte("png"), dir) + if err != nil { + t.Fatalf("save image: %v", err) + } + if filepath.Dir(path) != dir { + t.Fatalf("expected save in %q, got %q", dir, path) + } + if data, err := os.ReadFile(path); err != nil || string(data) != "png" { + t.Fatalf("expected saved bytes, data=%q err=%v", string(data), err) + } + + next, err := svc.SaveImage(context.Background(), []byte("png2"), dir) + if err != nil { + t.Fatalf("save second image: %v", err) + } + if next == path { + t.Fatalf("expected unique path for second save") + } +} diff --git a/internal/infrastructure/clipboard/clipboard.go b/internal/infrastructure/clipboard/clipboard.go index 60cc96b..8ab5ede 100644 --- a/internal/infrastructure/clipboard/clipboard.go +++ b/internal/infrastructure/clipboard/clipboard.go @@ -1,10 +1,10 @@ // Package clipboard wraps golang.design/x/clipboard with lazy initialization. // // Why a wrapper: -// - The upstream library requires a one-time clipboard.Init() call. Hiding it -// here keeps the application service free of init concerns. -// - Allows future swap to a different backend (e.g. atotto/clipboard) without -// changing callers. +// - The upstream library requires a one-time clipboard.Init() call. Hiding it +// here keeps the application service free of init concerns. +// - Allows future swap to a different backend (e.g. atotto/clipboard) without +// changing callers. package clipboard import ( @@ -17,6 +17,7 @@ import ( // Writer abstracts clipboard writes for testability. type Writer interface { WriteText(s string) error + WriteImage(pngBytes []byte) error } type clipWriter struct { @@ -44,3 +45,12 @@ func (w *clipWriter) WriteText(s string) error { clipboard.Write(clipboard.FmtText, []byte(s)) return nil } + +// WriteImage replaces the clipboard contents with PNG-encoded image data. +func (w *clipWriter) WriteImage(pngBytes []byte) error { + if err := w.ensureInit(); err != nil { + return fmt.Errorf("clipboard init: %w", err) + } + clipboard.Write(clipboard.FmtImage, pngBytes) + return nil +} diff --git a/native_overlay_callbacks_darwin.go b/native_overlay_callbacks_darwin.go index 9649b04..b6cc643 100644 --- a/native_overlay_callbacks_darwin.go +++ b/native_overlay_callbacks_darwin.go @@ -4,35 +4,67 @@ package main import "C" -//export nativeOverlayConfirm -func nativeOverlayConfirm(x, y, w, h C.int, annotationsJSON *C.char) { +func nativeCaptureResult(x, y, w, h C.int, annotationsJSON *C.char) CaptureResult { + rawAnnotations := C.GoString(annotationsJSON) + return CaptureResult{ + Rect: RegionRect{ + X: int(x), + Y: int(y), + W: int(w), + H: int(h), + }, + Annotations: parseNativeAnnotations(rawAnnotations), + } +} + +func consumeNativeOverlayApp() *App { nativeOverlayState.Lock() app := nativeOverlayState.app nativeOverlayState.app = nil nativeOverlayState.Unlock() + return app +} + +//export nativeOverlayConfirm +func nativeOverlayConfirm(x, y, w, h C.int, annotationsJSON *C.char) { + app := consumeNativeOverlayApp() if app == nil { return } - rawAnnotations := C.GoString(annotationsJSON) + result := nativeCaptureResult(x, y, w, h, annotationsJSON) go func() { - _ = app.ConfirmNativeRegion(CaptureResult{ - Rect: RegionRect{ - X: int(x), - Y: int(y), - W: int(w), - H: int(h), - }, - Annotations: parseNativeAnnotations(rawAnnotations), - }) + _ = app.ConfirmNativeRegion(result) + }() +} + +//export nativeOverlayCopy +func nativeOverlayCopy(x, y, w, h C.int, annotationsJSON *C.char) { + app := consumeNativeOverlayApp() + if app == nil { + return + } + result := nativeCaptureResult(x, y, w, h, annotationsJSON) + go func() { + _ = app.CopyNativeRegionImage(result) + }() +} + +//export nativeOverlaySave +func nativeOverlaySave(x, y, w, h C.int, annotationsJSON *C.char, dir *C.char) { + app := consumeNativeOverlayApp() + if app == nil { + return + } + result := nativeCaptureResult(x, y, w, h, annotationsJSON) + saveDir := C.GoString(dir) + go func() { + _, _ = app.SaveNativeRegionImageToDir(result, saveDir) }() } //export nativeOverlayCancel func nativeOverlayCancel() { - nativeOverlayState.Lock() - app := nativeOverlayState.app - nativeOverlayState.app = nil - nativeOverlayState.Unlock() + app := consumeNativeOverlayApp() if app == nil { return } diff --git a/native_overlay_darwin.go b/native_overlay_darwin.go index b887b07..1aef464 100644 --- a/native_overlay_darwin.go +++ b/native_overlay_darwin.go @@ -11,6 +11,8 @@ package main #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 nativeOverlayCancel(void); static NSWindow *nativeOverlayWindow = nil; @@ -40,6 +42,7 @@ static id nativeOverlayKeyMonitor = nil; @property(strong) NSMutableArray *annotations; @property(strong) NSMutableDictionary *draftAnnotation; @property(strong) NSButton *cancelButton; +@property(strong) NSButton *saveButton; @property(strong) NSButton *uploadButton; @property(strong) NSButton *penButton; @property(strong) NSButton *rectButton; @@ -52,6 +55,8 @@ static id nativeOverlayKeyMonitor = nil; - (void)syncControls; - (void)styleControls; - (void)confirmSelection; +- (void)copySelection; +- (void)saveSelection; - (void)cancelSelection; @end @@ -70,6 +75,7 @@ static id nativeOverlayKeyMonitor = nil; _annotations = [NSMutableArray array]; _cancelButton = [NSButton buttonWithTitle:@"Cancel" target:self action:@selector(cancelSelection)]; + _saveButton = [NSButton buttonWithTitle:@"Save" target:self action:@selector(saveSelection)]; _uploadButton = [NSButton buttonWithTitle:@"Upload & copy" target:self action:@selector(confirmSelection)]; _penButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectPen)]; _rectButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectRect)]; @@ -81,10 +87,10 @@ static id nativeOverlayKeyMonitor = nil; _hintLabel = [NSTextField labelWithString:@"Drag to select an area · Esc to cancel"]; [self styleControls]; - for (NSView *view in @[_cancelButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _sizeLabel, _hintLabel]) { + for (NSView *view in @[_cancelButton, _saveButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _sizeLabel, _hintLabel]) { [self addSubview:view]; } - for (NSView *view in @[_cancelButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView]) { + for (NSView *view in @[_cancelButton, _saveButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView]) { [view setHidden:YES]; } [_sizeLabel setHidden:YES]; @@ -150,6 +156,9 @@ static id nativeOverlayKeyMonitor = nil; [self styleButton:_cancelButton background:[NSColor colorWithCalibratedWhite:0.12 alpha:0.96] foreground:[NSColor colorWithCalibratedWhite:0.86 alpha:1.0]]; + [self styleButton:_saveButton + background:[NSColor colorWithCalibratedWhite:0.18 alpha:0.96] + foreground:[NSColor whiteColor]]; [self styleButton:_uploadButton background:[NSColor colorWithCalibratedRed:59.0/255.0 green:130.0/255.0 blue:246.0/255.0 alpha:1.0] foreground:[NSColor whiteColor]]; @@ -310,6 +319,10 @@ static id nativeOverlayKeyMonitor = nil; - (void)mouseDown:(NSEvent *)event { NSPoint p = [self convertPoint:[event locationInWindow] fromView:nil]; + if ([event clickCount] == 2 && _hasSelection && NSPointInRect(p, _selection)) { + [self copySelection]; + return; + } NSString *handle = [self resizeHandleAtPoint:p]; if (handle != nil) { _resizing = YES; @@ -426,6 +439,7 @@ static id nativeOverlayKeyMonitor = nil; - (void)syncControls { BOOL visible = _hasSelection && _selection.size.width >= 4 && _selection.size.height >= 4; [_cancelButton setHidden:!visible]; + [_saveButton setHidden:!visible]; [_uploadButton setHidden:!visible]; [_penButton setHidden:!visible]; [_rectButton setHidden:!visible]; @@ -442,7 +456,7 @@ static id nativeOverlayKeyMonitor = nil; [_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)]; - CGFloat toolbarW = 220; + CGFloat toolbarW = 292; CGFloat toolbarH = 40; CGFloat x = _selection.origin.x + _selection.size.width - toolbarW; CGFloat y = _selection.origin.y + _selection.size.height + 8; @@ -451,8 +465,9 @@ static id nativeOverlayKeyMonitor = nil; } x = MAX(0, MIN(x, self.bounds.size.width - toolbarW)); - [_cancelButton setFrame:NSMakeRect(x, y, 86, 32)]; - [_uploadButton setFrame:NSMakeRect(x + 92, y, 128, 32)]; + [_cancelButton setFrame:NSMakeRect(x, y, 74, 32)]; + [_saveButton setFrame:NSMakeRect(x + 80, y, 64, 32)]; + [_uploadButton setFrame:NSMakeRect(x + 150, y, 142, 32)]; CGFloat markW = 170; CGFloat markX = MAX(0, MIN(_selection.origin.x, self.bounds.size.width - markW)); @@ -547,28 +562,61 @@ static id nativeOverlayKeyMonitor = nil; return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; } -- (void)confirmSelection { - if (!_hasSelection) { - return; - } - NSRect r = _selection; - [nativeOverlayWindow orderOut:nil]; - if (nativeOverlayKeyMonitor != nil) { - [NSEvent removeMonitor:nativeOverlayKeyMonitor]; - nativeOverlayKeyMonitor = nil; - } - nativeOverlayWindow = nil; - NSString *json = [self annotationsJSON]; - nativeOverlayConfirm((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]); -} - -- (void)cancelSelection { +- (void)closeOverlayWindow { [nativeOverlayWindow orderOut:nil]; if (nativeOverlayKeyMonitor != nil) { [NSEvent removeMonitor:nativeOverlayKeyMonitor]; nativeOverlayKeyMonitor = nil; } nativeOverlayWindow = nil; +} + +- (void)confirmSelection { + if (!_hasSelection) { + return; + } + NSRect r = _selection; + [self closeOverlayWindow]; + NSString *json = [self annotationsJSON]; + 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 = _selection; + [self closeOverlayWindow]; + NSString *json = [self annotationsJSON]; + 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 = _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 closeOverlayWindow]; nativeOverlayCancel(); } @end