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