From 642bbb4ade0202baa9b4c9764d12aebdcea9ce99 Mon Sep 17 00:00:00 2001 From: Amit Date: Fri, 10 Jul 2026 17:25:49 +0800 Subject: [PATCH 01/42] docs: specify UI and execution revamp --- docs/README.md | 3 +- ...07-10-snipit-ui-execution-revamp-design.md | 710 ++++++++++++++++++ 2 files changed, 712 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-07-10-snipit-ui-execution-revamp-design.md diff --git a/docs/README.md b/docs/README.md index 569f1af..e0a053c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,8 @@ snipIT is intentionally a **single-script** product (`SnipIT.ps1`); most of what | Path | What it is | |---|---| -| [`mockups/preview-redesign.html`](mockups/preview-redesign.html) | Standalone HTML mock of the chromeless Fluent preview window — used as the design reference when iterating on the WPF preview chrome. Open it in any browser. | +| [`superpowers/specs/2026-07-10-snipit-ui-execution-revamp-design.md`](superpowers/specs/2026-07-10-snipit-ui-execution-revamp-design.md) | Approved product and engineering specification for the transient-first execution model, global hotkey, capture surfaces, and black-glass Floating Studio Preview. | +| [`mockups/preview-redesign.html`](mockups/preview-redesign.html) | Historical Notion-inspired Preview exploration. It is superseded by the approved UI and execution revamp specification above. | ## Documentation in other places diff --git a/docs/superpowers/specs/2026-07-10-snipit-ui-execution-revamp-design.md b/docs/superpowers/specs/2026-07-10-snipit-ui-execution-revamp-design.md new file mode 100644 index 0000000..36213e5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-snipit-ui-execution-revamp-design.md @@ -0,0 +1,710 @@ +# SnipIT UI and Execution Revamp + +**Status:** Approved design +**Date:** 2026-07-10 +**Scope:** Product-wide interaction shell, capture lifecycle, global hotkey, capture surfaces, Preview editor, and supporting settings UI +**Implementation boundary:** One PowerShell script, PowerShell 7.5+, .NET 9, Windows 11, zero external runtime dependencies, and no administrator elevation + +## 1. Decision summary + +The revamp keeps SnipIT's purpose and distribution model while replacing its presentation and capture orchestration. + +- SnipIT remains a single deliverable: SnipIT.ps1. +- The product is transient-first. It has no persistent main window. +- Ctrl+Alt+Shift+Q is the single default global shortcut and starts Smart capture. +- Other capture modes remain available from the tray and optional capture widget, but receive no default global shortcuts. +- The Preview uses the approved Floating Studio composition and Optical Bench visual language. +- The visual system is neutral near-black translucent glass with a muted champagne-platinum interaction accent. +- Copy & close is the primary completion action. +- The first release is a professional still-image editor. OCR, scrolling capture, recording, history, and cloud features are excluded. +- All capture entry points pass through one serialized runtime coordinator rather than invoking nested capture functions directly. +- Existing cross-platform Core helpers, test gates, per-monitor DPI behavior, capture exclusion, and bitmap-ownership contracts remain stable. + +## 2. Goals + +### Product goals + +1. Make capture feel immediate in Terminal, IntelliJ IDEA, and Visual Studio Code without requiring Print Screen, a number pad, or rarely available single-modifier shortcuts. +2. Keep the captured image visually dominant while offering the complete still-image editing tool set. +3. Make every floating control feel like one coherent product: the same material, border language, curvature, elevation, spacing, and focus behavior. +4. Return to an invisible idle state after completion. +5. Eliminate overlapping capture operations, modal re-entry, silent startup failures, and contradictory bitmap disposal. + +### Engineering goals + +1. Preserve the single-script product feature and avoid external UI frameworks, packages, services, or compiled companion executables. +2. Retain the proven WinForms message pump for NotifyIcon and RegisterHotKey integration. +3. Keep WPF surface construction and editor interaction testable through named closures and Show-PreviewWindow -TestAction. +4. Put pure math, validation, settings normalization, and transition decisions in Core where they can run without Windows. +5. Make resource ownership and state transitions explicit enough to verify with automated tests. + +## 3. Non-goals + +This revamp does not include: + +- OCR or text extraction +- Scrolling or long-page capture +- Screen recording or GIF creation +- Capture history or a gallery +- Cloud upload, collaboration, or account features +- Plugin support +- A C# rewrite, packaged desktop executable, or external UI dependency +- A persistent dashboard or home window +- True per-control Acrylic or Mica blur +- Multiple default global shortcuts + +Generated user state such as settings and diagnostics is permitted. The single-file rule applies to the shipped application code, not files the running application creates under the user's profile. + +## 4. Experience model + +### Idle + +- The console is hidden. +- The tray icon and global hotkey remain active. +- No primary window is visible. +- The edge-reveal capture widget is optional and disabled by default. + +### Capture + +1. Ctrl+Alt+Shift+Q submits a Smart capture request. +2. The coordinator hides registered SnipIT windows, captures the virtual desktop, restores those windows, and opens the selection overlay. +3. Hovering highlights a window. Dragging creates a custom region. Escape or right-click cancels. +4. Full-desktop and active-window capture use the same coordinator and continue directly to Preview. + +Active-window bounds are resolved immediately before each fresh capture. A repeated capture may not reuse coordinates from an earlier window position or size. + +### Preview + +- The captured image fills almost the entire window. +- Floating islands contain only the controls relevant to the current task. +- Editing is non-destructive until copy or save. +- The Preview can be resized, maximized, pinned, zoomed, and panned. + +### Completion + +- The primary button and Ctrl+Enter perform Copy & close. +- The primary button's split menu exposes Copy and keep open. +- Ctrl+C performs Copy and keep open, preserving the conventional clipboard shortcut. +- Save and Ctrl+S open the native Save As dialog and keep Preview open after a successful save. +- Copy closes only after the clipboard operation succeeds. +- Save or copy failure keeps Preview open and presents a recoverable message. +- Ctrl+N submits a new Smart capture request through the coordinator. +- Escape cancels an in-progress edit first. When no edit is active, it closes Preview without output. + +## 5. Visual system + +### 5.1 Direction + +The approved direction is Floating Studio with Optical Bench styling: + +- canvas-first composition +- neutral black substrate without a blue cast +- translucent floating islands +- restrained champagne-platinum interaction color +- coral, mint, amber, and violet reserved for annotations and semantic feedback +- precise typography and spacing suitable for developer workflows + +The UI must not resemble a gaming overlay, an opaque desktop ribbon, or a generic Fluent panel stack. + +### 5.2 Design tokens + +| Token | Value | Use | +|---|---:|---| +| Page black | #030405 | Window substrate and deepest background | +| Canvas black | #090A0C | Empty canvas and editor surround | +| Glass contrast scrim | #CC000000 | Mandatory readability floor behind each island | +| Glass top | #260E1012 | Top overlay stop of simulated glass gradient | +| Glass bottom | #14050608 | Bottom overlay stop of simulated glass gradient | +| Primary text | #F2F5F7 | Main labels and icons | +| Secondary text | #C0C5CA | Metadata and inactive controls | +| Muted text | #9EA4AA | Non-essential coordinate and hint text | +| Hairline | #59FFFFFF | One-DIP glass and control boundary | +| Hover fill | #1FFFFFFF | Neutral hover state | +| Accent | #D8C8A5 | Active tools, focus, primary-action tint | +| Accent text | #F3EAD7 | Text on accent-tinted controls | +| Coral | #FF8069 | Annotation color and destructive hover | +| Mint | #A8EFD7 | Selection geometry and success | +| Amber | #FFD36B | Annotation color and warnings | +| Violet | #BBA1FF | Annotation color | +| Island radius | 16 DIP | Every floating outer island | +| Control radius | 10 DIP | Buttons, selectors, and tool states | +| Outer side offset | 30 DIP | Top-left, top-right, and side anchoring | +| Outer bottom offset | 35 DIP | Bottom-left, dock, and bottom-right anchoring | +| Corner island height | 42 DIP | Top action cluster and both bottom utility islands | +| Tool target | 38 by 38 DIP | Main dock controls | +| Main dock height | 51 DIP | Bottom-center tool island | +| Property island height | 54 DIP | Contextual controls above the dock | + +All floating outer islands use the same 16 DIP radius. The top-right action cluster, bottom-left viewport island, and bottom-right status island also share the same 42 DIP height so their curvature has the same visual silhouette. Save, Copy & close, Pin, and Close use the same 10 DIP inner radius. + +The contrast scrim is mandatory even when the screenshot underneath is pure white. After compositing the scrim and glass gradient, the island background may be no lighter than #303234. Primary and secondary text must retain at least 4.5:1 contrast, and interactive boundaries or focus indicators must retain at least 3:1 contrast, against that worst-case composite. + +Using sRGB alpha composition and rounding to whole channels, the specified tokens produce #2F2F30 over pure white. Against that result, primary text is 12.15:1, secondary text is 7.65:1, the hairline is 3.01:1, and the champagne accent is 8.06:1. + +BorderThickness is 1 DIP, not one physical device pixel. Every glass surface enables UseLayoutRounding and SnapsToDevicePixels so the border remains stable at fractional monitor scale factors. + +### 5.3 WPF-safe glass + +The browser mock uses backdrop blur to communicate the intended depth. The PowerShell/WPF implementation must use a deterministic simulated material: + +1. the mandatory black contrast scrim +2. a translucent near-black vertical gradient +3. a one-DIP translucent white border +4. a subtle inner top highlight +5. a soft black drop shadow + +Do not apply BlurEffect to the entire island or depend on Mica/Acrylic behind an AllowsTransparency window. Those approaches either blur the control itself or are incompatible with the current chromeless transparent-window model. If a future implementation adopts compatible native window chrome, backdrop support can be reconsidered separately. + +### 5.4 Typography and motion + +- Segoe UI Variable Text is the default UI face. +- Segoe UI Variable Display is used only for large headings in onboarding or About. +- Cascadia Code is used for coordinates, zoom, dimensions, and shortcut labels. +- Transitions last 120 to 160 milliseconds and affect opacity or translation only. +- No essential state depends on animation. +- When system animations are disabled, state changes are immediate. + +## 6. Surface design + +### 6.1 Smart capture overlay + +Smart capture uses one borderless WPF overlay window per monitor rather than one DPI context stretched across the virtual desktop. A shared overlay session coordinates them as one capture surface. + +- The shared session owns the frozen virtual-desktop bitmap, global physical-pixel drag origin, current pointer, current selection, hovered HWND, result, and cancellation state. +- Each monitor overlay is positioned from that monitor's physical bounds using that monitor's pixel-to-DIP transform and displays the matching crop of the frozen desktop. +- Input from every monitor window delegates to the same named session closures. +- The overlay where a drag starts captures the pointer. Global pointer coordinates continue through Win32 GetCursorPos, so a drag can span monitors. +- Each overlay renders only the intersection of the global physical-pixel selection with its monitor. This gives continuous cross-monitor geometry without a DPI seam. +- A 40 percent black dimmer separates selectable targets. +- Window hover uses a 2 DIP champagne outline and a faint accent fill. +- Drag selection uses a 2 DIP mint outline and live width by height metadata. +- Releasing a valid drag commits the region immediately and opens Preview; the overlay does not add a separate resize-confirmation step. +- A small black-glass instruction island sits at the top center and disappears once a drag begins. +- The loupe is a black-glass island near the pointer with an enlarged nearest-neighbor pixel view, center crosshair, and screen coordinates. +- The loupe moves away from screen edges and the current selection. +- Pointer processing is coalesced to at most one visual update per render frame. Native window lookup, DWM bounds lookup, and loupe crop are not repeated for every raw mouse event. The CompositionTarget.Rendering handler is stored and detached on Completed, UserCancelled, Preempted, Failed, and Shutdown. +- Escape and right-click cancel without creating a bitmap for Preview. + +Every monitor-overlay HWND is registered with the existing own-window registry and removed on close. + +Win32 window bounds, virtual-screen bounds, capture rectangles, and bitmap crops use physical desktop pixels. Each WPF overlay applies the explicit pixel-to-DIP transform for the monitor containing the point it renders. A selection spanning monitors is converted back to virtual-desktop physical pixels before validation and cropping. + +### 6.2 Preview window + +The screenshot is the interface. Window chrome never creates a permanent frame around it. + +Preview has a minimum logical size of 760 by 540 DIP. It opens on the monitor containing the capture center; for a cross-monitor capture, it uses the monitor with the largest captured intersection. Its initial bounds are clamped to that monitor's work area. A six-DIP invisible edge and corner hit region supports resizing through native non-client hit testing. Dragging the brand island moves the window; double-clicking it toggles maximize and restore. Maximized bounds respect the monitor work area. Alt+Space opens the system menu, Alt+F4 closes, and Windows snap shortcuts remain available. + +#### Top-left brand island + +- SnipIT mark +- image dimensions +- current zoom +- drag affordance + +The brand island is informational and visually quieter than the tool islands. + +#### Top-right action island + +In order: + +1. Pin +2. Save +3. Copy & close with split affordance +4. Close + +All buttons share the same height, inner radius, border, and neutral glass fill. Copy & close receives a translucent accent tint rather than an opaque light fill. Close gains a coral tint only on hover or keyboard focus. + +#### Context property island + +This island appears above the tool dock and changes with the active tool. + +- Select: position, size, duplicate, delete +- Crop: aspect preset, reset, apply +- Pen and Highlight: color, width, opacity +- Arrow and Line: color, width, opacity, start style, end style +- Rectangle and Ellipse: stroke color, fill, width, opacity +- Text: color, font size, weight, background +- Steps: color, size, sequence reset +- Blur and Pixelate: mode, strength, feathering + +When Select has no selection and no drawing tool is active, the island collapses. + +#### Bottom-center tool dock + +The complete still-image tool set is: + +1. Select +2. Crop +3. Pen +4. Highlight +5. Arrow or Line +6. Rectangle or Ellipse +7. Text +8. Numbered Steps +9. Blur or Pixelate +10. Color +11. Undo +12. Redo + +Split tools retain the most recently used subtype. Active state uses champagne tint, a one-DIP accent border, and a short indicator line. A narrow window moves secondary choices into a More menu rather than removing functionality. + +#### Responsive modes + +Responsive mode is selected from Preview's ActualWidth and ActualHeight, in order: + +| Mode | Trigger | Tool dock | Property island | Corner islands | +|---|---|---|---|---| +| Narrow | Width below 900 DIP or height below 600 DIP | Raised to a 96 DIP bottom offset; shows Select, Crop, Pen, Text, Blur or Pixelate, More, Undo, and Redo | Raised above the dock; labels collapse and secondary fields move to More Properties | Bottom-left shows zoom and Fit; bottom-right is icon-only while idle and expands for messages | +| Compact | Width below 1200 DIP or height below 700 DIP | Remains on the bottom baseline; always shows Select, Crop, Pen, Text, Blur or Pixelate, Color, Undo, Redo, More, plus two most-recent annotation tools | Labels collapse; secondary fields move to More Properties | Both retain their complete content | +| Wide | Width at least 1200 DIP and height at least 700 DIP | Shows the complete tool set inline | Shows the complete active-tool property set inline | Both retain their complete content | + +In Compact mode, the two recent slots draw from Highlight, Arrow or Line, Rectangle or Ellipse, and Steps. In Narrow mode those tools remain in More. If the active tool is not visible, More displays that tool's icon, accent state, and name rather than appearing inactive. More Properties uses ordered groups from the active tool and never horizontally clips or scrolls the property island. + +The bottom-left and bottom-right islands remain 42 DIP high with 16 DIP radii in every mode. Mode changes occur without animation while the user is actively resizing. + +#### Split and overflow controls + +WPF has no stock SplitButton, so every split action is one documented composite control: + +- an outer 10 DIP Border contains a primary Button and a separate options Button +- primary click, Enter, or Space invokes the displayed default +- clicking the options region or pressing Alt+Down opens the associated menu +- Arrow keys, Home, End, Enter, and Escape operate the open menu +- closing a menu returns focus to the region that opened it +- Arrow or Line, Rectangle or Ellipse, and Blur or Pixelate primary regions activate the most recently used subtype +- Copy & close primary performs Copy & close; its options region exposes Copy and keep open +- More is a menu button rather than a split action + +The primary and options regions are native WPF Button controls, separate tab stops, and have distinct automation names. Their ContextMenu entries use native MenuItem automation. The options and More buttons update AutomationProperties.ItemStatus between Collapsed and Expanded, and a split tool includes its selected subtype in its accessible name. + +Popup and ContextMenu can create independent HWNDs. Their Opened handlers register the resolved HwndSource, their Closed handlers unregister it, and the capture coordinator closes all transient menus before hiding SnipIT windows and taking a desktop snapshot. + +#### Bottom-left viewport island + +- image-space x and y coordinates +- zoom out +- current zoom percentage +- zoom in +- Fit + +It uses the exact 42 DIP height and 16 DIP outer radius of the top-right action island. + +#### Bottom-right status island + +- non-destructive editing status during normal work +- short-lived success message after copy or save +- recoverable error message when an action fails + +It uses the same 42 DIP height, 16 DIP outer radius, padding rhythm, border, and elevation as the other corner islands. + +### 6.3 Annotation behavior + +All annotations are stored in image-pixel coordinates and remain independent of viewport zoom. + +| Tool | Behavior | +|---|---| +| Select | Selects the topmost annotation, moves it, exposes resize handles, and supports Delete | +| Crop | Stores a non-destructive crop rectangle with free, original, 1:1, 4:3, and 16:9 presets | +| Pen | Records a smoothed freehand point list | +| Highlight | Records a wide translucent freehand stroke | +| Arrow or Line | Stores start and end points plus endpoint styles | +| Rectangle or Ellipse | Stores bounds, stroke, optional fill, and opacity | +| Text | Opens an inline editor; Enter commits, Shift+Enter inserts a line break, Escape cancels | +| Steps | Places an automatically incremented numbered marker that can be moved or renumbered | +| Blur or Pixelate | Stores a rectangular privacy region and generates a cached preview patch | + +Selection handles use mint so selection geometry remains visually distinct from the champagne interaction accent. Cyan is not used as a chrome or interaction color. + +Undo records committed content changes: create, delete, move, resize, crop, property changes, clear, and step renumbering. Zoom, pan, hover, selection changes, and open menus do not enter history. A new edit clears the redo stack. + +### 6.4 Input routing precedence + +Preview routes keyboard input by focus scope before applying window commands: + +1. an open Popup, ContextMenu, or split menu owns navigation and Escape +2. an inline text editor or editable property field owns text, selection, clipboard, Space, arrows, Delete, Enter, and Escape +3. an uncommitted drawing or crop draft owns its editing keys +4. a canvas selection owns annotation movement and deletion +5. only then may Preview execute a window-level command + +Consequences: + +- Ctrl+C copies selected text while a text editor or editable property field has focus. +- Ctrl+Shift+C always performs Copy and keep open for the image. +- Ctrl+C performs Copy and keep open when canvas or chrome has focus. +- Ctrl+Enter commits inline text while the text editor has focus; otherwise it performs Copy & close. +- Space pans only while the canvas has focus and no text editor, property editor, menu, or draft owns input. +- Space continues to activate a focused Button through normal WPF behavior. +- Arrow keys and Delete modify an annotation only while the canvas has focus and an annotation is selected. +- Alt+F4 remains an unconditional window-close command. + +Escape resolves one level per press in this order: + +1. close an open menu or popup +2. cancel and revert an inline text or property edit +3. cancel an uncommitted drawing or crop draft +4. clear the current annotation selection +5. deactivate the drawing tool and return to Select +6. close Preview without output + +The window-level PreviewKeyDown handler checks the focused element and editor state before marking an event handled. It must not steal input from WPF text, menu, or button controls. + +### 6.5 Flattening and output + +Flattening uses this order: + +1. crop the source bitmap +2. apply blur and pixelate privacy regions to source pixels +3. render highlight, pen, shapes, arrows, text, and steps +4. encode or copy the final bitmap + +This keeps privacy effects below later annotations so labels and arrows remain crisp. + +All records remain in original-source pixel coordinates. When a crop is active, flattening intersects every privacy region and annotation with the crop, translates output geometry by negative crop X and negative crop Y, clips partially intersecting records, omits records outside the crop, and invalidates any privacy preview cache affected by a crop change. + +The temporary flattened bitmap is always disposed in a finally block. The Preview owns the original capture bitmap and disposes it exactly once when the Preview lifecycle ends. + +### 6.6 Tray, optional widget, Settings, and About + +- NotifyIcon remains WinForms-based for reliable Windows integration. +- The tray menu remains a native ContextMenuStrip but receives an owner-drawn neutral-black palette, matching typography, and champagne active states. It is the deliberate non-glass exception. +- The optional capture widget is disabled by default. When enabled from the tray, it edge-reveals as a small black-glass island containing Smart, Full, Window, and Settings. +- Settings is a compact WPF black-glass window for the global shortcut, default save folder, launch at sign-in, and widget visibility. +- About uses the same resources and contains version, runtime, repository, and license information. +- Every WPF top-level window registers its HWND for capture exclusion. +- The launch-at-sign-in setting is authoritative. The Startup shortcut exists only while that setting is enabled, an existing unconditional shortcut is migrated once, and SNIPIT_TEST_MODE performs no install or shortcut writes. + +## 7. Global hotkey contract + +### Default + +- Modifiers: Control, Alt, Shift, and MOD_NOREPEAT +- Key: Q +- Action: Smart capture +- Display string: Ctrl+Alt+Shift+Q + +The application does not assume the shortcut is available merely because common IDE and terminal keymaps do not claim it. RegisterHotKey is the authoritative availability check on the running machine. + +### Registration behavior + +1. Load and validate the stored shortcut. +2. If none is stored, use Ctrl+Alt+Shift+Q. +3. Attempt registration after the hidden host HWND exists. +4. On success, expose the binding in the tray tooltip, Settings, About, and first-run notification. +5. On failure, keep SnipIT running, leave capture accessible from the tray, log the native failure, and open a clear Settings prompt. Do not silently choose another shortcut. + +Changing the shortcut is atomic: + +1. validate the candidate +2. unregister the old binding +3. try the candidate +4. persist only after success +5. restore the old binding if the candidate fails + +Bare modifier keys and Windows-reserved keys are rejected. The recorder stores a normalized modifier bitmask and virtual-key value, not localized display text. + +If rollback registration also fails, SnipIT leaves no global binding, logs the candidate and rollback failures, keeps Settings open, and reports that capture remains available from the tray. It never displays an unregistered shortcut as active. + +No alternate capture mode receives a default global binding. Users may choose a different single Smart-capture binding in Settings. + +Settings are stored at %LOCALAPPDATA%\SnipIT\settings.json using built-in JSON conversion. A missing or malformed file falls back to defaults, is logged, and does not prevent startup. + +| Setting | Default | +|---|---| +| Smart capture hotkey | Ctrl+Alt+Shift+Q | +| Save folder | The user's Pictures\Snips folder | +| Save format | PNG | +| Launch at sign-in | Enabled | +| Edge-reveal widget | Disabled | + +## 8. Runtime architecture + +### 8.1 Host decision + +The WinForms Application.Run message pump remains the long-lived host. It already provides stable NotifyIcon, timer, and WM_HOTKEY behavior. WPF continues to render the application surfaces on the same STA thread. + +The revamp does not attempt to create a second thread, a second process, or a System.Windows.Application singleton. + +### 8.2 Central coordinator + +All entry points call one request function. Hotkeys, tray commands, widget buttons, delayed capture, New snip, and capture-mode changes may not call capture functions directly. + +The coordinator owns: + +- Phase +- ActiveRequest +- PendingRequest +- ActiveSurface +- PreviewWindow +- Settings +- RegisteredHotkey +- ShutdownRequested +- own-window registry + +Phases are: + +1. Idle +2. DelayPending +3. CaptureStarting +4. Selecting +5. Previewing +6. Completing +7. Recovering +8. Auxiliary +9. ShuttingDown + +Only one capture request is active. One pending slot is allowed and the latest request wins. + +Every modal surface returns one of Completed, UserCancelled, Preempted, Failed, or Shutdown. The coordinator, rather than the surface, maps that result to the next phase. + +| Current phase | New capture request | Normal completion | Cancellation or failure | +|---|---|---|---| +| Idle | Becomes ActiveRequest; enter CaptureStarting | Not applicable | Not applicable | +| DelayPending | Cancel the timer; newest request becomes ActiveRequest; enter CaptureStarting | Timer due enters CaptureStarting | User cancellation returns to Idle | +| CaptureStarting | Replace PendingRequest; allow the current hide, snapshot, and restore transaction to reach cleanup | Enter Selecting for Smart or Previewing for direct capture | Dispose coordinator-owned resources; enter Recovering | +| Selecting | Replace PendingRequest; signal the overlay to return Preempted | Valid selection enters Previewing | UserCancelled returns to Idle; Failed enters Recovering | +| Previewing | Replace PendingRequest; signal Preview to return Preempted | Completion enters Completing or close returns to Idle | Failed enters Recovering | +| Completing | Replace PendingRequest and finish the current copy, save, or dialog without interruption | Copy & close returns to Idle; Save returns to Previewing | Recoverable output failure returns to Previewing | +| Recovering | Replace PendingRequest while cleanup finishes | After cleanup, start PendingRequest or return to Idle | Fatal failure enters ShuttingDown | +| Auxiliary | Replace PendingRequest; close Settings or About with Preempted | Closing the auxiliary surface returns to Idle | Failure enters Recovering | +| ShuttingDown | Reject the request | Remain ShuttingDown | Remain ShuttingDown | + +After every cleanup path, the coordinator schedules PendingRequest through BeginInvoke rather than calling it recursively. If several requests arrive, only the latest remains pending. A successful Save normally returns to Previewing; when a PendingRequest exists, Preview instead closes after the save completes and the pending request starts only after Preview has disposed its resources. + +PendingRequest is checked before the coordinator opens any next surface. If CaptureStarting produces a bitmap after a newer request is pending, the coordinator disposes that still-owned bitmap and schedules the pending request instead of opening a stale Preview. + +The current WinForms hotkey window continues to marshal WM_HOTKEY work with BeginInvoke. No WPF work is performed inside WndProc. + +### 8.3 WPF surface boundaries + +Modal WPF surfaces remain acceptable for this revamp, but host-level hotkey delivery through their nested dispatchers is a required verification target rather than an assumed contract. The coordinator owns what happens after ShowDialog returns, preventing direct capture-to-capture nesting. + +Within SnipIT.ps1, responsibilities are separated into same-file functions and regions: + +- Core pure helpers +- Bootstrap and diagnostics +- P/Invoke and system integration +- Theme and shared resource construction +- Settings and hotkey normalization +- Capture coordinator +- Capture bitmap primitives +- Smart overlay +- Preview shell and context +- Annotation rendering and flattening +- Widget, tray, Settings, and About +- Main loop and cleanup + +Preview uses a context object for controls, editor state, history, and resource ownership. Named interaction closures are created when the window is built, and thin event wrappers call those closures. Script-scoped helper functions must not depend on function-local Preview variables. + +Show-PreviewWindow retains the -TestAction hook and exposes the context plus named closures required by the interactive harness. + +## 9. Data and ownership contracts + +### Capture request + +A request contains: + +- unique identifier +- capture mode +- optional delay +- source +- submission time + +### Preview context + +The Preview context contains: + +- original bitmap +- frozen BitmapSource +- crop rectangle +- annotation list +- selected annotation identifier +- active tool and subtype +- tool properties +- draft interaction +- undo and redo stacks +- zoom and pan state +- completion result +- resolved controls and named closures + +### Annotation record + +Every record has: + +- stable identifier +- kind +- image-space geometry +- color +- stroke width +- opacity +- kind-specific properties +- z-order + +Blur and pixelate records also own a cached preview patch. Cached patches are disposed when replaced, removed, or when Preview closes. + +### Bitmap ownership + +1. Every capture factory creates a fresh System.Drawing.Bitmap. +2. Once passed to Preview, Preview owns that bitmap. +3. The caller never disposes it afterward. +4. Preview disposes it once in its close path. +5. Temporary crops, loupe images, cached privacy patches, and flattened outputs are protected by try/finally. +6. Cancellation before Preview disposes any bitmap still owned by the coordinator. +7. Smart overlay owns the frozen desktop snapshot until it returns and disposes it on Completed, UserCancelled, Preempted, Failed, and Shutdown. +8. Ownership of a cropped or direct-capture bitmap transfers only after Preview has installed its close cleanup. If Preview construction or opening fails before that point, the coordinator retains and disposes the bitmap. + +The current Smart-capture double-dispose is removed as part of the coordinator migration. + +## 10. Error handling and diagnostics + +Initialization is enclosed by the global error boundary before the console is hidden. Startup failures must not disappear. + +- Diagnostics path: %LOCALAPPDATA%\SnipIT\logs\snipit.log +- Logs include timestamp, phase, request identifier, operation, exception type, and message. +- User-facing messages are concise and omit stack traces. +- Hotkey registration failure is recoverable and routes to Settings. +- Capture failure disposes coordinator-owned resources, reports through the tray, and returns to Idle. +- Clipboard copy retries three times with 50, 100, and 200 millisecond delays when the clipboard is temporarily busy. +- Clipboard failure leaves Preview open. +- Save failure leaves Preview open and preserves all edits. +- A delayed request can be cancelled before capture begins. +- Fatal shutdown always unregisters the hotkey and disposes the tray icon, host form, active surfaces, owned bitmaps, timers, and mutex. + +## 11. Accessibility and input + +- Every actionable control receives an AutomationProperties.Name. +- Tooltips include the action and shortcut where one exists. +- Keyboard focus is a visible 2 DIP champagne outline and never relies only on fill color. +- Swatches are buttons, keyboard reachable, and announce their color names and selected state. +- Active tools use icon color, fill, and indicator line rather than color alone. +- Tab order follows the visual order of each island. +- Arrow keys move a selected annotation by one image pixel; Shift+Arrow moves it by ten. +- Delete removes a selected annotation. +- Space temporarily pans while held. +- Ctrl+mouse-wheel zooms around the pointer. +- Ctrl+plus, Ctrl+minus, and Ctrl+0 zoom in, out, and fit. +- High Contrast mode replaces custom translucent fills with system brushes and disables shadows. +- Reduced-motion settings disable island entrance and toast animations. + +## 12. Testing strategy + +The observed design baseline is 111 passing headless assertions and 42 passing interactive WPF tests. The revamp may change test organization, but it may not reduce behavior coverage. + +### Core tests + +Add pure tests for: + +- hotkey parsing, normalization, validation, and display formatting +- settings defaulting and malformed-data recovery +- complete coordinator transition decisions for every phase and surface result +- latest-pending-request behavior +- annotation bounds and crop transforms +- monitor-specific physical-pixel and DIP transforms +- responsive-mode selection from width and height +- worst-case glass, text, boundary, and focus contrast composition +- step numbering +- privacy-region output bounds + +### Interactive WPF tests + +Preserve the existing -TestAction approach and cover: + +- shared theme resources and exact radius tokens +- glass contrast over pure-white and pure-black image backgrounds +- corner island height and radius consistency +- keyboard focus and automation names +- Wide, Compact, and Narrow layout content, overflow, and collision rules +- input-routing precedence for menus, text, properties, drafts, canvas, and window commands +- split-button focus, primary action, options action, menu navigation, and automation state +- chromeless resize, maximize, restore, and system-command behavior +- tool and subtype selection +- property-island changes +- Select move and resize +- Crop presets and output +- Pen and Highlight point capture +- Arrow, Line, Rectangle, and Ellipse +- multiline text editing +- numbered Steps +- Blur and Pixelate preview and flattening +- undo and redo boundaries +- Copy & close versus Copy and keep open +- recoverable clipboard and save failures + +### Coordinator and ownership tests + +Use injected capture, Preview, and output factories to verify: + +- all entry points route through the coordinator +- only one active request exists +- latest pending request wins +- transitions occur after cleanup rather than recursion +- Preview receives a fresh bitmap +- each bitmap is disposed exactly once +- every top-level window joins capture exclusion +- every monitor overlay shares one session while using its own DPI transform +- every Popup and ContextMenu HWND is registered while open and closed before capture +- every CompositionTarget.Rendering subscription is detached on all surface results +- delayed requests can be cancelled +- hotkey replacement persists only on success and reports rollback failure accurately +- SNIPIT_TEST_MODE performs no installation or shortcut writes +- injected failures before and after console hiding reach diagnostics and deterministic cleanup +- shutdown cleans every native registration +- a Windows host integration test can post WM_HOTKEY while Overlay, Preview, Save As, Settings, and About are modal, and can prove that cleanup finishes before the next capture starts + +### Manual Windows matrix + +Before release, verify: + +- one, two, and three monitors +- negative virtual-screen origin +- mixed 100, 125, 150, and 200 percent scale +- IntelliJ IDEA, Visual Studio Code, Windows Terminal, and a full-screen application +- High Contrast and reduced motion +- default hotkey available and occupied +- clipboard busy and read-only save destination +- pure-white and high-detail screenshots beneath every glass island +- Preview at 1200 by 700, 1199 by 699, 900 by 600, 899 by 599, and the 760 by 540 minimum +- edge and corner resize, maximize, restore, Alt+Space, Alt+F4, and Windows snap + +All repository quality gates remain required: headless tests, interactive tests, AST parse, PSScriptAnalyzer Error severity, Trivy, Semgrep, Gitleaks, jscpd below 3 percent, and signed commits. + +## 13. Delivery sequence + +The implementation plan should preserve a runnable application at each phase: + +1. shared theme tokens, Settings storage, launch-at-sign-in migration, and the new hotkey +2. serialized coordinator, startup boundary, diagnostics, and ownership fixes +3. Smart overlay, tray styling, and optional widget +4. Floating Studio Preview shell with existing annotation behavior +5. Select, Crop, Pen, Steps, Blur, Pixelate, and complete property controls +6. accessibility, responsive behavior, error states, documentation, and release verification + +New behavior is introduced behind the same public launch command. No parallel legacy application or second script is created. + +## 14. Acceptance criteria + +The revamp is complete when: + +1. Ctrl+Alt+Shift+Q starts Smart capture on a compact keyboard without Print Screen or a number pad. +2. An occupied shortcut produces a clear recoverable Settings path and does not stop the tray application. +3. Idle SnipIT has no visible main window. +4. Every capture source routes through one coordinator, and two capture surfaces cannot overlap. +5. Preview matches the approved neutral-black champagne-glass direction. +6. Every floating outer island uses a 16 DIP radius. +7. The top-right, bottom-left, and bottom-right corner islands share a 42 DIP height and matching curvature. +8. Save, Copy & close, and Close share the same inner radius and material family. +9. Select, Crop, Pen, Highlight, Arrow or Line, Rectangle or Ellipse, Text, Steps, Blur or Pixelate, color, undo, and redo are available. +10. Copy & close is primary; Copy and keep open remains available. +11. Crop, privacy effects, and annotations flatten in the specified order. +12. A capture bitmap is disposed exactly once. +13. All SnipIT top-level windows are excluded from capture. +14. Mixed-DPI and negative-origin virtual desktops remain accurate. +15. Startup, capture, clipboard, save, and hotkey failures are observable and recoverable where specified. +16. The application remains SnipIT.ps1 with no external runtime dependency and no administrator requirement. +17. Existing quality gates pass and the new coordinator, tools, geometry, accessibility, and ownership contracts have automated coverage. +18. Glass text retains 4.5:1 contrast and interactive boundaries or focus retain 3:1 contrast over a pure-white capture. +19. Wide, Compact, and Narrow layouts expose every action without overlap, clipping, or horizontal scrolling. +20. A cross-monitor drag remains aligned in physical pixels across mixed-DPI monitor seams. +21. Text fields, menus, property editors, drafts, and canvas selections receive keyboard input in the specified precedence order. +22. Popup and ContextMenu windows cannot appear in a new capture. From 740012210ce944edbaa54633710c7958a8699d29 Mon Sep 17 00:00:00 2001 From: Amit Date: Fri, 10 Jul 2026 18:00:04 +0800 Subject: [PATCH 02/42] docs: add UI execution implementation plan --- .../2026-07-10-snipit-ui-execution-revamp.md | 847 ++++++++++++++++++ 1 file changed, 847 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md diff --git a/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md b/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md new file mode 100644 index 0000000..22122c2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md @@ -0,0 +1,847 @@ +# SnipIT UI and Execution Revamp Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox - [ ] syntax for tracking. + +**Goal:** Deliver the approved transient-first SnipIT experience with Ctrl+Alt+Shift+Q, serialized capture execution, exact-once bitmap ownership, per-monitor capture overlays, and the black-champagne Floating Studio editor. + +**Architecture:** Keep the WinForms message pump and NotifyIcon as the long-lived host, but route every capture source through one coordinator. Keep all application code in SnipIT.ps1 while creating explicit same-file boundaries for pure decisions, settings, hotkey registration, capture surfaces, Preview context, annotation rendering, output, and cleanup. Preserve named Preview closures and test hooks while expanding them into stable injected interfaces. + +**Tech Stack:** PowerShell 7.5+, .NET 9, WPF, WinForms, System.Drawing, embedded Win32 P/Invoke, JSON settings, and the repository's dependency-free PowerShell test harnesses. + +## Global Constraints + +- SnipIT.ps1 remains the only shipped application-code file. +- Windows 11, PowerShell 7.5+, and .NET 9 remain the runtime floor. +- Add no external runtime dependency, package manager, service, executable, or administrator requirement. +- The approved design specification at docs/superpowers/specs/2026-07-10-snipit-ui-execution-revamp-design.md is authoritative. +- Ctrl+Alt+Shift+Q is the only default global shortcut; it invokes Smart capture. +- All floating outer islands use 16 DIP radius. Top-right, bottom-left, and bottom-right islands use 42 DIP height. Inner controls use 10 DIP radius. +- UI chrome uses neutral black, #CC000000 contrast scrim, and #D8C8A5 accent. Cyan is not a chrome accent. +- Capture geometry and annotation storage use physical image pixels. WPF rendering converts explicitly to monitor-local DIP. +- Preview owns a capture bitmap only after it installs close cleanup, and disposes it exactly once. +- Preserve CoreOnly, SNIPIT_TEST_MODE, named Preview closures, Show-PreviewWindow -TestAction, self-window exclusion, negative-origin desktop support, and per-monitor DPI. +- Preserve the user's untracked AGENTS.md file and any unrelated worktree changes. +- Every task follows red-green-refactor and ends with a signed commit. + +--- + +## File map + +| File | Responsibility in this plan | +|---|---| +| SnipIT.ps1 | All production logic and UI: Core, settings, diagnostics, P/Invoke, coordinator, capture, surfaces, editor, output, tray, and cleanup | +| Test-SnipIT.ps1 | Cross-platform pure tests for tokens, hotkeys, transitions, responsive rules, input routing, transforms, annotations, and crop math | +| Test-SnipIT-Interactive.ps1 | Windows STA WPF tests for surfaces, theme resources, editor closures, tools, output, accessibility, and resource cleanup | +| Test-SnipIT-Host.ps1 | Windows STA host integration tests for WM_HOTKEY, modal surface arbitration, coordinator sequencing, and shutdown | +| .github/workflows/test.yml | Add the Windows host harness while retaining Linux/Windows pure tests, WPF tests, and AST parse | +| README.md | Update hotkeys, editor features, execution model, commands, and verified test totals | +| docs/README.md | Link the implementation plan and keep the approved specification discoverable | + +The specification spans several surfaces, but they share the same coordinator, theme, editor context, and single production file. One sequential plan prevents interface drift; Tasks 1 through 3 form the runtime foundation, Tasks 4 through 6 form the shell, and Tasks 7 through 10 complete editing and release verification. + +--- + +### Task 1: Pure contracts for theme, settings, layout, input, geometry, and coordinator decisions + +**Files:** +- Modify: SnipIT.ps1, Core region at current lines 16-317 +- Modify: Test-SnipIT.ps1 after the current capture-loop groups + +**Interfaces:** +- Produces: Get-SnipThemeTokens +- Produces: Get-SnipDefaultSettings -PicturesDir string +- Produces: Test-SnipHotkeyDefinition -Modifiers int -VirtualKey int +- Produces: Format-SnipHotkey -Modifiers int -VirtualKey int +- Produces: Get-PreviewResponsiveMode -Width double -Height double +- Produces: Resolve-PreviewKeyCommand -FocusedRole string -EditorState hashtable -Key string -Modifiers string[] +- Produces: Get-SnipCoordinatorDecision -Phase string -Event string -HasPending bool +- Produces: ConvertTo-SnipCropLocalRect -Rectangle object -Crop object +- Produces: ConvertTo-SnipDipPoint and ConvertTo-SnipPhysicalPoint +- Produces: Get-SnipContrastRatio -Foreground string -Background string + +- [ ] **Step 1: Add failing pure tests** + +Add focused groups with exact expected values: + +~~~powershell +Describe 'Approved theme and settings contracts' +It 'returns the locked black champagne tokens' { + $t = Get-SnipThemeTokens + ShouldBe $t.PageBlack '#030405' + ShouldBe $t.GlassScrim '#CC000000' + ShouldBe $t.Accent '#D8C8A5' + ShouldBe $t.IslandRadius 16 + ShouldBe $t.ControlRadius 10 + ShouldBe $t.CornerIslandHeight 42 + ShouldBe (Get-SnipContrastRatio -Foreground $t.SecondaryText -Background '#2F2F30') 7.65 + ShouldBe (Get-SnipContrastRatio -Foreground $t.Hairline -Background '#2F2F30') 3.01 +} +It 'defaults to one Smart capture binding on Q' { + $s = Get-SnipDefaultSettings -PicturesDir 'C:\Pictures' + ShouldBe $s.Hotkey.Modifiers 0x4007 + ShouldBe $s.Hotkey.VirtualKey 0x51 + ShouldBe (Format-SnipHotkey -Modifiers $s.Hotkey.Modifiers -VirtualKey $s.Hotkey.VirtualKey) 'Ctrl+Alt+Shift+Q' + ShouldBe $s.WidgetVisible $false +} + +Describe 'Preview responsive contract' +It 'selects all exact boundary modes' { + ShouldBe (Get-PreviewResponsiveMode 1200 700) 'Wide' + ShouldBe (Get-PreviewResponsiveMode 1199 700) 'Compact' + ShouldBe (Get-PreviewResponsiveMode 1200 699) 'Compact' + ShouldBe (Get-PreviewResponsiveMode 900 600) 'Compact' + ShouldBe (Get-PreviewResponsiveMode 899 700) 'Narrow' + ShouldBe (Get-PreviewResponsiveMode 1200 599) 'Narrow' +} + +Describe 'Preview key routing contract' +It 'gives text editors ownership before image commands' { + $state = @{ PopupOpen=$false; EditingText=$true; Draft=$null; SelectionId=$null; ActiveTool='Text' } + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole TextEditor -EditorState $state -Key C -Modifiers Ctrl) 'TextCopy' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole TextEditor -EditorState $state -Key Enter -Modifiers Ctrl) 'CommitText' +} +It 'routes canvas image completion commands' { + $state = @{ PopupOpen=$false; EditingText=$false; Draft=$null; SelectionId=$null; ActiveTool='Select' } + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $state -Key C -Modifiers Ctrl) 'CopyKeepOpen' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $state -Key Enter -Modifiers Ctrl) 'CopyAndClose' +} +~~~ + +Add table-driven assertions for every phase/event pair in specification section 8.2, mixed-DPI point round trips, and crop-local clipping. + +- [ ] **Step 2: Run the pure suite and confirm the new groups fail** + +Run: pwsh -NoProfile -File .\Test-SnipIT.ps1 + +Expected: existing 111 assertions pass; new groups fail because the new functions do not exist. + +- [ ] **Step 3: Add the minimal pure implementations** + +Use locked constants and deterministic decisions: + +~~~powershell +function Get-SnipThemeTokens { + [pscustomobject]@{ + PageBlack='#030405'; CanvasBlack='#090A0C'; GlassScrim='#CC000000' + GlassTop='#260E1012'; GlassBottom='#14050608' + PrimaryText='#F2F5F7'; SecondaryText='#C0C5CA'; MutedText='#9EA4AA' + Hairline='#59FFFFFF'; HoverFill='#1FFFFFFF'; Accent='#D8C8A5' + AccentText='#F3EAD7'; Coral='#FF8069'; Mint='#A8EFD7' + Amber='#FFD36B'; Violet='#BBA1FF' + IslandRadius=16; ControlRadius=10; CornerIslandHeight=42 + ToolTarget=38; MainDockHeight=51; PropertyIslandHeight=54 + } +} + +function Get-PreviewResponsiveMode { + param([double]$Width, [double]$Height) + if ($Width -lt 900 -or $Height -lt 600) { return 'Narrow' } + if ($Width -lt 1200 -or $Height -lt 700) { return 'Compact' } + 'Wide' +} + +function Get-SnipContrastRatio { + param([string]$Foreground, [string]$Background) + function Get-RelativeLuminance { + param([string]$Hex) + $h = $Hex.TrimStart('#') + $channels = 0,2,4 | ForEach-Object { + $v = [Convert]::ToInt32($h.Substring($_,2),16) / 255.0 + if ($v -le 0.03928) { $v / 12.92 } else { [math]::Pow(($v + 0.055) / 1.055, 2.4) } + } + 0.2126*$channels[0] + 0.7152*$channels[1] + 0.0722*$channels[2] + } + $a = Get-RelativeLuminance $Foreground + $b = Get-RelativeLuminance $Background + [math]::Round(([math]::Max($a,$b)+0.05)/([math]::Min($a,$b)+0.05),2) +} + +function Get-SnipDefaultSettings { + param([string]$PicturesDir = [Environment]::GetFolderPath('MyPictures')) + [pscustomobject]@{ + Version=1 + Hotkey=[pscustomobject]@{ Modifiers=0x4007; VirtualKey=0x51 } + SaveFolder=(Join-Path $PicturesDir 'Snips') + SaveFormat='Png' + LaunchAtSignIn=$true + WidgetVisible=$false + } +} +~~~ + +Implement the full phase/event switch and keyboard precedence exactly as specified; unknown values throw ArgumentException rather than silently falling through. + +- [ ] **Step 4: Run the pure suite and AST parse** + +Run: + +~~~powershell +pwsh -NoProfile -File .\Test-SnipIT.ps1 +pwsh -NoProfile -Command '$t=$null;$e=$null;[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path .\SnipIT.ps1),[ref]$t,[ref]$e)|Out-Null;if($e){$e;exit 1}' +~~~ + +Expected: all pure assertions pass and AST parse exits 0. + +- [ ] **Step 5: Commit** + +~~~powershell +git add SnipIT.ps1 Test-SnipIT.ps1 +git commit -S -m "feat: add revamp core contracts" +~~~ + +--- + +### Task 2: Durable settings, diagnostics, Startup synchronization, and atomic hotkey registration + +**Files:** +- Modify: SnipIT.ps1, Bootstrap, First-Run Install, and Tray + Hotkeys regions +- Modify: Test-SnipIT-Interactive.ps1 before the Preview test invocation + +**Interfaces:** +- Consumes: Get-SnipDefaultSettings, Test-SnipHotkeyDefinition, Format-SnipHotkey +- Produces: Get-SnipSettingsPath, Read-SnipSettings, Save-SnipSettings +- Produces: Sync-SnipStartupShortcut -Settings object -Paths object +- Produces: Register-SnipHotkeyBinding and Set-SnipHotkeyBinding with injected native delegates +- Produces: Write-SnipDiag with durable file logging + +- [ ] **Step 1: Add failing Windows settings and hotkey tests** + +Use a fresh temporary directory and injected register delegates: + +~~~powershell +Describe 'Settings persistence and hotkey replacement' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-settings-' + [guid]::NewGuid()) + $settingsPath = Join-Path $root 'settings.json' + $pictures = Join-Path $root 'Pictures' + New-Item -ItemType Directory -Force -Path $root,$pictures | Out-Null + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings -PicturesDir $pictures + SettingsPath = $settingsPath + RegisteredHotkey = [pscustomobject]@{ Modifiers=0x4007; VirtualKey=0x51 } + } + It 'recovers defaults from malformed JSON' { + Set-Content -LiteralPath $settingsPath -Value '{bad' + $result = Read-SnipSettings -Path $settingsPath -PicturesDir $pictures + Should-Be $result.Hotkey.VirtualKey 0x51 + Should-Be $result.Hotkey.Modifiers 0x4007 + } + It 'does not persist a rejected replacement binding' { + $calls = [System.Collections.ArrayList]::new() + $register = { param($hwnd,$id,$mods,$vk) [void]$calls.Add($vk); $vk -ne 0x53 } + $result = Set-SnipHotkeyBinding -Context $context -Candidate @{Modifiers=0x4007;VirtualKey=0x53} -Register $register -Unregister { $true } + Should-BeFalse $result.Success + Should-Be $context.Settings.Hotkey.VirtualKey 0x51 + } +} +~~~ + +Add tests for successful persistence, rollback success, rollback failure, default widget false, Startup shortcut create/remove, and SNIPIT_TEST_MODE making no shortcut writes. + +- [ ] **Step 2: Run the interactive suite and verify red** + +Run: pwsh -NoProfile -Sta -File .\Test-SnipIT-Interactive.ps1 + +Expected: the new settings/hotkey groups fail before Preview opens. + +- [ ] **Step 3: Implement settings, logging, and one hotkey** + +- Store settings at %LOCALAPPDATA%\SnipIT\settings.json through ConvertTo-Json and ConvertFrom-Json. +- Write via a sibling temporary file, then Move-Item atomically. +- Normalize every loaded property against Get-SnipDefaultSettings. +- Move the global try/catch boundary above STA/bootstrap side effects and append diagnostics to %LOCALAPPDATA%\SnipIT\logs\snipit.log. +- Make LaunchAtSignIn authoritative; migrate the existing unconditional Startup shortcut once. +- Register only hotkey ID 1 with modifiers 0x4007 and VK 0x51. +- Persist a candidate only after registration succeeds; restore the previous binding on failure; report rollback failure without claiming a binding. +- Keep tray capture commands available when registration fails. + +The atomic result object is: + +~~~powershell +[pscustomobject]@{ + Success = $success + ActiveBinding = $activeBinding + CandidateError = $candidateError + RollbackError = $rollbackError +} +~~~ + +- [ ] **Step 4: Run pure and interactive suites** + +Run: + +~~~powershell +pwsh -NoProfile -File .\Test-SnipIT.ps1 +pwsh -NoProfile -Sta -File .\Test-SnipIT-Interactive.ps1 +~~~ + +Expected: all assertions pass; no settings or shortcut file is created outside each test's temporary path. + +- [ ] **Step 5: Commit** + +~~~powershell +git add SnipIT.ps1 Test-SnipIT-Interactive.ps1 +git commit -S -m "feat: persist settings and register the default hotkey" +~~~ + +--- + +### Task 3: Serialized capture coordinator and exact-once bitmap ownership + +**Files:** +- Modify: SnipIT.ps1, Capture Orchestration at current lines 2240-2353, hotkey callback, tray handlers, widget handlers, and Preview close setup +- Modify: Test-SnipIT.ps1 for transition tests +- Modify: Test-SnipIT-Interactive.ps1 for injected coordinator/ownership tests + +**Interfaces:** +- Consumes: Get-SnipCoordinatorDecision +- Produces: New-SnipCaptureRequest, New-SnipCaptureCoordinator +- Produces: Request-SnipCapture, Invoke-SnipCapturePump, Complete-SnipSurface +- Produces: Close-SnipActiveSurface, Stop-SnipCaptureCoordinator +- Produces: Invoke-SnipPreviewTransfer -Bitmap object -Preview scriptblock +- Changes: Show-PreviewWindow accepts OnOwnershipAccepted and returns Completed, UserCancelled, Preempted, Failed, or Shutdown + +- [ ] **Step 1: Add failing coordinator and ownership tests** + +Cover all sources and exact disposal: + +~~~powershell +It 'keeps only the latest pending request' { + $c = New-SnipCaptureCoordinator -Post { param($work) & $work } + Request-SnipCapture -Coordinator $c -Mode Smart -Source Hotkey + $c.Phase = 'Previewing' + Request-SnipCapture -Coordinator $c -Mode Full -Source Tray + Request-SnipCapture -Coordinator $c -Mode Window -Source Widget + Should-Be $c.PendingRequest.Mode 'Window' +} + +It 'transfers a bitmap only after Preview installs cleanup' { + $capture = [pscustomobject]@{ DisposeCount=0 } + $capture | Add-Member -MemberType ScriptMethod -Name Dispose -Value { $this.DisposeCount++ } + $result = Invoke-SnipPreviewTransfer -Bitmap $capture -Preview { + param($bitmap,$accept) + & $accept + 'Completed' + } + Should-Be $capture.DisposeCount 0 + Should-Be $result.OwnershipTransferred $true +} +~~~ + +Add failure-before-acceptance, cancellation, preemption, output-in-progress, active-window-bounds refresh, delayed-capture cancellation, and no-recursive-start assertions. + +- [ ] **Step 2: Run tests and confirm red** + +Run both existing suites. Expected: new coordinator names and disposal probes fail. + +- [ ] **Step 3: Implement the coordinator and replace direct invocation** + +Coordinator state: + +~~~powershell +[pscustomobject]@{ + Phase='Idle'; ActiveRequest=$null; PendingRequest=$null + ActiveSurface=$null; OwnedBitmap=$null; PreviewWindow=$null + Settings=$settings; RegisteredHotkey=$null + ShutdownRequested=$false; PumpScheduled=$false + Post=$postToHost +} +~~~ + +Every hotkey, tray item, widget button, delay timer, and New snip calls Request-SnipCapture. Invoke-SnipCapturePump is the only function that calls capture factories or surfaces. It schedules continuation through the injected Post delegate after cleanup; it never calls itself recursively. + +For Smart capture, the overlay owns the virtual-desktop snapshot and disposes it on every result. The coordinator owns a returned crop until Preview invokes OnOwnershipAccepted after installing its Closed handler. Delete the current post-Preview $bmp.Dispose() in Invoke-SmartCapture. Resolve active-window HWND and bounds inside each fresh capture transaction. + +- [ ] **Step 4: Run all current tests** + +Run: + +~~~powershell +pwsh -NoProfile -File .\Test-SnipIT.ps1 +pwsh -NoProfile -Sta -File .\Test-SnipIT-Interactive.ps1 +~~~ + +Expected: both suites pass. The interactive suite contains an in-process 100-iteration ownership assertion and reports no ObjectDisposedException or double disposal. + +- [ ] **Step 5: Commit** + +~~~powershell +git add SnipIT.ps1 Test-SnipIT.ps1 Test-SnipIT-Interactive.ps1 +git commit -S -m "refactor: serialize capture execution" +~~~ + +--- + +### Task 4: Shared WPF resources, Settings, About, widget, and tray presentation + +**Files:** +- Modify: SnipIT.ps1, Capture Core utility surfaces, Floating Widget, and Tray regions +- Modify: Test-SnipIT-Interactive.ps1 + +**Interfaces:** +- Consumes: Get-SnipThemeTokens, settings and coordinator request APIs +- Produces: New-SnipThemeResources -HighContrast bool +- Produces: Add-SnipThemeResources -Root FrameworkElement -Resources ResourceDictionary +- Produces: Connect-SnipWindowLifecycle +- Produces: Show-SettingsWindow, Show-AboutWindow, Show-FloatingWidget with optional TestAction +- Produces: New-SnipTrayMenu + +- [ ] **Step 1: Add failing resource and utility-surface tests** + +Assert exact tokens and surface order: + +~~~powershell +It 'builds the approved island resources' { + $r = New-SnipThemeResources -HighContrast:$false + Should-Be $r['SnipIslandRadius'] 16 + Should-Be $r['SnipControlRadius'] 10 + Should-Be $r['SnipCornerHeight'] 42 + Should-Be $r['SnipAccentBrush'].Color.ToString() '#FFD8C8A5' +} +It 'widget actions submit through the coordinator service' { + $requests = [System.Collections.ArrayList]::new() + $context = [pscustomobject]@{ + SubmitRequest = { param($mode) [void]$requests.Add([pscustomobject]@{Mode=$mode}) }.GetNewClosure() + } + Show-FloatingWidget -Context $context -TestAction { + param($kit) + & $kit.Click 'SmartBtn' + } + Should-Be $requests[0].Mode 'Smart' +} +~~~ + +Add high-contrast, reduced-motion, Settings recorder error, About metadata, window register/unregister, widget default-hidden, and owner-drawn tray palette assertions. + +- [ ] **Step 2: Run interactive tests and confirm red** + +Run the interactive suite. Expected: resource and surface tests fail while legacy Preview tests stay green. + +- [ ] **Step 3: Implement shared resources and utility surfaces** + +- Build a ResourceDictionary from the locked token object; use layered Borders for the mandatory contrast scrim, gradient, one-DIP hairline, inner highlight, and shadow. +- Use 16 DIP outer and 10 DIP inner radii everywhere. +- Switch to system brushes and remove shadows when SystemParameters.HighContrast is true. +- Settings records one key plus Control/Alt/Shift modifiers, calls Set-SnipHotkeyBinding, displays inline registration errors, and edits save folder, LaunchAtSignIn, and widget visibility. +- Widget order is Smart, Full, Window, Settings; it is created only when WidgetVisible is true. +- About displays version, PowerShell/.NET runtime, repository, license, and active shortcut. +- Utility surfaces register on SourceInitialized and unregister on Closed. +- Tray retains ContextMenuStrip but uses an owner-drawn neutral-black renderer and champagne selection. +- No utility surface calls capture functions directly. + +- [ ] **Step 4: Run both suites and visually smoke utility surfaces** + +Run both suites, then launch with pwsh -Sta -File .\SnipIT.ps1 and inspect Settings, About, tray, and optional widget. Exit from the tray and confirm no process remains. + +- [ ] **Step 5: Commit** + +~~~powershell +git add SnipIT.ps1 Test-SnipIT-Interactive.ps1 +git commit -S -m "feat: unify SnipIT utility surfaces" +~~~ + +--- + +### Task 5: Per-monitor Smart overlay and throttled loupe + +**Files:** +- Modify: SnipIT.ps1, Smart Overlay current lines 797-1008 and monitor/DPI helpers +- Modify: Test-SnipIT.ps1 +- Modify: Test-SnipIT-Interactive.ps1 + +**Interfaces:** +- Consumes: coordinator surface results and shared resources +- Produces: Get-SnipMonitorLayouts +- Produces: Get-SnipOverlayIntersections +- Produces: New-SnipOverlayContext, New-SnipOverlayWindow +- Produces: Invoke-SnipOverlayRenderTick +- Changes: Show-SmartOverlay accepts Services and TestAction and returns a selection result object + +- [ ] **Step 1: Add failing monitor and overlay tests** + +Use fake monitor descriptors for a left 125 percent monitor and a primary 100 percent monitor: + +~~~powershell +It 'maps a cross-monitor physical selection without a seam' { + $layouts = Get-SnipMonitorLayouts -MonitorDescriptors $monitors + $rect = Get-DragRectangle -AnchorX -500 -AnchorY 100 -CurrentX 500 -CurrentY 600 + $parts = Get-SnipOverlayIntersections -Rectangle $rect -MonitorLayouts $layouts + ShouldBe $parts.Count 2 + ShouldBe (($parts | Measure-Object PhysicalWidth -Sum).Sum) 1000 +} +~~~ + +Interactive tests verify one registered HWND per monitor, a shared session, coalesced pointer events, loupe clamping, cross-monitor selection rendering, and all result paths detaching CompositionTarget.Rendering. + +- [ ] **Step 2: Run suites and confirm red** + +Expected: new monitor-layout and TestAction contracts fail. + +- [ ] **Step 3: Replace the single overlay with an overlay set** + +- Capture one frozen virtual-desktop bitmap. +- Create one borderless topmost WPF window per Screen descriptor with that monitor's DPI transform. +- Share physical drag origin, pointer, selection, hovered HWND, result, and cancellation. +- Use GetCursorPos during captured drags so selection crosses monitor windows. +- Render each monitor's intersection only. +- Use champagne window hover, mint drag outline, 40 percent dimmer, black-glass hint and loupe. +- Store one CompositionTarget.Rendering delegate and detach it on Completed, UserCancelled, Preempted, Failed, and Shutdown. +- Register/unregister every monitor HWND. +- Return selection geometry to the coordinator; let the coordinator create the crop bitmap. + +- [ ] **Step 4: Run tests and manual mixed-DPI smoke** + +Run pure and interactive suites. On available hardware, drag across a monitor seam and cancel with Escape and right-click. + +- [ ] **Step 5: Commit** + +~~~powershell +git add SnipIT.ps1 Test-SnipIT.ps1 Test-SnipIT-Interactive.ps1 +git commit -S -m "feat: add per-monitor Smart capture overlay" +~~~ + +--- + +### Task 6: Floating Studio Preview shell, responsive modes, split actions, and custom chrome + +**Files:** +- Modify: SnipIT.ps1, Preview Window current lines 1010-2238 +- Modify: Test-SnipIT-Interactive.ps1 + +**Interfaces:** +- Consumes: theme, responsive, coordinator, ownership, and settings APIs +- Produces: New-SnipPreviewContext, New-SnipPreviewWindow +- Produces: Set-SnipPreviewResponsiveMode, Set-SnipPropertyIsland +- Produces: New-SnipSplitControl +- Preserves: Show-PreviewWindow -Bitmap -TestAction and every existing named closure key + +- [ ] **Step 1: Add failing shell and layout tests** + +Assert names, geometry, ordering, and overflow: + +~~~powershell +It 'uses the locked corner geometry' { + Should-Be $kit.ActionsIsland.Height 42 + Should-Be $kit.ViewportIsland.Height 42 + Should-Be $kit.StatusIsland.Height 42 + Should-Be $kit.ActionsIsland.CornerRadius.TopLeft 16 + Should-Be $kit.ViewportIsland.CornerRadius.TopLeft 16 + Should-Be $kit.StatusIsland.CornerRadius.TopLeft 16 +} +It 'orders the top actions correctly' { + Should-Be (($kit.ActionOrder) -join ',') 'Pin,Save,CopyAndClose,Close' +} +~~~ + +Add Wide at 1200 by 700, Compact at 1199 by 699 and 900 by 600, Narrow at 899 by 599 and 760 by 540, no-overlap, active hidden-tool More state, property overflow, split keyboard, resize, maximize/restore, Alt+Space, and capture-monitor placement tests. + +- [ ] **Step 2: Run interactive tests and confirm red** + +Expected: new Floating Studio element names and geometry fail; legacy editor behavior remains green. + +- [ ] **Step 3: Build the shell without changing annotation semantics** + +Use one full-window Grid: + +~~~xml + + + + + + + + + + + + + + +~~~ + +Layer each island through shared resources. Build native Button plus options Button composites for Copy, Arrow/Line, Rectangle/Ellipse, and Blur/Pixelate. Register ContextMenu HWNDs while open and close them before capture. + +Implement responsive content exactly from specification section 6.2. Add six-DIP non-client resize hit testing, brand drag/double-click, work-area maximize, Alt+Space, Alt+F4, and snap-compatible window styles. Retain existing closure names and add Context, Resources, ResponsiveMode, CommandRouter, islands, and action order to TestAction. + +- [ ] **Step 4: Run interactive tests and render the Preview** + +Run both suites. Start a synthetic Preview in test mode at Wide, Compact, and Narrow sizes and visually compare black substrate, champagne active state, corner curvature, property spacing, and no cyan chrome. + +- [ ] **Step 5: Commit** + +~~~powershell +git add SnipIT.ps1 Test-SnipIT-Interactive.ps1 +git commit -S -m "feat: build the Floating Studio preview shell" +~~~ + +--- + +### Task 7: Stable annotation records, Select, move/resize, and non-destructive Crop + +**Files:** +- Modify: SnipIT.ps1, Core annotation copy helpers and Preview context/render/input sections +- Modify: Test-SnipIT.ps1 +- Modify: Test-SnipIT-Interactive.ps1 + +**Interfaces:** +- Produces: New-SnipAnnotation, Copy-SnipAnnotation +- Produces: Find-SnipAnnotation, Select-SnipAnnotation +- Produces: Move-SnipAnnotation, Resize-SnipAnnotation +- Produces: Set-SnipCrop, Get-SnipCropRectangle +- Changes: Preview context owns CropRectangle and SelectedAnnotationId + +- [ ] **Step 1: Add failing model, selection, and crop tests** + +Test stable identifiers, deep point copies, z-order hit testing, handles at non-unit zoom, one-pixel and ten-pixel keyboard movement, one undo entry per drag, Delete, all crop presets, crop reset, and source immutability. + +~~~powershell +It 'translates crop-intersecting geometry into output coordinates' { + $r = ConvertTo-SnipCropLocalRect -Rectangle ([pscustomobject]@{X=90;Y=90;Width=40;Height=40}) -Crop ([pscustomobject]@{X=100;Y=100;Width=100;Height=100}) + ShouldBe $r.X 0 + ShouldBe $r.Y 0 + ShouldBe $r.Width 30 + ShouldBe $r.Height 30 +} +~~~ + +- [ ] **Step 2: Run tests and confirm red** + +Expected: stable ID, selection, resize, and crop groups fail. + +- [ ] **Step 3: Implement the model and interactions** + +Every annotation record has Id, Kind, Geometry, Color, StrokeWidth, Opacity, Properties, and Z. Copy-SnipAnnotation deep-copies point arrays and kind-specific properties. + +Select chooses the topmost hit, shows mint handles, moves/resizes in image pixels, and commits one history snapshot at gesture end. Arrow keys move one pixel and Shift+Arrow ten while canvas focus owns input. Crop is separate non-destructive context state with Free, Original, 1:1, 4:3, and 16:9 presets. + +- [ ] **Step 4: Run pure and interactive suites** + +Expected: all prior and new selection/crop tests pass. + +- [ ] **Step 5: Commit** + +~~~powershell +git add SnipIT.ps1 Test-SnipIT.ps1 Test-SnipIT-Interactive.ps1 +git commit -S -m "feat: add selection and non-destructive crop" +~~~ + +--- + +### Task 8: Pen, Highlight, Arrow/Line, Rectangle/Ellipse, Text, Steps, and property controls + +**Files:** +- Modify: SnipIT.ps1, Preview drawing closures, renderer, property island, and history +- Modify: Test-SnipIT-Interactive.ps1 + +**Interfaces:** +- Produces: Start-SnipStroke, Add-SnipStrokePoint, Complete-SnipStroke +- Produces: Set-SnipToolSubtype +- Produces: Open-SnipTextEditor +- Produces: Add-SnipStep, Reset-SnipStepSequence +- Produces: Render-SnipAnnotations + +- [ ] **Step 1: Add failing tool tests** + +Cover: + +- Pen stores a smoothed image-pixel point list. +- Highlight width and opacity do not scale with viewport zoom. +- Arrow/Line and Rectangle/Ellipse retain last subtype. +- Shapes preserve stroke, optional fill, width, and opacity. +- Text Enter commits, Shift+Enter inserts a line, Escape cancels, and color changes live. +- Steps increment, move, reset, and undo. +- Property island reflects each active tool and selected annotation. +- Property changes create one undo entry and clear redo. + +- [ ] **Step 2: Run interactive tests and confirm red** + +Expected: new tool and property groups fail. + +- [ ] **Step 3: Implement tools through the Preview context** + +Keep named mouse closures as thin adapters. Drafts live in Context.Draft; committed records use New-SnipAnnotation. Pen uses distance-based point reduction followed by Catmull-Rom-to-polyline sampling. Highlight uses a translucent stroke. Split tools persist subtype in Context.ToolProperties. + +Use annotation colors Coral, Champagne, Mint, Amber, Violet, White, and Black. The active UI accent remains Champagne. Steps use the next unused positive integer and Reset sequence is undoable. + +- [ ] **Step 4: Run suites** + +Run pure and interactive suites. Expected: all legacy and new tool tests pass. + +- [ ] **Step 5: Commit** + +~~~powershell +git add SnipIT.ps1 Test-SnipIT-Interactive.ps1 +git commit -S -m "feat: complete Floating Studio annotation tools" +~~~ + +--- + +### Task 9: Blur/Pixelate, flattening order, copy/save behavior, and keyboard precedence + +**Files:** +- Modify: SnipIT.ps1, privacy cache, flattening, output, status, and key routing +- Modify: Test-SnipIT.ps1 +- Modify: Test-SnipIT-Interactive.ps1 + +**Interfaces:** +- Produces: New-SnipPrivacyPatch, Remove-SnipPrivacyPatch +- Produces: New-SnipFlattenedBitmap +- Produces: Invoke-SnipClipboardWrite with injected writer +- Produces: Invoke-SnipPreviewCopy -Mode CopyAndClose or CopyKeepOpen +- Produces: Invoke-SnipPreviewSave +- Consumes: Resolve-PreviewKeyCommand + +- [ ] **Step 1: Add failing privacy, output, and input tests** + +Add deterministic bitmap fixtures proving: + +1. crop occurs first +2. privacy effects translate and clip to crop-local coordinates +3. blur/pixelate modify source pixels +4. annotations render above privacy regions +5. cached patches dispose when replaced, cropped out, removed, or Preview closes +6. temporary flattening bitmaps dispose on success and failure +7. CopyAndClose closes only after clipboard success +8. CopyKeepOpen and Save keep Preview open +9. clipboard retries use 50, 100, and 200 millisecond delays +10. text/menu/property/draft/canvas/window key precedence matches specification section 6.4 + +- [ ] **Step 2: Run tests and confirm red** + +Expected: privacy and new output command tests fail. + +- [ ] **Step 3: Implement privacy processing and output** + +For each privacy region, crop source pixels into a temporary bitmap. Blur downsamples with HighQualityBilinear and upsamples with HighQualityBicubic; Pixelate downsamples and upsamples with NearestNeighbor. Cache only committed preview patches and dispose previous patches in finally paths. + +New-SnipFlattenedBitmap creates a caller-owned output, draws the crop, applies privacy regions, then renders all other annotations. Copy/save wrap the temporary bitmap in try/finally. Clipboard failures leave Preview open and update the 42-DIP status island. Save uses Resolve-SaveImagePath and preserves editor state. + +Route PreviewKeyDown through Resolve-PreviewKeyCommand before handling the event. Ctrl+Shift+C always copies the image and keeps Preview open; focused text controls retain Ctrl+C. + +- [ ] **Step 4: Run suites and a repeated output stress test** + +Run both suites, then execute 100 synthetic flatten/copy cycles through TestAction and assert process private memory stabilizes after garbage collection. + +- [ ] **Step 5: Commit** + +~~~powershell +git add SnipIT.ps1 Test-SnipIT.ps1 Test-SnipIT-Interactive.ps1 +git commit -S -m "feat: add privacy tools and reliable output" +~~~ + +--- + +### Task 10: Host integration, accessibility, documentation, quality gates, and acceptance audit + +**Files:** +- Create: Test-SnipIT-Host.ps1 +- Modify: SnipIT.ps1 +- Modify: Test-SnipIT-Interactive.ps1 +- Modify: .github/workflows/test.yml +- Modify: README.md +- Modify: docs/README.md + +**Interfaces:** +- Produces: Start-SnipITHost with injected TestAction for the host harness +- Produces: Close-SnipTransientPopups +- Produces: Set-SnipAccessibilityMode +- Verifies every public interface produced by Tasks 1-9 + +- [ ] **Step 1: Add failing host and accessibility tests** + +Test-SnipIT-Host.ps1 starts the real hidden WinForms host in STA test mode, posts WM_HOTKEY ID 1, and observes coordinator events. It covers hotkey delivery while Overlay, Preview, injected modal Save dialog, Settings, and About are active; latest pending request; cleanup before next capture; popup closure; and shutdown unregistration. + +Interactive accessibility tests enumerate every actionable control and assert: + +- nonempty AutomationProperties.Name +- visible two-DIP focus outline +- keyboard-reachable swatches with selected state +- tab order matching visible islands in Wide, Compact, and Narrow +- High Contrast system brushes and no shadows +- reduced motion removes island/toast animation +- Popup/ContextMenu HWND registration and cleanup +- resize, maximize, restore, Alt+Space, Alt+F4, and snap-compatible styles + +- [ ] **Step 2: Run the new host harness and confirm red** + +Run: pwsh -NoProfile -Sta -File .\Test-SnipIT-Host.ps1 + +Expected: host-test entry points or assertions fail before implementation. + +- [ ] **Step 3: Implement host seams and accessibility** + +- Factor host construction into Start-SnipITHost without changing normal launch. +- Use SNIPIT_HOST_TEST_MODE only to inject services and suppress external install/notification effects; it must still create the real hidden host HWND and hotkey NativeWindow. +- Ensure all popups close and all transient HWNDs unregister before snapshot. +- Apply AutomationProperties.Name, HelpText, and ItemStatus to every action. +- Use ToggleButton or RadioButton swatches so selection is exposed to UI Automation. +- React to SystemParameters.StaticPropertyChanged for High Contrast and client-area animation while windows are open. +- Add host tests to the Windows CI job. + +- [ ] **Step 4: Update documentation** + +README must document: + +- Ctrl+Alt+Shift+Q as the only default global shortcut +- transient-first lifecycle and coordinator behavior +- complete tool list and Copy & close behavior +- Settings path and conflict recovery +- optional widget default +- all three test commands and current verified totals + +docs/README must link this plan and retain the approved specification as the design authority. + +- [ ] **Step 5: Run the full verification matrix** + +Run: + +~~~powershell +pwsh -NoProfile -File .\Test-SnipIT.ps1 +pwsh -NoProfile -Sta -File .\Test-SnipIT-Interactive.ps1 +pwsh -NoProfile -Sta -File .\Test-SnipIT-Host.ps1 +pwsh -NoProfile -Command '$t=$null;$e=$null;[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path .\SnipIT.ps1),[ref]$t,[ref]$e)|Out-Null;if($e){$e;exit 1}' +pwsh -NoProfile -Command 'Invoke-ScriptAnalyzer -Path .\SnipIT.ps1 -Severity Error' +git diff --check +~~~ + +Also run the repository's Trivy, Semgrep, Gitleaks, jscpd, and SBOM workflow commands from .github/workflows/security.yml when the tools are installed. Record unavailable local tools explicitly and rely on CI for those gates. + +- [ ] **Step 6: Perform the manual Windows matrix** + +Verify one to three monitors, negative origin, 100/125/150/200 percent mixed scale, IntelliJ IDEA, Visual Studio Code, Windows Terminal, full-screen capture, High Contrast, reduced motion, occupied hotkey, busy clipboard, read-only save destination, bright/high-detail screenshots, each responsive boundary, custom chrome, and Windows snap. + +- [ ] **Step 7: Audit all 22 acceptance criteria** + +Create an evidence table in the implementation handoff. Each criterion must cite a test name, command output, inspected UI state, or manual-matrix result. Missing or indirect evidence keeps the implementation open. + +- [ ] **Step 8: Commit** + +~~~powershell +git add SnipIT.ps1 Test-SnipIT.ps1 Test-SnipIT-Interactive.ps1 Test-SnipIT-Host.ps1 .github/workflows/test.yml README.md docs/README.md +git commit -S -m "feat: complete the SnipIT UI and execution revamp" +~~~ + +--- + +## Acceptance traceability + +| Specification criteria | Implementing tasks | +|---|---| +| 1-2 hotkey and conflict recovery | 1, 2, 3, 10 | +| 3 transient idle | 2, 3, 4 | +| 4 serialized capture | 1, 3, 10 | +| 5-8 black-glass geometry and action material | 1, 4, 6, 10 | +| 9 complete tool set | 7, 8, 9 | +| 10 completion behavior | 6, 9 | +| 11 flattening order | 7, 9 | +| 12 exact-once bitmap disposal | 3, 5, 9 | +| 13 capture exclusion | 3, 4, 5, 6, 10 | +| 14 mixed-DPI and negative origin | 1, 5, 10 | +| 15 recoverable errors | 2, 3, 9, 10 | +| 16 single script and no external runtime | every task | +| 17 quality gates and coverage | every task, finalized in 10 | +| 18 contrast | 1, 4, 6, 10 | +| 19 responsive layouts | 1, 6, 10 | +| 20 cross-monitor seam | 1, 5, 10 | +| 21 keyboard precedence | 1, 6, 9, 10 | +| 22 transient popup exclusion | 3, 4, 6, 10 | From 421c0c946c888aa3522194226e25f6c489c57cff Mon Sep 17 00:00:00 2001 From: Amit Date: Fri, 10 Jul 2026 18:01:37 +0800 Subject: [PATCH 03/42] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index df99f1f..d9b1083 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ Desktop.ini .vscode/ .vs/ .idea/ +.worktrees/ *.swp *~ From 0a4f344e459f25dcd8e9750a96cc54e0afa19f6c Mon Sep 17 00:00:00 2001 From: Amit Date: Fri, 10 Jul 2026 18:05:03 +0800 Subject: [PATCH 04/42] docs: fix cross-platform plan examples --- .../2026-07-10-snipit-ui-execution-revamp.md | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md b/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md index 22122c2..2f8afbd 100644 --- a/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md +++ b/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md @@ -77,7 +77,8 @@ It 'returns the locked black champagne tokens' { ShouldBe (Get-SnipContrastRatio -Foreground $t.Hairline -Background '#2F2F30') 3.01 } It 'defaults to one Smart capture binding on Q' { - $s = Get-SnipDefaultSettings -PicturesDir 'C:\Pictures' + $pictures = Join-Path ([IO.Path]::GetTempPath()) 'Pictures' + $s = Get-SnipDefaultSettings -PicturesDir $pictures ShouldBe $s.Hotkey.Modifiers 0x4007 ShouldBe $s.Hotkey.VirtualKey 0x51 ShouldBe (Format-SnipHotkey -Modifiers $s.Hotkey.Modifiers -VirtualKey $s.Hotkey.VirtualKey) 'Ctrl+Alt+Shift+Q' @@ -142,17 +143,40 @@ function Get-PreviewResponsiveMode { function Get-SnipContrastRatio { param([string]$Foreground, [string]$Background) - function Get-RelativeLuminance { + function ConvertFrom-SnipHexColor { param([string]$Hex) $h = $Hex.TrimStart('#') - $channels = 0,2,4 | ForEach-Object { - $v = [Convert]::ToInt32($h.Substring($_,2),16) / 255.0 + if ($h.Length -eq 8) { + $alpha = [Convert]::ToInt32($h.Substring(0,2),16) / 255.0 + $offset = 2 + } elseif ($h.Length -eq 6) { + $alpha = 1.0 + $offset = 0 + } else { + throw [ArgumentException]::new('Expected #RRGGBB or #AARRGGBB') + } + $rgb = 0,2,4 | ForEach-Object { + [Convert]::ToInt32($h.Substring($offset + $_,2),16) + } + [pscustomobject]@{ Alpha=$alpha; R=$rgb[0]; G=$rgb[1]; B=$rgb[2] } + } + function Get-RelativeLuminance { + param([double[]]$Rgb) + $channels = $Rgb | ForEach-Object { + $v = $_ / 255.0 if ($v -le 0.03928) { $v / 12.92 } else { [math]::Pow(($v + 0.055) / 1.055, 2.4) } } 0.2126*$channels[0] + 0.7152*$channels[1] + 0.0722*$channels[2] } - $a = Get-RelativeLuminance $Foreground - $b = Get-RelativeLuminance $Background + $fg = ConvertFrom-SnipHexColor $Foreground + $bg = ConvertFrom-SnipHexColor $Background + $composite = @( + $fg.R*$fg.Alpha + $bg.R*(1-$fg.Alpha) + $fg.G*$fg.Alpha + $bg.G*(1-$fg.Alpha) + $fg.B*$fg.Alpha + $bg.B*(1-$fg.Alpha) + ) + $a = Get-RelativeLuminance $composite + $b = Get-RelativeLuminance -Rgb @($bg.R,$bg.G,$bg.B) [math]::Round(([math]::Max($a,$b)+0.05)/([math]::Min($a,$b)+0.05),2) } From 63402dbe2724d9dbd5559dbd9bf25c128a2e5925 Mon Sep 17 00:00:00 2001 From: Amit Date: Fri, 10 Jul 2026 18:10:42 +0800 Subject: [PATCH 05/42] docs: specify Task 1 pure contracts --- .../2026-07-10-snipit-ui-execution-revamp.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md b/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md index 2f8afbd..39a14b2 100644 --- a/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md +++ b/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md @@ -59,6 +59,83 @@ The specification spans several surfaces, but they share the same coordinator, t - Produces: ConvertTo-SnipDipPoint and ConvertTo-SnipPhysicalPoint - Produces: Get-SnipContrastRatio -Foreground string -Background string +#### Exact Task 1 contracts + +**Hotkey validation and formatting** + +- Modifier constants are Alt 0x1, Control 0x2, Shift 0x4, and NoRepeat 0x4000. +- Test-SnipHotkeyDefinition returns false when unknown modifier bits are present, NoRepeat is absent, or fewer than two of Alt/Control/Shift are set. +- Accepted virtual keys are Space 0x20, digits 0x30-0x39, letters 0x41-0x5A, and F1-F24 0x70-0x87. +- Modifier keys, Windows keys, Escape, Tab, Enter, and every unlisted virtual key are rejected. +- Format-SnipHotkey first validates and throws ArgumentException for an invalid binding. It emits modifiers in Control, Alt, Shift order, omits NoRepeat from display, and formats keys as Space, 0-9, A-Z, or F1-F24. + +**Coordinate conversion** + +~~~powershell +ConvertTo-SnipDipPoint -PhysicalX double -PhysicalY double -MonitorPhysicalX double -MonitorPhysicalY double -ScaleX double -ScaleY double +ConvertTo-SnipPhysicalPoint -DipX double -DipY double -MonitorPhysicalX double -MonitorPhysicalY double -ScaleX double -ScaleY double +~~~ + +Physical to DIP returns X=(PhysicalX-MonitorPhysicalX)/ScaleX and Y=(PhysicalY-MonitorPhysicalY)/ScaleY as doubles. DIP to physical returns X=MonitorPhysicalX+DipX*ScaleX and Y=MonitorPhysicalY+DipY*ScaleY, rounded to integers with MidpointRounding.AwayFromZero. Scale values less than or equal to zero throw ArgumentOutOfRangeException. Both functions return a PSCustomObject with X and Y. + +**Crop-local rectangles** + +ConvertTo-SnipCropLocalRect accepts Rectangle and Crop objects with X, Y, Width, and Height. It treats rectangles as half-open bounds, returns null for non-positive dimensions or no positive-area intersection, clips partial intersections, translates by negative Crop.X and Crop.Y, and returns a new PSCustomObject containing only X, Y, Width, and Height. It never mutates either input. + +**Keyboard command taxonomy** + +Resolve-PreviewKeyCommand returns one command string or null. Modifier matching is case-insensitive. + +- Ctrl+Shift+C returns CopyKeepOpen before any focused-control rule. +- PopupOpen or FocusedRole Popup: Escape returns ClosePopup; Up, Down, Home, End, or Enter returns PopupNavigation; every other key returns null. +- TextEditor or EditingText: Ctrl+C returns TextCopy; Ctrl+Enter returns CommitText; Escape returns CancelTextEdit; every other key returns TextInput. +- PropertyEditor or EditingProperty: Escape returns CancelPropertyEdit; every other key returns PropertyInput. +- FocusedRole Button with Space or Enter returns ActivateFocusedButton. +- A non-null Draft with Escape returns CancelDraft. +- A non-null SelectionId with Escape returns ClearSelection; Delete returns DeleteSelection; Left, Right, Up, or Down returns MoveSelection plus the direction plus 1, or plus 10 when Shift is present. Example: MoveSelectionLeft10. +- An ActiveTool other than Select with Escape returns ActivateSelect. +- Ctrl+C returns CopyKeepOpen; Ctrl+Enter returns CopyAndClose; Ctrl+S returns Save; Ctrl+N returns NewSmartCapture. +- Ctrl+Plus, Ctrl+OemPlus, or Ctrl+Add returns ZoomIn. Ctrl+Minus, Ctrl+OemMinus, or Ctrl+Subtract returns ZoomOut. Ctrl+D0 or Ctrl+NumPad0 returns ZoomFit. +- FocusedRole Canvas with Space returns TemporaryPan. +- Alt+F4 returns ClosePreview. Alt+Space returns ShowSystemMenu. +- Escape after every higher-precedence rule returns ClosePreview. Every unmatched key returns null. + +**Coordinator decision taxonomy** + +Get-SnipCoordinatorDecision returns a PSCustomObject with Action, NextPhase, QueueLatest, CloseSurface, and Reject. Valid phases are Idle, DelayPending, CaptureStarting, Selecting, Previewing, Completing, Recovering, Auxiliary, and ShuttingDown. Valid events are Submit, DelayElapsed, SmartReady, DirectReady, SelectionCompleted, CopyClosed, SaveCompleted, SurfaceClosed, UserCancelled, Preempted, Failed, Recovered, ShutdownRequested, and CleanupFinished. An unsupported phase/event combination throws InvalidOperationException. + +Submit decisions: + +| Phase | Action | NextPhase | QueueLatest | CloseSurface | Reject | +|---|---|---|---:|---:|---:| +| Idle | Start | CaptureStarting | false | false | false | +| DelayPending | CancelDelayAndStart | CaptureStarting | false | false | false | +| CaptureStarting | QueueLatest | CaptureStarting | true | false | false | +| Selecting | QueueLatestAndClose | Selecting | true | true | false | +| Previewing | QueueLatestAndClose | Previewing | true | true | false | +| Completing | QueueLatest | Completing | true | false | false | +| Recovering | QueueLatest | Recovering | true | false | false | +| Auxiliary | QueueLatestAndClose | Auxiliary | true | true | false | +| ShuttingDown | Reject | ShuttingDown | false | false | true | + +Completion decisions: + +| Required phase and event | Action without pending | Next without pending | Action with pending | Next with pending | +|---|---|---|---|---| +| DelayPending + DelayElapsed | Start | CaptureStarting | Start | CaptureStarting | +| CaptureStarting + SmartReady | OpenSelector | Selecting | DiscardAndStartPending | CaptureStarting | +| CaptureStarting + DirectReady | OpenPreview | Previewing | DiscardAndStartPending | CaptureStarting | +| Selecting + SelectionCompleted | OpenPreview | Previewing | DiscardAndStartPending | CaptureStarting | +| Completing + CopyClosed | ReturnIdle | Idle | StartPending | CaptureStarting | +| Completing + SaveCompleted | ReturnPreview | Previewing | ClosePreviewAndStartPending | CaptureStarting | +| Selecting, Previewing, or Auxiliary + SurfaceClosed | ReturnIdle | Idle | StartPending | CaptureStarting | +| DelayPending, CaptureStarting, Selecting, Previewing, or Auxiliary + UserCancelled | ReturnIdle | Idle | StartPending | CaptureStarting | +| DelayPending, CaptureStarting, Selecting, Previewing, or Auxiliary + Preempted | ReturnIdle | Idle | StartPending | CaptureStarting | +| Recovering + Recovered | ReturnIdle | Idle | StartPending | CaptureStarting | +| ShuttingDown + CleanupFinished | ShutdownComplete | ShuttingDown | ShutdownComplete | ShuttingDown | + +Failed from every phase except ShuttingDown returns BeginRecovery and Recovering with all flags false. Failed from ShuttingDown returns Reject and ShuttingDown with Reject true. ShutdownRequested from any phase returns BeginShutdown and ShuttingDown with all other flags false. + - [ ] **Step 1: Add failing pure tests** Add focused groups with exact expected values: From 11791ceb94dc880999476b0e2ae12952ae98ed2b Mon Sep 17 00:00:00 2001 From: Amit Date: Fri, 10 Jul 2026 18:12:24 +0800 Subject: [PATCH 06/42] docs: resolve Preview key precedence --- .../superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md b/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md index 39a14b2..f07bd08 100644 --- a/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md +++ b/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md @@ -87,6 +87,8 @@ ConvertTo-SnipCropLocalRect accepts Rectangle and Crop objects with X, Y, Width, Resolve-PreviewKeyCommand returns one command string or null. Modifier matching is case-insensitive. - Ctrl+Shift+C returns CopyKeepOpen before any focused-control rule. +- Alt+F4 returns ClosePreview before any focused-control rule. +- Alt+Space returns ShowSystemMenu before any focused-control rule. - PopupOpen or FocusedRole Popup: Escape returns ClosePopup; Up, Down, Home, End, or Enter returns PopupNavigation; every other key returns null. - TextEditor or EditingText: Ctrl+C returns TextCopy; Ctrl+Enter returns CommitText; Escape returns CancelTextEdit; every other key returns TextInput. - PropertyEditor or EditingProperty: Escape returns CancelPropertyEdit; every other key returns PropertyInput. @@ -97,7 +99,6 @@ Resolve-PreviewKeyCommand returns one command string or null. Modifier matching - Ctrl+C returns CopyKeepOpen; Ctrl+Enter returns CopyAndClose; Ctrl+S returns Save; Ctrl+N returns NewSmartCapture. - Ctrl+Plus, Ctrl+OemPlus, or Ctrl+Add returns ZoomIn. Ctrl+Minus, Ctrl+OemMinus, or Ctrl+Subtract returns ZoomOut. Ctrl+D0 or Ctrl+NumPad0 returns ZoomFit. - FocusedRole Canvas with Space returns TemporaryPan. -- Alt+F4 returns ClosePreview. Alt+Space returns ShowSystemMenu. - Escape after every higher-precedence rule returns ClosePreview. Every unmatched key returns null. **Coordinator decision taxonomy** From dd7489027eef573101308a32ec9a9fa4654afffd Mon Sep 17 00:00:00 2001 From: Amit Date: Fri, 10 Jul 2026 18:29:21 +0800 Subject: [PATCH 07/42] feat: add revamp core contracts --- SnipIT.ps1 | 426 +++++++++++++++++++++++++++++++++++++++++++++++ Test-SnipIT.ps1 | 427 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 853 insertions(+) diff --git a/SnipIT.ps1 b/SnipIT.ps1 index 05e6d69..abcb968 100644 --- a/SnipIT.ps1 +++ b/SnipIT.ps1 @@ -314,6 +314,432 @@ function Invoke-CaptureLoop { return $iterations } +function Get-SnipThemeTokens { + [pscustomobject][ordered]@{ + PageBlack = '#030405'; CanvasBlack = '#090A0C'; GlassScrim = '#CC000000' + GlassTop = '#260E1012'; GlassBottom = '#14050608' + PrimaryText = '#F2F5F7'; SecondaryText = '#C0C5CA'; MutedText = '#9EA4AA' + Hairline = '#59FFFFFF'; HoverFill = '#1FFFFFFF'; Accent = '#D8C8A5' + AccentText = '#F3EAD7'; Coral = '#FF8069'; Mint = '#A8EFD7' + Amber = '#FFD36B'; Violet = '#BBA1FF' + IslandRadius = 16; ControlRadius = 10; CornerIslandHeight = 42 + ToolTarget = 38; MainDockHeight = 51; PropertyIslandHeight = 54 + } +} + +function Get-SnipContrastRatio { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$Foreground, + [Parameter(Mandatory)] [string]$Background + ) + + function ConvertFrom-SnipHexColor { + param([Parameter(Mandatory)] [string]$Hex) + + $value = $Hex.TrimStart('#') + if ($value.Length -eq 8) { + $alpha = [Convert]::ToInt32($value.Substring(0, 2), 16) / 255.0 + $offset = 2 + } elseif ($value.Length -eq 6) { + $alpha = 1.0 + $offset = 0 + } else { + throw [ArgumentException]::new('Expected #RRGGBB or #AARRGGBB') + } + $rgb = 0, 2, 4 | ForEach-Object { + [Convert]::ToInt32($value.Substring($offset + $_, 2), 16) + } + [pscustomobject]@{ Alpha = $alpha; R = $rgb[0]; G = $rgb[1]; B = $rgb[2] } + } + + function Get-RelativeLuminance { + param([Parameter(Mandatory)] [double[]]$Rgb) + + $channels = $Rgb | ForEach-Object { + $channel = $_ / 255.0 + if ($channel -le 0.03928) { + $channel / 12.92 + } else { + [math]::Pow(($channel + 0.055) / 1.055, 2.4) + } + } + 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2] + } + + $foregroundColor = ConvertFrom-SnipHexColor -Hex $Foreground + $backgroundColor = ConvertFrom-SnipHexColor -Hex $Background + $composite = @( + $foregroundColor.R * $foregroundColor.Alpha + $backgroundColor.R * (1 - $foregroundColor.Alpha) + $foregroundColor.G * $foregroundColor.Alpha + $backgroundColor.G * (1 - $foregroundColor.Alpha) + $foregroundColor.B * $foregroundColor.Alpha + $backgroundColor.B * (1 - $foregroundColor.Alpha) + ) + $foregroundLuminance = Get-RelativeLuminance -Rgb $composite + $backgroundLuminance = Get-RelativeLuminance -Rgb @( + $backgroundColor.R, $backgroundColor.G, $backgroundColor.B + ) + [math]::Round( + ([math]::Max($foregroundLuminance, $backgroundLuminance) + 0.05) / + ([math]::Min($foregroundLuminance, $backgroundLuminance) + 0.05), + 2 + ) +} + +function Get-SnipDefaultSettings { + param([string]$PicturesDir = [Environment]::GetFolderPath('MyPictures')) + + [pscustomobject][ordered]@{ + Version = 1 + Hotkey = [pscustomobject][ordered]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + SaveFolder = Join-Path $PicturesDir 'Snips' + SaveFormat = 'Png' + LaunchAtSignIn = $true + WidgetVisible = $false + } +} + +function Test-SnipHotkeyDefinition { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [int]$Modifiers, + [Parameter(Mandatory)] [int]$VirtualKey + ) + + $knownModifiers = 0x4007 + if (($Modifiers -band (-bnot $knownModifiers)) -ne 0) { return $false } + if (($Modifiers -band 0x4000) -eq 0) { return $false } + + $chordModifierCount = 0 + foreach ($modifier in 0x1, 0x2, 0x4) { + if (($Modifiers -band $modifier) -ne 0) { $chordModifierCount++ } + } + if ($chordModifierCount -lt 2) { return $false } + + return $VirtualKey -eq 0x20 -or + ($VirtualKey -ge 0x30 -and $VirtualKey -le 0x39) -or + ($VirtualKey -ge 0x41 -and $VirtualKey -le 0x5A) -or + ($VirtualKey -ge 0x70 -and $VirtualKey -le 0x87) +} + +function Format-SnipHotkey { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [int]$Modifiers, + [Parameter(Mandatory)] [int]$VirtualKey + ) + + if (-not (Test-SnipHotkeyDefinition -Modifiers $Modifiers -VirtualKey $VirtualKey)) { + throw [ArgumentException]::new('The hotkey definition is not supported.') + } + + $parts = [System.Collections.Generic.List[string]]::new() + if (($Modifiers -band 0x2) -ne 0) { $parts.Add('Ctrl') } + if (($Modifiers -band 0x1) -ne 0) { $parts.Add('Alt') } + if (($Modifiers -band 0x4) -ne 0) { $parts.Add('Shift') } + + $keyText = if ($VirtualKey -eq 0x20) { + 'Space' + } elseif ($VirtualKey -ge 0x30 -and $VirtualKey -le 0x39) { + [string][char]$VirtualKey + } elseif ($VirtualKey -ge 0x41 -and $VirtualKey -le 0x5A) { + [string][char]$VirtualKey + } else { + 'F{0}' -f ($VirtualKey - 0x6F) + } + $parts.Add($keyText) + $parts -join '+' +} + +function Get-PreviewResponsiveMode { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [double]$Width, + [Parameter(Mandatory)] [double]$Height + ) + + if ($Width -lt 900 -or $Height -lt 600) { return 'Narrow' } + if ($Width -lt 1200 -or $Height -lt 700) { return 'Compact' } + 'Wide' +} + +function ConvertTo-SnipDipPoint { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [double]$PhysicalX, + [Parameter(Mandatory)] [double]$PhysicalY, + [Parameter(Mandatory)] [double]$MonitorPhysicalX, + [Parameter(Mandatory)] [double]$MonitorPhysicalY, + [Parameter(Mandatory)] [double]$ScaleX, + [Parameter(Mandatory)] [double]$ScaleY + ) + + if ($ScaleX -le 0) { + throw [ArgumentOutOfRangeException]::new('ScaleX', 'ScaleX must be greater than zero.') + } + if ($ScaleY -le 0) { + throw [ArgumentOutOfRangeException]::new('ScaleY', 'ScaleY must be greater than zero.') + } + [pscustomobject][ordered]@{ + X = [double](($PhysicalX - $MonitorPhysicalX) / $ScaleX) + Y = [double](($PhysicalY - $MonitorPhysicalY) / $ScaleY) + } +} + +function ConvertTo-SnipPhysicalPoint { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [double]$DipX, + [Parameter(Mandatory)] [double]$DipY, + [Parameter(Mandatory)] [double]$MonitorPhysicalX, + [Parameter(Mandatory)] [double]$MonitorPhysicalY, + [Parameter(Mandatory)] [double]$ScaleX, + [Parameter(Mandatory)] [double]$ScaleY + ) + + if ($ScaleX -le 0) { + throw [ArgumentOutOfRangeException]::new('ScaleX', 'ScaleX must be greater than zero.') + } + if ($ScaleY -le 0) { + throw [ArgumentOutOfRangeException]::new('ScaleY', 'ScaleY must be greater than zero.') + } + [pscustomobject][ordered]@{ + X = [int][math]::Round( + $MonitorPhysicalX + $DipX * $ScaleX, + 0, + [MidpointRounding]::AwayFromZero + ) + Y = [int][math]::Round( + $MonitorPhysicalY + $DipY * $ScaleY, + 0, + [MidpointRounding]::AwayFromZero + ) + } +} + +function ConvertTo-SnipCropLocalRect { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Rectangle, + [Parameter(Mandatory)] $Crop + ) + + if ($Rectangle.Width -le 0 -or $Rectangle.Height -le 0 -or + $Crop.Width -le 0 -or $Crop.Height -le 0) { + return $null + } + + $intersectionLeft = [math]::Max([double]$Rectangle.X, [double]$Crop.X) + $intersectionTop = [math]::Max([double]$Rectangle.Y, [double]$Crop.Y) + $intersectionRight = [math]::Min( + [double]$Rectangle.X + [double]$Rectangle.Width, + [double]$Crop.X + [double]$Crop.Width + ) + $intersectionBottom = [math]::Min( + [double]$Rectangle.Y + [double]$Rectangle.Height, + [double]$Crop.Y + [double]$Crop.Height + ) + if ($intersectionRight -le $intersectionLeft -or $intersectionBottom -le $intersectionTop) { + return $null + } + + [pscustomobject][ordered]@{ + X = $intersectionLeft - [double]$Crop.X + Y = $intersectionTop - [double]$Crop.Y + Width = $intersectionRight - $intersectionLeft + Height = $intersectionBottom - $intersectionTop + } +} + +function Resolve-PreviewKeyCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$FocusedRole, + [Parameter(Mandatory)] [hashtable]$EditorState, + [Parameter(Mandatory)] [string]$Key, + [AllowNull()][AllowEmptyCollection()] [string[]]$Modifiers = @() + ) + + $hasCtrl = @($Modifiers) -contains 'Ctrl' + $hasShift = @($Modifiers) -contains 'Shift' + $hasAlt = @($Modifiers) -contains 'Alt' + + if ($hasCtrl -and $hasShift -and $Key -eq 'C') { return 'CopyKeepOpen' } + if ($hasAlt -and $Key -eq 'F4') { return 'ClosePreview' } + if ($hasAlt -and $Key -eq 'Space') { return 'ShowSystemMenu' } + + if ($EditorState.PopupOpen -or $FocusedRole -eq 'Popup') { + if ($Key -eq 'Escape') { return 'ClosePopup' } + if ($Key -in 'Up', 'Down', 'Home', 'End', 'Enter') { return 'PopupNavigation' } + return $null + } + + if ($FocusedRole -eq 'TextEditor' -or $EditorState.EditingText) { + if ($hasCtrl -and $Key -eq 'C') { return 'TextCopy' } + if ($hasCtrl -and $Key -eq 'Enter') { return 'CommitText' } + if ($Key -eq 'Escape') { return 'CancelTextEdit' } + return 'TextInput' + } + + if ($FocusedRole -eq 'PropertyEditor' -or $EditorState.EditingProperty) { + if ($Key -eq 'Escape') { return 'CancelPropertyEdit' } + return 'PropertyInput' + } + + if ($FocusedRole -eq 'Button' -and $Key -in 'Space', 'Enter') { + return 'ActivateFocusedButton' + } + if ($null -ne $EditorState.Draft -and $Key -eq 'Escape') { return 'CancelDraft' } + + if ($null -ne $EditorState.SelectionId) { + if ($Key -eq 'Escape') { return 'ClearSelection' } + if ($Key -eq 'Delete') { return 'DeleteSelection' } + if ($Key -in 'Left', 'Right', 'Up', 'Down') { + $direction = switch ($Key.ToLowerInvariant()) { + 'left' { 'Left' } + 'right' { 'Right' } + 'up' { 'Up' } + 'down' { 'Down' } + } + $distance = if ($hasShift) { 10 } else { 1 } + return "MoveSelection$direction$distance" + } + } + + if ($Key -eq 'Escape' -and + -not [string]::IsNullOrEmpty([string]$EditorState.ActiveTool) -and + $EditorState.ActiveTool -ne 'Select') { + return 'ActivateSelect' + } + + if ($hasCtrl) { + switch ($Key.ToLowerInvariant()) { + 'c' { return 'CopyKeepOpen' } + 'enter' { return 'CopyAndClose' } + 's' { return 'Save' } + 'n' { return 'NewSmartCapture' } + { $_ -in 'plus', 'oemplus', 'add' } { return 'ZoomIn' } + { $_ -in 'minus', 'oemminus', 'subtract' } { return 'ZoomOut' } + { $_ -in 'd0', 'numpad0' } { return 'ZoomFit' } + } + } + + if ($FocusedRole -eq 'Canvas' -and $Key -eq 'Space') { return 'TemporaryPan' } + if ($Key -eq 'Escape') { return 'ClosePreview' } + return $null +} + +function Get-SnipCoordinatorDecision { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$Phase, + [Parameter(Mandatory)] [string]$Event, + [Parameter(Mandatory)] [bool]$HasPending + ) + + $validPhases = @( + 'Idle', 'DelayPending', 'CaptureStarting', 'Selecting', 'Previewing', + 'Completing', 'Recovering', 'Auxiliary', 'ShuttingDown' + ) + $validEvents = @( + 'Submit', 'DelayElapsed', 'SmartReady', 'DirectReady', 'SelectionCompleted', + 'CopyClosed', 'SaveCompleted', 'SurfaceClosed', 'UserCancelled', 'Preempted', + 'Failed', 'Recovered', 'ShutdownRequested', 'CleanupFinished' + ) + if ($Phase -notin $validPhases) { + throw [ArgumentException]::new("Unknown coordinator phase '$Phase'.", 'Phase') + } + if ($Event -notin $validEvents) { + throw [ArgumentException]::new("Unknown coordinator event '$Event'.", 'Event') + } + + $newDecision = { + param( + [string]$Action, + [string]$NextPhase, + [bool]$QueueLatest = $false, + [bool]$CloseSurface = $false, + [bool]$Reject = $false + ) + [pscustomobject][ordered]@{ + Action = $Action + NextPhase = $NextPhase + QueueLatest = $QueueLatest + CloseSurface = $CloseSurface + Reject = $Reject + } + } + + if ($Event -eq 'Submit') { + $decision = switch ($Phase) { + 'Idle' { & $newDecision 'Start' 'CaptureStarting' } + 'DelayPending' { & $newDecision 'CancelDelayAndStart' 'CaptureStarting' } + 'CaptureStarting' { & $newDecision 'QueueLatest' 'CaptureStarting' $true } + 'Selecting' { & $newDecision 'QueueLatestAndClose' 'Selecting' $true $true } + 'Previewing' { & $newDecision 'QueueLatestAndClose' 'Previewing' $true $true } + 'Completing' { & $newDecision 'QueueLatest' 'Completing' $true } + 'Recovering' { & $newDecision 'QueueLatest' 'Recovering' $true } + 'Auxiliary' { & $newDecision 'QueueLatestAndClose' 'Auxiliary' $true $true } + 'ShuttingDown' { & $newDecision 'Reject' 'ShuttingDown' $false $false $true } + } + return $decision + } + + if ($Event -eq 'ShutdownRequested') { + return (& $newDecision 'BeginShutdown' 'ShuttingDown') + } + if ($Event -eq 'Failed') { + if ($Phase -eq 'ShuttingDown') { + return (& $newDecision 'Reject' 'ShuttingDown' $false $false $true) + } + return (& $newDecision 'BeginRecovery' 'Recovering') + } + + $phaseEvent = "$Phase|$Event" + switch ($phaseEvent) { + 'DelayPending|DelayElapsed' { + return (& $newDecision 'Start' 'CaptureStarting') + } + 'CaptureStarting|SmartReady' { + if ($HasPending) { return (& $newDecision 'DiscardAndStartPending' 'CaptureStarting') } + return (& $newDecision 'OpenSelector' 'Selecting') + } + 'CaptureStarting|DirectReady' { + if ($HasPending) { return (& $newDecision 'DiscardAndStartPending' 'CaptureStarting') } + return (& $newDecision 'OpenPreview' 'Previewing') + } + 'Selecting|SelectionCompleted' { + if ($HasPending) { return (& $newDecision 'DiscardAndStartPending' 'CaptureStarting') } + return (& $newDecision 'OpenPreview' 'Previewing') + } + 'Completing|CopyClosed' { + if ($HasPending) { return (& $newDecision 'StartPending' 'CaptureStarting') } + return (& $newDecision 'ReturnIdle' 'Idle') + } + 'Completing|SaveCompleted' { + if ($HasPending) { return (& $newDecision 'ClosePreviewAndStartPending' 'CaptureStarting') } + return (& $newDecision 'ReturnPreview' 'Previewing') + } + { $_ -in @( + 'Selecting|SurfaceClosed', 'Previewing|SurfaceClosed', 'Auxiliary|SurfaceClosed', + 'DelayPending|UserCancelled', 'CaptureStarting|UserCancelled', + 'Selecting|UserCancelled', 'Previewing|UserCancelled', 'Auxiliary|UserCancelled', + 'DelayPending|Preempted', 'CaptureStarting|Preempted', + 'Selecting|Preempted', 'Previewing|Preempted', 'Auxiliary|Preempted', + 'Recovering|Recovered' + ) } { + if ($HasPending) { return (& $newDecision 'StartPending' 'CaptureStarting') } + return (& $newDecision 'ReturnIdle' 'Idle') + } + 'ShuttingDown|CleanupFinished' { + return (& $newDecision 'ShutdownComplete' 'ShuttingDown') + } + } + + throw [InvalidOperationException]::new( + "Coordinator event '$Event' is not supported while in phase '$Phase'." + ) +} + #endregion # Tests dot-source this script with -CoreOnly to load only the pure functions above. diff --git a/Test-SnipIT.ps1 b/Test-SnipIT.ps1 index 27231b1..3903818 100644 --- a/Test-SnipIT.ps1 +++ b/Test-SnipIT.ps1 @@ -634,6 +634,433 @@ foreach ($fn in 'Invoke-FullScreenCapture', 'Invoke-WindowCapture') { } } +function ShouldThrowType { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [scriptblock]$Body, + [Parameter(Mandatory)] [type]$ExceptionType + ) + + $caught = $null + try { + & $Body + } catch { + $caught = $_.Exception + } + if ($null -eq $caught) { + throw "Expected exception '$($ExceptionType.FullName)' but no exception was thrown" + } + + $current = $caught + while ($null -ne $current -and -not ($current -is $ExceptionType)) { + $current = $current.InnerException + } + if ($null -eq $current) { + throw "Expected exception '$($ExceptionType.FullName)' but got '$($caught.GetType().FullName)'" + } +} + +Describe 'Approved theme and settings contracts' +It 'returns every locked black champagne token' { + $tokens = Get-SnipThemeTokens + $expected = [ordered]@{ + PageBlack = '#030405'; CanvasBlack = '#090A0C'; GlassScrim = '#CC000000' + GlassTop = '#260E1012'; GlassBottom = '#14050608' + PrimaryText = '#F2F5F7'; SecondaryText = '#C0C5CA'; MutedText = '#9EA4AA' + Hairline = '#59FFFFFF'; HoverFill = '#1FFFFFFF'; Accent = '#D8C8A5' + AccentText = '#F3EAD7'; Coral = '#FF8069'; Mint = '#A8EFD7' + Amber = '#FFD36B'; Violet = '#BBA1FF' + IslandRadius = 16; ControlRadius = 10; CornerIslandHeight = 42 + ToolTarget = 38; MainDockHeight = 51; PropertyIslandHeight = 54 + } + foreach ($name in $expected.Keys) { + ShouldBe $tokens.$name $expected[$name] + } +} +It 'meets the locked worst-case contrast ratios' { + $tokens = Get-SnipThemeTokens + ShouldBe (Get-SnipContrastRatio -Foreground $tokens.SecondaryText -Background '#2F2F30') 7.69 + ShouldBe (Get-SnipContrastRatio -Foreground $tokens.Hairline -Background '#2F2F30') 3.01 +} +It 'rejects malformed contrast colors' { + ShouldThrowType -Body { + Get-SnipContrastRatio -Foreground '#FFF' -Background '#000000' | Out-Null + } -ExceptionType ([ArgumentException]) +} +It 'defaults to one Smart capture binding on Q' { + $pictures = Join-Path ([IO.Path]::GetTempPath()) 'Pictures' + $settings = Get-SnipDefaultSettings -PicturesDir $pictures + ShouldBe $settings.Version 1 + ShouldBe $settings.Hotkey.Modifiers 0x4007 + ShouldBe $settings.Hotkey.VirtualKey 0x51 + ShouldBe (Format-SnipHotkey -Modifiers $settings.Hotkey.Modifiers -VirtualKey $settings.Hotkey.VirtualKey) 'Ctrl+Alt+Shift+Q' + ShouldBe $settings.SaveFolder (Join-Path $pictures 'Snips') + ShouldBe $settings.SaveFormat 'Png' + ShouldBe $settings.LaunchAtSignIn $true + ShouldBe $settings.WidgetVisible $false +} + +Describe 'Smart capture hotkey contract' +$validHotkeys = @( + @{ Name = 'Space'; Modifiers = 0x4003; VirtualKey = 0x20; Display = 'Ctrl+Alt+Space' } + @{ Name = 'digit zero'; Modifiers = 0x4006; VirtualKey = 0x30; Display = 'Ctrl+Shift+0' } + @{ Name = 'digit nine'; Modifiers = 0x4005; VirtualKey = 0x39; Display = 'Alt+Shift+9' } + @{ Name = 'letter A'; Modifiers = 0x4003; VirtualKey = 0x41; Display = 'Ctrl+Alt+A' } + @{ Name = 'letter Z'; Modifiers = 0x4007; VirtualKey = 0x5A; Display = 'Ctrl+Alt+Shift+Z' } + @{ Name = 'F1'; Modifiers = 0x4005; VirtualKey = 0x70; Display = 'Alt+Shift+F1' } + @{ Name = 'F24'; Modifiers = 0x4006; VirtualKey = 0x87; Display = 'Ctrl+Shift+F24' } +) +foreach ($hotkeyCase in $validHotkeys) { + It "accepts and formats $($hotkeyCase.Name)" { + ShouldBeTrue (Test-SnipHotkeyDefinition -Modifiers $hotkeyCase.Modifiers -VirtualKey $hotkeyCase.VirtualKey) + ShouldBe (Format-SnipHotkey -Modifiers $hotkeyCase.Modifiers -VirtualKey $hotkeyCase.VirtualKey) $hotkeyCase.Display + } +} +$invalidHotkeys = @( + @{ Name = 'unknown modifier bits'; Modifiers = 0x400B; VirtualKey = 0x51 } + @{ Name = 'missing NoRepeat'; Modifiers = 0x0003; VirtualKey = 0x51 } + @{ Name = 'only one chord modifier'; Modifiers = 0x4002; VirtualKey = 0x51 } + @{ Name = 'bare Shift'; Modifiers = 0x4003; VirtualKey = 0x10 } + @{ Name = 'Windows key'; Modifiers = 0x4003; VirtualKey = 0x5B } + @{ Name = 'Escape'; Modifiers = 0x4003; VirtualKey = 0x1B } + @{ Name = 'Tab'; Modifiers = 0x4003; VirtualKey = 0x09 } + @{ Name = 'Enter'; Modifiers = 0x4003; VirtualKey = 0x0D } + @{ Name = 'unlisted Delete'; Modifiers = 0x4003; VirtualKey = 0x2E } +) +foreach ($hotkeyCase in $invalidHotkeys) { + It "rejects $($hotkeyCase.Name)" { + ShouldBeFalse (Test-SnipHotkeyDefinition -Modifiers $hotkeyCase.Modifiers -VirtualKey $hotkeyCase.VirtualKey) + } +} +It 'refuses to format an invalid binding' { + ShouldThrowType -Body { + Format-SnipHotkey -Modifiers 0x4002 -VirtualKey 0x51 | Out-Null + } -ExceptionType ([ArgumentException]) +} + +Describe 'Preview responsive contract' +It 'selects all exact boundary modes' { + ShouldBe (Get-PreviewResponsiveMode 1200 700) 'Wide' + ShouldBe (Get-PreviewResponsiveMode 1199 700) 'Compact' + ShouldBe (Get-PreviewResponsiveMode 1200 699) 'Compact' + ShouldBe (Get-PreviewResponsiveMode 900 600) 'Compact' + ShouldBe (Get-PreviewResponsiveMode 899 700) 'Narrow' + ShouldBe (Get-PreviewResponsiveMode 1200 599) 'Narrow' +} + +Describe 'Mixed-DPI point conversion contract' +It 'round-trips physical pixels through monitor-local DIPs' { + $dip = ConvertTo-SnipDipPoint ` + -PhysicalX -1295 -PhysicalY 450 ` + -MonitorPhysicalX -1920 -MonitorPhysicalY -90 ` + -ScaleX 1.25 -ScaleY 1.5 + ShouldBe $dip.X 500.0 + ShouldBe $dip.Y 360.0 + ShouldBe ($dip.X.GetType().FullName) 'System.Double' + ShouldBe ($dip.Y.GetType().FullName) 'System.Double' + + $physical = ConvertTo-SnipPhysicalPoint ` + -DipX $dip.X -DipY $dip.Y ` + -MonitorPhysicalX -1920 -MonitorPhysicalY -90 ` + -ScaleX 1.25 -ScaleY 1.5 + ShouldBe $physical.X -1295 + ShouldBe $physical.Y 450 + ShouldBe ($physical.X.GetType().FullName) 'System.Int32' + ShouldBe ($physical.Y.GetType().FullName) 'System.Int32' +} +It 'rounds physical midpoint pixels away from zero' { + $physical = ConvertTo-SnipPhysicalPoint ` + -DipX -0.25 -DipY 0.25 ` + -MonitorPhysicalX 0 -MonitorPhysicalY 0 ` + -ScaleX 2 -ScaleY 2 + ShouldBe $physical.X -1 + ShouldBe $physical.Y 1 +} +It 'rejects non-positive monitor scales' { + ShouldThrowType -Body { + ConvertTo-SnipDipPoint -PhysicalX 1 -PhysicalY 1 -MonitorPhysicalX 0 -MonitorPhysicalY 0 -ScaleX 0 -ScaleY 1 | Out-Null + } -ExceptionType ([ArgumentOutOfRangeException]) + ShouldThrowType -Body { + ConvertTo-SnipDipPoint -PhysicalX 1 -PhysicalY 1 -MonitorPhysicalX 0 -MonitorPhysicalY 0 -ScaleX 1 -ScaleY -1 | Out-Null + } -ExceptionType ([ArgumentOutOfRangeException]) + ShouldThrowType -Body { + ConvertTo-SnipPhysicalPoint -DipX 1 -DipY 1 -MonitorPhysicalX 0 -MonitorPhysicalY 0 -ScaleX 0 -ScaleY 1 | Out-Null + } -ExceptionType ([ArgumentOutOfRangeException]) + ShouldThrowType -Body { + ConvertTo-SnipPhysicalPoint -DipX 1 -DipY 1 -MonitorPhysicalX 0 -MonitorPhysicalY 0 -ScaleX 1 -ScaleY -1 | Out-Null + } -ExceptionType ([ArgumentOutOfRangeException]) +} + +Describe 'Crop-local rectangle contract' +It 'clips a rectangle across the crop top-left and translates it locally' { + $rect = [pscustomobject]@{ X = 90; Y = 90; Width = 40; Height = 40 } + $crop = [pscustomobject]@{ X = 100; Y = 100; Width = 100; Height = 100 } + $result = ConvertTo-SnipCropLocalRect -Rectangle $rect -Crop $crop + ShouldBe $result.X 0 + ShouldBe $result.Y 0 + ShouldBe $result.Width 30 + ShouldBe $result.Height 30 +} +It 'translates a fully contained rectangle without clipping' { + $result = ConvertTo-SnipCropLocalRect ` + -Rectangle ([pscustomobject]@{ X = 125; Y = 130; Width = 20; Height = 10 }) ` + -Crop ([pscustomobject]@{ X = 100; Y = 100; Width = 100; Height = 100 }) + ShouldBe $result.X 25 + ShouldBe $result.Y 30 + ShouldBe $result.Width 20 + ShouldBe $result.Height 10 +} +It 'clips a rectangle across the crop bottom-right' { + $result = ConvertTo-SnipCropLocalRect ` + -Rectangle ([pscustomobject]@{ X = 180; Y = 190; Width = 30; Height = 20 }) ` + -Crop ([pscustomobject]@{ X = 100; Y = 100; Width = 100; Height = 100 }) + ShouldBe $result.X 80 + ShouldBe $result.Y 90 + ShouldBe $result.Width 20 + ShouldBe $result.Height 10 +} +It 'returns null for separated or merely touching half-open bounds' { + $crop = [pscustomobject]@{ X = 100; Y = 100; Width = 100; Height = 100 } + ShouldBeTrue ($null -eq (ConvertTo-SnipCropLocalRect ` + -Rectangle ([pscustomobject]@{ X = 0; Y = 0; Width = 10; Height = 10 }) -Crop $crop)) + ShouldBeTrue ($null -eq (ConvertTo-SnipCropLocalRect ` + -Rectangle ([pscustomobject]@{ X = 200; Y = 100; Width = 10; Height = 10 }) -Crop $crop)) +} +It 'returns null for non-positive rectangle or crop dimensions' { + ShouldBeTrue ($null -eq (ConvertTo-SnipCropLocalRect ` + -Rectangle ([pscustomobject]@{ X = 100; Y = 100; Width = 0; Height = 10 }) ` + -Crop ([pscustomobject]@{ X = 100; Y = 100; Width = 100; Height = 100 }))) + ShouldBeTrue ($null -eq (ConvertTo-SnipCropLocalRect ` + -Rectangle ([pscustomobject]@{ X = 100; Y = 100; Width = 10; Height = 10 }) ` + -Crop ([pscustomobject]@{ X = 100; Y = 100; Width = -1; Height = 100 }))) +} +It 'returns only local rectangle properties and never mutates its inputs' { + $rect = [pscustomobject]@{ X = 90; Y = 90; Width = 40; Height = 40 } + $crop = [pscustomobject]@{ X = 100; Y = 100; Width = 100; Height = 100 } + $result = ConvertTo-SnipCropLocalRect -Rectangle $rect -Crop $crop + ShouldBe (@($result.PSObject.Properties.Name) -join ',') 'X,Y,Width,Height' + ShouldBe $rect.X 90 + ShouldBe $rect.Y 90 + ShouldBe $rect.Width 40 + ShouldBe $rect.Height 40 + ShouldBe $crop.X 100 + ShouldBe $crop.Y 100 + ShouldBe $crop.Width 100 + ShouldBe $crop.Height 100 +} + +Describe 'Preview key routing contract' +$baseEditorState = @{ + PopupOpen = $false; EditingText = $false; EditingProperty = $false + Draft = $null; SelectionId = $null; ActiveTool = 'Select' +} +It 'gives Ctrl+Shift+C ownership before focused controls' { + $state = $baseEditorState.Clone() + $state.EditingText = $true + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole TextEditor -EditorState $state -Key C -Modifiers @('cTrL', 'sHiFt')) 'CopyKeepOpen' +} +It 'routes Alt+F4 and Alt+Space before focused controls' { + $state = $baseEditorState.Clone() + $state.PopupOpen = $true + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Popup -EditorState $state -Key F4 -Modifiers Alt) 'ClosePreview' + $state.PopupOpen = $false + $state.EditingText = $true + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole TextEditor -EditorState $state -Key Space -Modifiers Alt) 'ShowSystemMenu' +} +It 'gives open popups navigation and one-level Escape ownership' { + $state = $baseEditorState.Clone() + $state.PopupOpen = $true + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $state -Key Escape -Modifiers @()) 'ClosePopup' + foreach ($keyName in 'Up', 'Down', 'Home', 'End', 'Enter') { + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $state -Key $keyName -Modifiers @()) 'PopupNavigation' + } + ShouldBeTrue ($null -eq (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $state -Key A -Modifiers @())) +} +It 'treats Popup focus as an open popup' { + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Popup -EditorState $baseEditorState -Key Escape -Modifiers @()) 'ClosePopup' +} +It 'gives text editors ownership before image commands' { + $state = $baseEditorState.Clone() + $state.EditingText = $true + $state.ActiveTool = 'Text' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole TextEditor -EditorState $state -Key C -Modifiers Ctrl) 'TextCopy' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole TextEditor -EditorState $state -Key Enter -Modifiers Ctrl) 'CommitText' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole TextEditor -EditorState $state -Key Escape -Modifiers @()) 'CancelTextEdit' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole TextEditor -EditorState $state -Key A -Modifiers @()) 'TextInput' +} +It 'uses EditingText state even when focus role is not TextEditor' { + $state = $baseEditorState.Clone() + $state.EditingText = $true + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $state -Key A -Modifiers @()) 'TextInput' +} +It 'gives editable properties ownership before image commands' { + $state = $baseEditorState.Clone() + $state.EditingProperty = $true + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole PropertyEditor -EditorState $state -Key Escape -Modifiers @()) 'CancelPropertyEdit' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole PropertyEditor -EditorState $state -Key C -Modifiers Ctrl) 'PropertyInput' +} +It 'preserves focused button activation' { + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Button -EditorState $baseEditorState -Key Space -Modifiers @()) 'ActivateFocusedButton' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Button -EditorState $baseEditorState -Key Enter -Modifiers @()) 'ActivateFocusedButton' +} +It 'cancels a draft before selection and window Escape behavior' { + $state = $baseEditorState.Clone() + $state.Draft = [pscustomobject]@{ Kind = 'Rectangle' } + $state.SelectionId = 'selection-1' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $state -Key Escape -Modifiers @()) 'CancelDraft' +} +It 'routes selected annotation Escape and Delete' { + $state = $baseEditorState.Clone() + $state.SelectionId = 'selection-1' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $state -Key Escape -Modifiers @()) 'ClearSelection' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $state -Key Delete -Modifiers @()) 'DeleteSelection' +} +foreach ($direction in 'Left', 'Right', 'Up', 'Down') { + It "moves a selected annotation $direction by one or ten" { + $state = $baseEditorState.Clone() + $state.SelectionId = 'selection-1' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $state -Key $direction -Modifiers @()) "MoveSelection${direction}1" + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $state -Key $direction -Modifiers SHIFT) "MoveSelection${direction}10" + } +} +It 'deactivates a drawing tool on Escape before closing Preview' { + $state = $baseEditorState.Clone() + $state.ActiveTool = 'Pen' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $state -Key Escape -Modifiers @()) 'ActivateSelect' +} +It 'routes canvas image completion commands' { + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $baseEditorState -Key C -Modifiers Ctrl) 'CopyKeepOpen' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $baseEditorState -Key Enter -Modifiers Ctrl) 'CopyAndClose' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $baseEditorState -Key S -Modifiers Ctrl) 'Save' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $baseEditorState -Key N -Modifiers Ctrl) 'NewSmartCapture' +} +It 'routes every zoom-key spelling' { + foreach ($keyName in 'Plus', 'OemPlus', 'Add') { + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $baseEditorState -Key $keyName -Modifiers Ctrl) 'ZoomIn' + } + foreach ($keyName in 'Minus', 'OemMinus', 'Subtract') { + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $baseEditorState -Key $keyName -Modifiers Ctrl) 'ZoomOut' + } + foreach ($keyName in 'D0', 'NumPad0') { + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $baseEditorState -Key $keyName -Modifiers Ctrl) 'ZoomFit' + } +} +It 'routes canvas Space, final Escape, and unmatched keys' { + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $baseEditorState -Key Space -Modifiers @()) 'TemporaryPan' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $baseEditorState -Key Escape -Modifiers @()) 'ClosePreview' + ShouldBeTrue ($null -eq (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $baseEditorState -Key A -Modifiers @())) +} + +function Assert-SnipCoordinatorDecision { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Actual, + [Parameter(Mandatory)] [string]$Action, + [Parameter(Mandatory)] [string]$NextPhase, + [bool]$QueueLatest = $false, + [bool]$CloseSurface = $false, + [bool]$Reject = $false + ) + ShouldBe $Actual.Action $Action + ShouldBe $Actual.NextPhase $NextPhase + ShouldBe $Actual.QueueLatest $QueueLatest + ShouldBe $Actual.CloseSurface $CloseSurface + ShouldBe $Actual.Reject $Reject + ShouldBe (@($Actual.PSObject.Properties.Name) -join ',') 'Action,NextPhase,QueueLatest,CloseSurface,Reject' +} + +Describe 'Central coordinator decision contract' +$submitCases = @( + @{ Phase = 'Idle'; Action = 'Start'; Next = 'CaptureStarting'; Queue = $false; Close = $false; Reject = $false } + @{ Phase = 'DelayPending'; Action = 'CancelDelayAndStart'; Next = 'CaptureStarting'; Queue = $false; Close = $false; Reject = $false } + @{ Phase = 'CaptureStarting'; Action = 'QueueLatest'; Next = 'CaptureStarting'; Queue = $true; Close = $false; Reject = $false } + @{ Phase = 'Selecting'; Action = 'QueueLatestAndClose'; Next = 'Selecting'; Queue = $true; Close = $true; Reject = $false } + @{ Phase = 'Previewing'; Action = 'QueueLatestAndClose'; Next = 'Previewing'; Queue = $true; Close = $true; Reject = $false } + @{ Phase = 'Completing'; Action = 'QueueLatest'; Next = 'Completing'; Queue = $true; Close = $false; Reject = $false } + @{ Phase = 'Recovering'; Action = 'QueueLatest'; Next = 'Recovering'; Queue = $true; Close = $false; Reject = $false } + @{ Phase = 'Auxiliary'; Action = 'QueueLatestAndClose'; Next = 'Auxiliary'; Queue = $true; Close = $true; Reject = $false } + @{ Phase = 'ShuttingDown'; Action = 'Reject'; Next = 'ShuttingDown'; Queue = $false; Close = $false; Reject = $true } +) +foreach ($decisionCase in $submitCases) { + It "maps $($decisionCase.Phase) + Submit" { + $actual = Get-SnipCoordinatorDecision -Phase $decisionCase.Phase -Event Submit -HasPending $false + Assert-SnipCoordinatorDecision -Actual $actual ` + -Action $decisionCase.Action -NextPhase $decisionCase.Next ` + -QueueLatest $decisionCase.Queue -CloseSurface $decisionCase.Close -Reject $decisionCase.Reject + } +} + +$completionCases = @( + @{ Phase = 'DelayPending'; Event = 'DelayElapsed'; WithoutAction = 'Start'; WithoutNext = 'CaptureStarting'; WithAction = 'Start'; WithNext = 'CaptureStarting' } + @{ Phase = 'CaptureStarting'; Event = 'SmartReady'; WithoutAction = 'OpenSelector'; WithoutNext = 'Selecting'; WithAction = 'DiscardAndStartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'CaptureStarting'; Event = 'DirectReady'; WithoutAction = 'OpenPreview'; WithoutNext = 'Previewing'; WithAction = 'DiscardAndStartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'Selecting'; Event = 'SelectionCompleted'; WithoutAction = 'OpenPreview'; WithoutNext = 'Previewing'; WithAction = 'DiscardAndStartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'Completing'; Event = 'CopyClosed'; WithoutAction = 'ReturnIdle'; WithoutNext = 'Idle'; WithAction = 'StartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'Completing'; Event = 'SaveCompleted'; WithoutAction = 'ReturnPreview'; WithoutNext = 'Previewing'; WithAction = 'ClosePreviewAndStartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'Selecting'; Event = 'SurfaceClosed'; WithoutAction = 'ReturnIdle'; WithoutNext = 'Idle'; WithAction = 'StartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'Previewing'; Event = 'SurfaceClosed'; WithoutAction = 'ReturnIdle'; WithoutNext = 'Idle'; WithAction = 'StartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'Auxiliary'; Event = 'SurfaceClosed'; WithoutAction = 'ReturnIdle'; WithoutNext = 'Idle'; WithAction = 'StartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'DelayPending'; Event = 'UserCancelled'; WithoutAction = 'ReturnIdle'; WithoutNext = 'Idle'; WithAction = 'StartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'CaptureStarting'; Event = 'UserCancelled'; WithoutAction = 'ReturnIdle'; WithoutNext = 'Idle'; WithAction = 'StartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'Selecting'; Event = 'UserCancelled'; WithoutAction = 'ReturnIdle'; WithoutNext = 'Idle'; WithAction = 'StartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'Previewing'; Event = 'UserCancelled'; WithoutAction = 'ReturnIdle'; WithoutNext = 'Idle'; WithAction = 'StartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'Auxiliary'; Event = 'UserCancelled'; WithoutAction = 'ReturnIdle'; WithoutNext = 'Idle'; WithAction = 'StartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'DelayPending'; Event = 'Preempted'; WithoutAction = 'ReturnIdle'; WithoutNext = 'Idle'; WithAction = 'StartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'CaptureStarting'; Event = 'Preempted'; WithoutAction = 'ReturnIdle'; WithoutNext = 'Idle'; WithAction = 'StartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'Selecting'; Event = 'Preempted'; WithoutAction = 'ReturnIdle'; WithoutNext = 'Idle'; WithAction = 'StartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'Previewing'; Event = 'Preempted'; WithoutAction = 'ReturnIdle'; WithoutNext = 'Idle'; WithAction = 'StartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'Auxiliary'; Event = 'Preempted'; WithoutAction = 'ReturnIdle'; WithoutNext = 'Idle'; WithAction = 'StartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'Recovering'; Event = 'Recovered'; WithoutAction = 'ReturnIdle'; WithoutNext = 'Idle'; WithAction = 'StartPending'; WithNext = 'CaptureStarting' } + @{ Phase = 'ShuttingDown'; Event = 'CleanupFinished'; WithoutAction = 'ShutdownComplete'; WithoutNext = 'ShuttingDown'; WithAction = 'ShutdownComplete'; WithNext = 'ShuttingDown' } +) +foreach ($decisionCase in $completionCases) { + foreach ($hasPending in @($false, $true)) { + $pendingLabel = if ($hasPending) { 'with pending' } else { 'without pending' } + It "maps $($decisionCase.Phase) + $($decisionCase.Event) $pendingLabel" { + $actual = Get-SnipCoordinatorDecision ` + -Phase $decisionCase.Phase -Event $decisionCase.Event -HasPending $hasPending + $action = if ($hasPending) { $decisionCase.WithAction } else { $decisionCase.WithoutAction } + $next = if ($hasPending) { $decisionCase.WithNext } else { $decisionCase.WithoutNext } + Assert-SnipCoordinatorDecision -Actual $actual -Action $action -NextPhase $next + } + } +} + +$coordinatorPhases = @( + 'Idle', 'DelayPending', 'CaptureStarting', 'Selecting', 'Previewing', + 'Completing', 'Recovering', 'Auxiliary', 'ShuttingDown' +) +foreach ($phaseName in $coordinatorPhases) { + foreach ($hasPending in @($false, $true)) { + $pendingLabel = if ($hasPending) { 'with pending' } else { 'without pending' } + It "maps $phaseName + Failed $pendingLabel" { + $actual = Get-SnipCoordinatorDecision -Phase $phaseName -Event Failed -HasPending $hasPending + if ($phaseName -eq 'ShuttingDown') { + Assert-SnipCoordinatorDecision -Actual $actual -Action Reject -NextPhase ShuttingDown -Reject $true + } else { + Assert-SnipCoordinatorDecision -Actual $actual -Action BeginRecovery -NextPhase Recovering + } + } + It "maps $phaseName + ShutdownRequested $pendingLabel" { + $actual = Get-SnipCoordinatorDecision -Phase $phaseName -Event ShutdownRequested -HasPending $hasPending + Assert-SnipCoordinatorDecision -Actual $actual -Action BeginShutdown -NextPhase ShuttingDown + } + } +} +It 'rejects unsupported valid phase and event combinations' { + ShouldThrowType -Body { + Get-SnipCoordinatorDecision -Phase Idle -Event DelayElapsed -HasPending $false | Out-Null + } -ExceptionType ([InvalidOperationException]) +} +It 'rejects unknown phases and events as invalid arguments' { + ShouldThrowType -Body { + Get-SnipCoordinatorDecision -Phase Unknown -Event Submit -HasPending $false | Out-Null + } -ExceptionType ([ArgumentException]) + ShouldThrowType -Body { + Get-SnipCoordinatorDecision -Phase Idle -Event Unknown -HasPending $false | Out-Null + } -ExceptionType ([ArgumentException]) +} + Write-Host "" $total = $script:Pass + $script:Fail $color = if ($script:Fail -eq 0) { 'Green' } else { 'Red' } From 29cf8b55b8bb97da487a0f4922068aff3fc406bf Mon Sep 17 00:00:00 2001 From: Amit Date: Fri, 10 Jul 2026 18:38:30 +0800 Subject: [PATCH 08/42] docs: correct contrast reference ratios --- docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md | 2 +- .../specs/2026-07-10-snipit-ui-execution-revamp-design.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md b/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md index f07bd08..dfbc393 100644 --- a/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md +++ b/docs/superpowers/plans/2026-07-10-snipit-ui-execution-revamp.md @@ -151,7 +151,7 @@ It 'returns the locked black champagne tokens' { ShouldBe $t.IslandRadius 16 ShouldBe $t.ControlRadius 10 ShouldBe $t.CornerIslandHeight 42 - ShouldBe (Get-SnipContrastRatio -Foreground $t.SecondaryText -Background '#2F2F30') 7.65 + ShouldBe (Get-SnipContrastRatio -Foreground $t.SecondaryText -Background '#2F2F30') 7.69 ShouldBe (Get-SnipContrastRatio -Foreground $t.Hairline -Background '#2F2F30') 3.01 } It 'defaults to one Smart capture binding on Q' { diff --git a/docs/superpowers/specs/2026-07-10-snipit-ui-execution-revamp-design.md b/docs/superpowers/specs/2026-07-10-snipit-ui-execution-revamp-design.md index 36213e5..b554bb3 100644 --- a/docs/superpowers/specs/2026-07-10-snipit-ui-execution-revamp-design.md +++ b/docs/superpowers/specs/2026-07-10-snipit-ui-execution-revamp-design.md @@ -139,7 +139,7 @@ All floating outer islands use the same 16 DIP radius. The top-right action clus The contrast scrim is mandatory even when the screenshot underneath is pure white. After compositing the scrim and glass gradient, the island background may be no lighter than #303234. Primary and secondary text must retain at least 4.5:1 contrast, and interactive boundaries or focus indicators must retain at least 3:1 contrast, against that worst-case composite. -Using sRGB alpha composition and rounding to whole channels, the specified tokens produce #2F2F30 over pure white. Against that result, primary text is 12.15:1, secondary text is 7.65:1, the hairline is 3.01:1, and the champagne accent is 8.06:1. +Using sRGB alpha composition and rounding to whole channels, the specified tokens produce #2F2F30 over pure white. Against that result, primary text is 12.22:1, secondary text is 7.69:1, the hairline is 3.01:1, and the champagne accent is 8.10:1. BorderThickness is 1 DIP, not one physical device pixel. Every glass surface enables UseLayoutRounding and SnapsToDevicePixels so the border remains stable at fractional monitor scale factors. From 662305971bd9cf55245efe0c44057de65ea0f054 Mon Sep 17 00:00:00 2001 From: Amit Date: Fri, 10 Jul 2026 18:58:21 +0800 Subject: [PATCH 09/42] feat: persist settings and register the default hotkey --- SnipIT.ps1 | 547 ++++++++++++++++++++++++++++++------ Test-SnipIT-Interactive.ps1 | 201 +++++++++++++ 2 files changed, 666 insertions(+), 82 deletions(-) diff --git a/SnipIT.ps1 b/SnipIT.ps1 index abcb968..02689e0 100644 --- a/SnipIT.ps1 +++ b/SnipIT.ps1 @@ -3,9 +3,8 @@ SnipIT — professional snipping tool for Windows 11 Pure PowerShell 7.5+ on .NET 9. No admin. No external dependencies. - Hotkeys: - Ctrl+Shift+S Smart capture (hover-window or drag-region) with magnifier - Ctrl+Shift+F Full virtual-desktop capture + Hotkey: + Ctrl+Alt+Shift+Q Smart capture (hover-window or drag-region) with magnifier Tray menu: Capture region / Capture full screen / Show widget / About / Uninstall / Exit @@ -750,22 +749,165 @@ if ($CoreOnly) { return } # Preview-window settings (tuned constants shared across functions) $script:UndoStackMaxDepth = 100 -# Diagnostic ring buffer — lightweight log for previously-silent catch {} blocks. -# Inspect with: Get-SnipDiag (shows the last N entries; default 200 deep) +# Diagnostic ring buffer plus a durable per-user log. +# Inspect the in-memory copy with: Get-SnipDiag $script:DiagRingSize = 200 $script:DiagRing = New-Object System.Collections.Generic.Queue[string] +function Get-SnipSettingsPath { + param([string]$LocalAppData = $env:LOCALAPPDATA) + + if ([string]::IsNullOrWhiteSpace($LocalAppData)) { + $LocalAppData = [Environment]::GetFolderPath('LocalApplicationData') + } + Join-Path $LocalAppData 'SnipIT\settings.json' +} + function Write-SnipDiag { - param([string]$Message, $ErrorRecord = $null) - $ts = (Get-Date).ToString('HH:mm:ss.fff') + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$Message, + $ErrorRecord = $null, + [string]$Path + ) + + $ts = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss.fff') $line = if ($ErrorRecord) { "[$ts] $Message :: $($ErrorRecord.Exception.Message)" } else { "[$ts] $Message" } $script:DiagRing.Enqueue($line) while ($script:DiagRing.Count -gt $script:DiagRingSize) { [void]$script:DiagRing.Dequeue() } + + try { + if ([string]::IsNullOrWhiteSpace($Path)) { + $settingsDir = Split-Path (Get-SnipSettingsPath) -Parent + $Path = Join-Path $settingsDir 'logs\snipit.log' + } + $logDir = Split-Path $Path -Parent + if (-not [string]::IsNullOrWhiteSpace($logDir)) { + New-Item -ItemType Directory -Force -Path $logDir | Out-Null + } + Add-Content -LiteralPath $Path -Value $line -Encoding utf8 + } catch { + # Diagnostics must never become a second application failure. + } } function Get-SnipDiag { $script:DiagRing.ToArray() } +function Read-SnipSettings { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$Path, + [string]$PicturesDir = [Environment]::GetFolderPath('MyPictures') + ) + + $defaults = Get-SnipDefaultSettings -PicturesDir $PicturesDir + $diagPath = Join-Path (Split-Path $Path -Parent) 'logs\snipit.log' + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + Write-SnipDiag -Message "Settings file not found; using defaults: $Path" -Path $diagPath + return $defaults + } + + try { + $loaded = Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json -ErrorAction Stop + } catch { + Write-SnipDiag -Message "Settings file is malformed; using defaults: $Path" -ErrorRecord $_ -Path $diagPath + return $defaults + } + + function Get-LoadedSetting { + param([Parameter(Mandatory)] [string]$Name) + $property = $loaded.PSObject.Properties[$Name] + if ($null -eq $property) { return $null } + $property.Value + } + + # Version 1 is the only supported schema. Unknown or ill-typed versions + # are normalized property-by-property through the same defaults below. + $version = $defaults.Version + + $hotkey = $defaults.Hotkey + $loadedHotkey = Get-LoadedSetting -Name 'Hotkey' + if ($null -ne $loadedHotkey) { + try { + $modifiersProperty = $loadedHotkey.PSObject.Properties['Modifiers'] + $virtualKeyProperty = $loadedHotkey.PSObject.Properties['VirtualKey'] + if ($null -ne $modifiersProperty -and $null -ne $virtualKeyProperty) { + $modifiers = [int]$modifiersProperty.Value + $virtualKey = [int]$virtualKeyProperty.Value + if (Test-SnipHotkeyDefinition -Modifiers $modifiers -VirtualKey $virtualKey) { + $hotkey = [pscustomobject][ordered]@{ + Modifiers = $modifiers + VirtualKey = $virtualKey + } + } + } + } catch { + $hotkey = $defaults.Hotkey + } + } + + $saveFolderValue = Get-LoadedSetting -Name 'SaveFolder' + $saveFolder = if ($saveFolderValue -is [string] -and + -not [string]::IsNullOrWhiteSpace($saveFolderValue)) { + $saveFolderValue + } else { + $defaults.SaveFolder + } + + $saveFormatValue = Get-LoadedSetting -Name 'SaveFormat' + $saveFormat = if ($saveFormatValue -is [string] -and + $saveFormatValue -cin @('Png', 'Jpeg', 'Bmp')) { + $saveFormatValue + } else { + $defaults.SaveFormat + } + + $launchValue = Get-LoadedSetting -Name 'LaunchAtSignIn' + $launchAtSignIn = if ($launchValue -is [bool]) { $launchValue } else { $defaults.LaunchAtSignIn } + $widgetValue = Get-LoadedSetting -Name 'WidgetVisible' + $widgetVisible = if ($widgetValue -is [bool]) { $widgetValue } else { $defaults.WidgetVisible } + + [pscustomobject][ordered]@{ + Version = $version + Hotkey = $hotkey + SaveFolder = $saveFolder + SaveFormat = $saveFormat + LaunchAtSignIn = $launchAtSignIn + WidgetVisible = $widgetVisible + } +} + +function Save-SnipSettings { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object]$Settings, + [Parameter(Mandatory)] [string]$Path + ) + + $directory = Split-Path $Path -Parent + if (-not [string]::IsNullOrWhiteSpace($directory)) { + New-Item -ItemType Directory -Force -Path $directory | Out-Null + } + $temporaryPath = Join-Path $directory ('.{0}.{1}.tmp' -f ([IO.Path]::GetFileName($Path)), [guid]::NewGuid()) + try { + $Settings | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $temporaryPath -Encoding utf8NoBOM + Move-Item -LiteralPath $temporaryPath -Destination $Path -Force + } finally { + if (Test-Path -LiteralPath $temporaryPath) { + Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue + } + } +} + +$hotkeyForm = $null +$tray = $null +$hkWin = $null +$registeredHotkey = $null +$script:SingleInstanceMutex = $null + +try { + # Self-relaunch in STA (PowerShell 7 defaults to MTA; WPF requires STA) if ([System.Threading.Thread]::CurrentThread.GetApartmentState() -ne 'STA') { $pwsh = (Get-Process -Id $PID).Path @@ -811,7 +953,7 @@ if (-not $env:SNIPIT_TEST_MODE) { if (-not $script:SingleInstanceCreated) { try { [System.Windows.Forms.MessageBox]::Show( - 'SnipIT is already running. Check the system tray (bottom-right) or press Ctrl+Shift+S.', + 'SnipIT is already running. Check the system tray (bottom-right) or press Ctrl+Alt+Shift+Q.', 'SnipIT', 'OK', 'Information') | Out-Null } catch {} try { $script:SingleInstanceMutex.Dispose() } catch {} @@ -819,15 +961,16 @@ if (-not $env:SNIPIT_TEST_MODE) { } } -# Compute the persistent home directory: 'snipIT-Home' alongside the script. -# If we're already running from inside snipIT-Home, use the current dir. -$scriptDir = Split-Path $PSCommandPath -Parent -if ((Split-Path $scriptDir -Leaf) -eq 'snipIT-Home') { - $script:AppHomeDir = $scriptDir -} else { - $script:AppHomeDir = Join-Path $scriptDir 'snipIT-Home' +# All durable state stays below the current user's LocalAppData directory. +$desktopDir = [Environment]::GetFolderPath('Desktop') +$startupDir = [Environment]::GetFolderPath('Startup') +$script:InstallPaths = Get-InstallPaths -LocalAppData $env:LOCALAPPDATA ` + -DesktopDir $desktopDir -StartupDir $startupDir +$script:AppHomeDir = $script:InstallPaths.AppDir +$script:SettingsPath = Get-SnipSettingsPath +if (-not $env:SNIPIT_TEST_MODE) { + New-Item -ItemType Directory -Force -Path $script:AppHomeDir | Out-Null } -New-Item -ItemType Directory -Force -Path $script:AppHomeDir | Out-Null # .NET 9 WPF Fluent theme try { [System.Windows.Application]::Current.ThemeMode = 'System' } catch {} @@ -973,48 +1116,102 @@ function Get-SnipITIconPath { #region First-Run Install =================================================== function Write-SnipITShortcuts { - # Idempotent: always (re)write Desktop + Startup shortcuts so the icon - # path and arguments stay current across upgrades. - param([Parameter(Mandatory)] [string]$AppDir, [Parameter(Mandatory)] [string]$ScriptTarget) - $pwshExe = (Get-Process -Id $PID).Path - $shortcutArgs = "-NoProfile -WindowStyle Hidden -Sta -File `"$ScriptTarget`"" - $iconPath = Get-SnipITIconPath - $iconSource = "$iconPath,0" - $shell = New-Object -ComObject WScript.Shell - $links = @( - (Join-Path ([Environment]::GetFolderPath('Desktop')) 'SnipIT.lnk'), - (Join-Path ([Environment]::GetFolderPath('Startup')) 'SnipIT.lnk') + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object]$Paths, + [string]$IconPath + ) + + if ([string]::IsNullOrWhiteSpace([string]$Paths.DesktopShortcut)) { return } + Write-SnipITShortcut -Path $Paths.DesktopShortcut -AppDir $Paths.AppDir ` + -ScriptTarget $Paths.ScriptPath -IconPath $IconPath +} + +function Write-SnipITShortcut { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$Path, + [Parameter(Mandatory)] [string]$AppDir, + [Parameter(Mandatory)] [string]$ScriptTarget, + [string]$IconPath ) - foreach ($linkPath in $links) { - # Remove any stale shortcut so the icon cache is forced to refresh. - if (Test-Path $linkPath) { Remove-Item -Force -ErrorAction SilentlyContinue $linkPath } - $sc = $shell.CreateShortcut($linkPath) - $sc.TargetPath = $pwshExe - $sc.Arguments = $shortcutArgs - $sc.WorkingDirectory = $AppDir - $sc.IconLocation = $iconSource - $sc.WindowStyle = 7 - $sc.Description = 'SnipIT - professional snipping tool' - $sc.Save() + + $parent = Split-Path $Path -Parent + if (-not [string]::IsNullOrWhiteSpace($parent)) { + New-Item -ItemType Directory -Force -Path $parent | Out-Null + } + if (Test-Path -LiteralPath $Path) { + Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + } + + $shell = $null + $shortcut = $null + try { + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut($Path) + $shortcut.TargetPath = (Get-Process -Id $PID).Path + $shortcut.Arguments = Get-ShortcutArguments -ScriptPath $ScriptTarget + $shortcut.WorkingDirectory = $AppDir + if (-not [string]::IsNullOrWhiteSpace($IconPath) -and + (Test-Path -LiteralPath $IconPath -PathType Leaf)) { + $shortcut.IconLocation = "$IconPath,0" + } + $shortcut.WindowStyle = 7 + $shortcut.Description = 'SnipIT - professional snipping tool' + $shortcut.Save() + } finally { + if ($null -ne $shortcut -and [Runtime.InteropServices.Marshal]::IsComObject($shortcut)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($shortcut) + } + if ($null -ne $shell -and [Runtime.InteropServices.Marshal]::IsComObject($shell)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($shell) + } + } +} + +function Sync-SnipStartupShortcut { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object]$Settings, + [Parameter(Mandatory)] [object]$Paths + ) + + if ($env:SNIPIT_TEST_MODE -or + [string]::IsNullOrWhiteSpace([string]$Paths.StartupShortcut)) { + return + } + + if ([bool]$Settings.LaunchAtSignIn) { + $iconPath = Join-Path $Paths.AppDir 'SnipIT.ico' + Write-SnipITShortcut -Path $Paths.StartupShortcut -AppDir $Paths.AppDir ` + -ScriptTarget $Paths.ScriptPath -IconPath $iconPath + } elseif (Test-Path -LiteralPath $Paths.StartupShortcut) { + Remove-Item -LiteralPath $Paths.StartupShortcut -Force -ErrorAction Stop } } function Install-SnipIT { - $appDir = $script:AppHomeDir - $marker = Join-Path $appDir '.installed' - $target = Join-Path $appDir 'SnipIT.ps1' + param([object]$Paths = $script:InstallPaths) + + if ($env:SNIPIT_TEST_MODE) { return $false } + + $appDir = $Paths.AppDir + $marker = $Paths.Marker + $target = $Paths.ScriptPath $fresh = -not (Test-Path $marker) + New-Item -ItemType Directory -Force -Path $appDir | Out-Null - # Copy the running script into snipIT-Home unless we're already running - # from there (compare normalized absolute paths). + # Copy the running script into the per-user app directory unless it is + # already the executing copy. $runningFull = [System.IO.Path]::GetFullPath($PSCommandPath) $targetFull = [System.IO.Path]::GetFullPath($target) if ($runningFull -ne $targetFull) { Copy-Item -LiteralPath $PSCommandPath -Destination $target -Force } - Write-SnipITShortcuts -AppDir $appDir -ScriptTarget $target + $iconPath = Get-SnipITIconPath + Write-SnipITShortcuts -Paths $Paths -IconPath $iconPath if ($fresh) { Set-Content -LiteralPath $marker -Value (Get-Date -Format o) } return $fresh @@ -1022,17 +1219,25 @@ function Install-SnipIT { function Uninstall-SnipIT { Remove-Item -Force -ErrorAction SilentlyContinue ` - (Join-Path ([Environment]::GetFolderPath('Desktop')) 'SnipIT.lnk'), - (Join-Path ([Environment]::GetFolderPath('Startup')) 'SnipIT.lnk') - # Remove everything inside snipIT-Home except the directory itself (so we - # don't wipe the user's project dir if they put the script at its root). + $script:InstallPaths.DesktopShortcut, + $script:InstallPaths.StartupShortcut + # Remove the app's user-scoped LocalAppData contents. if (Test-Path $script:AppHomeDir) { Get-ChildItem -LiteralPath $script:AppHomeDir -Force | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue } } -$freshInstall = Install-SnipIT +$script:Settings = Get-SnipDefaultSettings +$freshInstall = $false +if (-not $env:SNIPIT_TEST_MODE) { + $script:Settings = Read-SnipSettings -Path $script:SettingsPath + Save-SnipSettings -Settings $script:Settings -Path $script:SettingsPath + $freshInstall = Install-SnipIT -Paths $script:InstallPaths + # This replaces the legacy unconditional Startup link with settings-owned + # synchronization. Re-running is idempotent after the one-time migration. + Sync-SnipStartupShortcut -Settings $script:Settings -Paths $script:InstallPaths +} #endregion @@ -2858,6 +3063,187 @@ function Show-FloatingWidget { #region Tray + Hotkeys ====================================================== +function Register-SnipHotkeyBinding { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [IntPtr]$Hwnd, + [Parameter(Mandatory)] [object]$Binding, + [Parameter(Mandatory)] [scriptblock]$Register + ) + + $activeBinding = $null + $candidateError = $null + try { + $modifiers = [int]$Binding.Modifiers + $virtualKey = [int]$Binding.VirtualKey + if (-not (Test-SnipHotkeyDefinition -Modifiers $modifiers -VirtualKey $virtualKey)) { + throw [ArgumentException]::new('The hotkey definition is not supported.') + } + $normalized = [pscustomobject][ordered]@{ + Modifiers = $modifiers + VirtualKey = $virtualKey + } + if ([bool](& $Register $Hwnd 1 $modifiers $virtualKey)) { + $activeBinding = $normalized + } else { + $candidateError = "RegisterHotKey rejected $(Format-SnipHotkey -Modifiers $modifiers -VirtualKey $virtualKey)." + } + } catch { + $candidateError = $_.Exception.Message + } + + [pscustomobject]@{ + Success = $null -ne $activeBinding + ActiveBinding = $activeBinding + CandidateError = $candidateError + RollbackError = $null + } +} + +function Set-SnipHotkeyBinding { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object]$Context, + [Parameter(Mandatory)] [object]$Candidate, + [Parameter(Mandatory)] [scriptblock]$Register, + [Parameter(Mandatory)] [scriptblock]$Unregister + ) + + $hwnd = [IntPtr]::Zero + $hwndProperty = $Context.PSObject.Properties['Hwnd'] + if ($null -ne $hwndProperty -and $null -ne $hwndProperty.Value) { + $hwnd = [IntPtr]$hwndProperty.Value + } + + $previousBinding = $null + if ($null -ne $Context.RegisteredHotkey) { + $previousBinding = [pscustomobject][ordered]@{ + Modifiers = [int]$Context.RegisteredHotkey.Modifiers + VirtualKey = [int]$Context.RegisteredHotkey.VirtualKey + } + } + + $candidateError = $null + $rollbackError = $null + try { + $candidateBinding = [pscustomobject][ordered]@{ + Modifiers = [int]$Candidate.Modifiers + VirtualKey = [int]$Candidate.VirtualKey + } + if (-not (Test-SnipHotkeyDefinition -Modifiers $candidateBinding.Modifiers ` + -VirtualKey $candidateBinding.VirtualKey)) { + throw [ArgumentException]::new('The hotkey definition is not supported.') + } + } catch { + return [pscustomobject]@{ + Success = $false + ActiveBinding = $previousBinding + CandidateError = $_.Exception.Message + RollbackError = $null + } + } + + if ($null -ne $previousBinding) { + try { + if (-not [bool](& $Unregister $hwnd 1)) { + throw [InvalidOperationException]::new('Could not unregister the active hotkey.') + } + } catch { + return [pscustomobject]@{ + Success = $false + ActiveBinding = $previousBinding + CandidateError = $_.Exception.Message + RollbackError = $null + } + } + } + + $candidateResult = Register-SnipHotkeyBinding -Hwnd $hwnd -Binding $candidateBinding -Register $Register + if (-not $candidateResult.Success) { + $candidateError = $candidateResult.CandidateError + $activeBinding = $null + if ($null -ne $previousBinding) { + $rollbackResult = Register-SnipHotkeyBinding -Hwnd $hwnd -Binding $previousBinding -Register $Register + if ($rollbackResult.Success) { + $activeBinding = $rollbackResult.ActiveBinding + $Context.RegisteredHotkey = $activeBinding + } else { + $rollbackError = "Could not restore the previous hotkey: $($rollbackResult.CandidateError)" + $Context.RegisteredHotkey = $null + } + } else { + $Context.RegisteredHotkey = $null + } + + $diagPath = $null + $settingsPathProperty = $Context.PSObject.Properties['SettingsPath'] + if ($null -ne $settingsPathProperty -and + -not [string]::IsNullOrWhiteSpace([string]$settingsPathProperty.Value)) { + $diagPath = Join-Path (Split-Path $settingsPathProperty.Value -Parent) 'logs\snipit.log' + } + $message = "Hotkey replacement failed: $candidateError" + if ($rollbackError) { $message += " Rollback failed: $rollbackError" } + Write-SnipDiag -Message $message -Path $diagPath + + return [pscustomobject]@{ + Success = $false + ActiveBinding = $activeBinding + CandidateError = $candidateError + RollbackError = $rollbackError + } + } + + $updatedSettings = [pscustomobject][ordered]@{ + Version = $Context.Settings.Version + Hotkey = $candidateBinding + SaveFolder = $Context.Settings.SaveFolder + SaveFormat = $Context.Settings.SaveFormat + LaunchAtSignIn = $Context.Settings.LaunchAtSignIn + WidgetVisible = $Context.Settings.WidgetVisible + } + try { + Save-SnipSettings -Settings $updatedSettings -Path $Context.SettingsPath + } catch { + $candidateError = "The new hotkey registered but its settings could not be saved: $($_.Exception.Message)" + try { + if (-not [bool](& $Unregister $hwnd 1)) { + throw [InvalidOperationException]::new('Could not unregister the unsaved candidate hotkey.') + } + if ($null -ne $previousBinding) { + $rollbackResult = Register-SnipHotkeyBinding -Hwnd $hwnd -Binding $previousBinding -Register $Register + if (-not $rollbackResult.Success) { + throw [InvalidOperationException]::new($rollbackResult.CandidateError) + } + $Context.RegisteredHotkey = $rollbackResult.ActiveBinding + } else { + $Context.RegisteredHotkey = $null + } + } catch { + $rollbackError = "Could not restore the previous hotkey: $($_.Exception.Message)" + $Context.RegisteredHotkey = $null + } + $diagPath = Join-Path (Split-Path $Context.SettingsPath -Parent) 'logs\snipit.log' + $message = "Hotkey persistence failed: $candidateError" + if ($rollbackError) { $message += " Rollback failed: $rollbackError" } + Write-SnipDiag -Message $message -Path $diagPath + return [pscustomobject]@{ + Success = $false + ActiveBinding = if ($rollbackError) { $null } else { $previousBinding } + CandidateError = $candidateError + RollbackError = $rollbackError + } + } + + $Context.Settings.Hotkey = $candidateBinding + $Context.RegisteredHotkey = $candidateBinding + [pscustomobject]@{ + Success = $true + ActiveBinding = $candidateBinding + CandidateError = $null + RollbackError = $null + } +} + # Test mode: harness dot-sources this script to call Show-PreviewWindow # directly. Skip the real tray, hotkey registration, and main loop. if ($env:SNIPIT_TEST_MODE) { return } @@ -2871,13 +3257,7 @@ $hotkeyForm.Size = New-Object System.Drawing.Size 1, 1 $hotkeyForm.StartPosition = 'Manual' $hotkeyForm.Location = New-Object System.Drawing.Point -2000, -2000 -$MOD_CONTROL = 0x2; $MOD_SHIFT = 0x4 -$HOTKEY_SMART = 1 -$HOTKEY_FULL = 2 -$HOTKEY_WINDOW = 3 -$VK_S = 0x53 -$VK_F = 0x46 -$VK_W = 0x57 +$HOTKEY_SMART = 1 $WM_HOTKEY = 0x0312 # Subclass via NativeWindow. WndProc must NOT do work directly — it BeginInvokes @@ -2936,12 +3316,9 @@ $hkWin.Callback = [Action[int]]{ $script:CurrentPreviewWindow.Close() return } - switch ($id) { - 1 { Invoke-SmartCapture } - 2 { Invoke-FullScreenCapture } - 3 { Invoke-WindowCapture } - } + if ($id -eq $HOTKEY_SMART) { Invoke-SmartCapture } } catch { + Write-SnipDiag -Message 'Hotkey capture failed' -ErrorRecord $_ try { $script:tray.BalloonTipTitle = 'SnipIT error' $script:tray.BalloonTipText = $_.Exception.Message @@ -2950,30 +3327,34 @@ $hkWin.Callback = [Action[int]]{ } } -$hotkeyErrors = @() -if (-not [Native]::RegisterHotKey($hotkeyForm.Handle, $HOTKEY_SMART, ($MOD_CONTROL -bor $MOD_SHIFT), $VK_S)) { - $hotkeyErrors += 'Ctrl+Shift+S' -} -if (-not [Native]::RegisterHotKey($hotkeyForm.Handle, $HOTKEY_FULL, ($MOD_CONTROL -bor $MOD_SHIFT), $VK_F)) { - $hotkeyErrors += 'Ctrl+Shift+F' +$registerNativeHotkey = { + param($hwnd, $id, $modifiers, $virtualKey) + [Native]::RegisterHotKey($hwnd, $id, $modifiers, $virtualKey) } -if (-not [Native]::RegisterHotKey($hotkeyForm.Handle, $HOTKEY_WINDOW, ($MOD_CONTROL -bor $MOD_SHIFT), $VK_W)) { - $hotkeyErrors += 'Ctrl+Shift+W' +$initialHotkeyResult = Register-SnipHotkeyBinding -Hwnd $hotkeyForm.Handle ` + -Binding $script:Settings.Hotkey -Register $registerNativeHotkey +$registeredHotkey = $initialHotkeyResult.ActiveBinding +$configuredHotkeyText = Format-SnipHotkey -Modifiers $script:Settings.Hotkey.Modifiers ` + -VirtualKey $script:Settings.Hotkey.VirtualKey +$hotkeyText = if ($registeredHotkey) { + Format-SnipHotkey -Modifiers $registeredHotkey.Modifiers -VirtualKey $registeredHotkey.VirtualKey +} else { + 'Unavailable' } # Tray icon $script:tray = New-Object System.Windows.Forms.NotifyIcon $tray = $script:tray $tray.Visible = $true -$tray.Text = 'SnipIT — Ctrl+Shift+S to snip' +$tray.Text = if ($registeredHotkey) { "SnipIT - $hotkeyText to snip" } else { 'SnipIT - use tray to capture' } try { $tray.Icon = New-Object System.Drawing.Icon (Get-SnipITIconPath) } catch { $tray.Icon = [System.Drawing.SystemIcons]::Application } $menu = New-Object System.Windows.Forms.ContextMenuStrip -[void]$menu.Items.Add('Smart capture (Ctrl+Shift+S)', $null, { Invoke-SmartCapture }) -[void]$menu.Items.Add('Full screen (Ctrl+Shift+F)', $null, { Invoke-FullScreenCapture }) -[void]$menu.Items.Add('Active window (Ctrl+Shift+W)', $null, { Invoke-WindowCapture }) +[void]$menu.Items.Add("Smart capture ($hotkeyText)", $null, { Invoke-SmartCapture }) +[void]$menu.Items.Add('Full screen', $null, { Invoke-FullScreenCapture }) +[void]$menu.Items.Add('Active window', $null, { Invoke-WindowCapture }) [void]$menu.Items.Add('-') $delayMenu = New-Object System.Windows.Forms.ToolStripMenuItem 'Delay capture' [void]$delayMenu.DropDownItems.Add('Smart in 3 seconds', $null, { Start-DelayedCapture 3 'smart' }) @@ -3008,14 +3389,14 @@ $delayMenu = New-Object System.Windows.Forms.ToolStripMenuItem 'Delay capture' $tray.ContextMenuStrip = $menu $tray.Add_DoubleClick({ Invoke-SmartCapture }) -if ($hotkeyErrors.Count -gt 0) { - Write-SnipDiag "Hotkey registration failed: $($hotkeyErrors -join ', ')" +if (-not $initialHotkeyResult.Success) { + Write-SnipDiag -Message "Hotkey registration failed: $($initialHotkeyResult.CandidateError)" $tray.BalloonTipTitle = 'SnipIT — hotkey conflict' - $tray.BalloonTipText = "Could not register: $($hotkeyErrors -join ', '). Use the tray menu instead." + $tray.BalloonTipText = "Could not register $configuredHotkeyText. Use the tray menu instead." $tray.ShowBalloonTip(5000) } elseif ($freshInstall) { $tray.BalloonTipTitle = 'SnipIT installed' - $tray.BalloonTipText = 'Press Ctrl+Shift+S to capture. Right-click the tray icon for options.' + $tray.BalloonTipText = "Press $hotkeyText to capture. Right-click the tray icon for options." $tray.ShowBalloonTip(4000) } @@ -3023,8 +3404,7 @@ if ($hotkeyErrors.Count -gt 0) { #region Main loop & cleanup ================================================= -try { - [System.Windows.Forms.Application]::Run() +[System.Windows.Forms.Application]::Run() } catch { # Surface the actual inner exception (PS wraps the .NET one in a # MethodInvocationException whose Message is unhelpfully generic). @@ -3033,15 +3413,18 @@ try { $inner = $_.Exception.InnerException $msg = "$($inner.GetType().FullName): $($inner.Message)`n`n$($inner.StackTrace)" } + Write-SnipDiag -Message 'Unhandled SnipIT failure' -ErrorRecord $_ + if ($env:SNIPIT_TEST_MODE) { throw } try { [System.Windows.Forms.MessageBox]::Show($msg, 'SnipIT runtime error', 'OK', 'Error') | Out-Null } catch { [Console]::Error.WriteLine($msg) } } finally { - try { [Native]::UnregisterHotKey($hotkeyForm.Handle, $HOTKEY_SMART) | Out-Null } catch {} - try { [Native]::UnregisterHotKey($hotkeyForm.Handle, $HOTKEY_FULL) | Out-Null } catch {} - try { [Native]::UnregisterHotKey($hotkeyForm.Handle, $HOTKEY_WINDOW) | Out-Null } catch {} + if ($registeredHotkey -and $hotkeyForm) { + try { [Native]::UnregisterHotKey($hotkeyForm.Handle, 1) | Out-Null } + catch { Write-SnipDiag -Message 'Hotkey cleanup failed' -ErrorRecord $_ } + } if ($tray) { try { $tray.Dispose() } catch {} } if ($hotkeyForm) { try { $hotkeyForm.Dispose() } catch {} } if ($script:SingleInstanceMutex) { diff --git a/Test-SnipIT-Interactive.ps1 b/Test-SnipIT-Interactive.ps1 index a6c7cd1..cf7ac7b 100644 --- a/Test-SnipIT-Interactive.ps1 +++ b/Test-SnipIT-Interactive.ps1 @@ -63,6 +63,207 @@ function Should-BeGreaterThan { param($Actual, $Min) if ([double]$Actual -le [double]$Min) { throw "Expected > $Min, got $Actual" } } +Describe 'Settings persistence and diagnostics' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-settings-' + [guid]::NewGuid()) + $settingsPath = Join-Path $root 'settings.json' + $diagPath = Join-Path $root 'logs\snipit.log' + $pictures = Join-Path $root 'Pictures' + New-Item -ItemType Directory -Force -Path $root, $pictures | Out-Null + + It 'resolves the settings path below the supplied LocalAppData root' { + Should-Be (Get-SnipSettingsPath -LocalAppData $root) (Join-Path $root 'SnipIT\settings.json') + } + + It 'recovers defaults from malformed JSON' { + Set-Content -LiteralPath $settingsPath -Value '{bad' + $result = Read-SnipSettings -Path $settingsPath -PicturesDir $pictures + Should-Be $result.Hotkey.VirtualKey 0x51 + Should-Be $result.Hotkey.Modifiers 0x4007 + Should-BeFalse $result.WidgetVisible + } + + It 'normalizes every invalid loaded property against defaults' { + @{ + Version = 'old' + Hotkey = @{ Modifiers = 0; VirtualKey = 0x1B } + SaveFolder = '' + SaveFormat = 'Gif' + LaunchAtSignIn = 'yes' + WidgetVisible = $null + } | ConvertTo-Json | Set-Content -LiteralPath $settingsPath + + $result = Read-SnipSettings -Path $settingsPath -PicturesDir $pictures + $defaults = Get-SnipDefaultSettings -PicturesDir $pictures + Should-Be $result.Version $defaults.Version + Should-Be $result.Hotkey.Modifiers $defaults.Hotkey.Modifiers + Should-Be $result.Hotkey.VirtualKey $defaults.Hotkey.VirtualKey + Should-Be $result.SaveFolder $defaults.SaveFolder + Should-Be $result.SaveFormat $defaults.SaveFormat + Should-Be $result.LaunchAtSignIn $defaults.LaunchAtSignIn + Should-Be $result.WidgetVisible $defaults.WidgetVisible + } + + It 'persists settings through a sibling temporary file' { + $settings = Get-SnipDefaultSettings -PicturesDir $pictures + $settings.SaveFolder = Join-Path $pictures 'Custom' + $settings.LaunchAtSignIn = $false + Save-SnipSettings -Settings $settings -Path $settingsPath + + $loaded = Read-SnipSettings -Path $settingsPath -PicturesDir $pictures + Should-Be $loaded.SaveFolder $settings.SaveFolder + Should-BeFalse $loaded.LaunchAtSignIn + Should-Be @(Get-ChildItem -LiteralPath $root -Filter '*.tmp').Count 0 + } + + It 'appends diagnostics to the durable log' { + Write-SnipDiag -Message 'settings test diagnostic' -Path $diagPath + Should-BeTrue (Test-Path -LiteralPath $diagPath) + Should-BeTrue ((Get-Content -Raw -LiteralPath $diagPath).Contains('settings test diagnostic')) + } + + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue +} + +Describe 'Hotkey registration and replacement' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-hotkey-' + [guid]::NewGuid()) + $settingsPath = Join-Path $root 'settings.json' + $pictures = Join-Path $root 'Pictures' + New-Item -ItemType Directory -Force -Path $root, $pictures | Out-Null + + It 'registers only hotkey ID 1 with the requested binding' { + $calls = [System.Collections.ArrayList]::new() + $register = { + param($hwnd, $id, $mods, $vk) + [void]$calls.Add([pscustomobject]@{ Id = $id; Modifiers = $mods; VirtualKey = $vk }) + $true + } + $binding = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + $result = Register-SnipHotkeyBinding -Hwnd ([IntPtr]::Zero) -Binding $binding -Register $register + Should-BeTrue $result.Success + Should-Be $calls.Count 1 + Should-Be $calls[0].Id 1 + Should-Be $calls[0].Modifiers 0x4007 + Should-Be $calls[0].VirtualKey 0x51 + } + + It 'persists a successful replacement only after registration' { + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings -PicturesDir $pictures + SettingsPath = $settingsPath + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + } + $calls = [System.Collections.ArrayList]::new() + $register = { param($hwnd, $id, $mods, $vk) [void]$calls.Add($vk); $true } + $result = Set-SnipHotkeyBinding -Context $context -Candidate @{ Modifiers = 0x4007; VirtualKey = 0x52 } -Register $register -Unregister { $true } + + Should-BeTrue $result.Success + Should-Be $result.ActiveBinding.VirtualKey 0x52 + Should-Be $context.Settings.Hotkey.VirtualKey 0x52 + Should-Be $context.RegisteredHotkey.VirtualKey 0x52 + Should-Be (Read-SnipSettings -Path $settingsPath -PicturesDir $pictures).Hotkey.VirtualKey 0x52 + } + + It 'restores the previous binding when the candidate is rejected' { + Remove-Item -LiteralPath $settingsPath -Force -ErrorAction SilentlyContinue + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings -PicturesDir $pictures + SettingsPath = $settingsPath + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + } + $calls = [System.Collections.ArrayList]::new() + $register = { param($hwnd, $id, $mods, $vk) [void]$calls.Add($vk); $vk -ne 0x53 } + $result = Set-SnipHotkeyBinding -Context $context -Candidate @{ Modifiers = 0x4007; VirtualKey = 0x53 } -Register $register -Unregister { $true } + + Should-BeFalse $result.Success + Should-Be $calls.Count 2 + Should-Be $calls[0] 0x53 + Should-Be $calls[1] 0x51 + Should-Be $result.ActiveBinding.VirtualKey 0x51 + Should-Be $context.Settings.Hotkey.VirtualKey 0x51 + Should-BeFalse (Test-Path -LiteralPath $settingsPath) + Should-Be $result.RollbackError $null + } + + It 'reports rollback failure without claiming an active binding' { + Remove-Item -LiteralPath $settingsPath -Force -ErrorAction SilentlyContinue + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings -PicturesDir $pictures + SettingsPath = $settingsPath + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + } + $register = { param($hwnd, $id, $mods, $vk) $false } + $result = Set-SnipHotkeyBinding -Context $context -Candidate @{ Modifiers = 0x4007; VirtualKey = 0x53 } -Register $register -Unregister { $true } + + Should-BeFalse $result.Success + Should-Be $result.ActiveBinding $null + Should-Be $context.RegisteredHotkey $null + Should-BeTrue (-not [string]::IsNullOrWhiteSpace($result.CandidateError)) + Should-BeTrue (-not [string]::IsNullOrWhiteSpace($result.RollbackError)) + Should-Be $context.Settings.Hotkey.VirtualKey 0x51 + Should-BeFalse (Test-Path -LiteralPath $settingsPath) + } + + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue +} + +Describe 'Startup shortcut synchronization' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-startup-' + [guid]::NewGuid()) + $desktop = Join-Path $root 'Desktop' + $startup = Join-Path $root 'Startup' + $pictures = Join-Path $root 'Pictures' + New-Item -ItemType Directory -Force -Path $root, $desktop, $startup, $pictures | Out-Null + $paths = Get-InstallPaths -LocalAppData $root -DesktopDir $desktop -StartupDir $startup + New-Item -ItemType Directory -Force -Path $paths.AppDir | Out-Null + Set-Content -LiteralPath $paths.ScriptPath -Value '# test launcher' + + It 'creates the Startup shortcut when LaunchAtSignIn is enabled' { + $settings = Get-SnipDefaultSettings -PicturesDir $pictures + $priorTestMode = $env:SNIPIT_TEST_MODE + try { + Remove-Item Env:SNIPIT_TEST_MODE -ErrorAction SilentlyContinue + Sync-SnipStartupShortcut -Settings $settings -Paths $paths + Should-BeTrue (Test-Path -LiteralPath $paths.StartupShortcut) + } finally { + $env:SNIPIT_TEST_MODE = $priorTestMode + } + } + + It 'removes the Startup shortcut when LaunchAtSignIn is disabled' { + Set-Content -LiteralPath $paths.StartupShortcut -Value 'legacy shortcut' + $settings = Get-SnipDefaultSettings -PicturesDir $pictures + $settings.LaunchAtSignIn = $false + $priorTestMode = $env:SNIPIT_TEST_MODE + try { + Remove-Item Env:SNIPIT_TEST_MODE -ErrorAction SilentlyContinue + Sync-SnipStartupShortcut -Settings $settings -Paths $paths + Should-BeFalse (Test-Path -LiteralPath $paths.StartupShortcut) + } finally { + $env:SNIPIT_TEST_MODE = $priorTestMode + } + } + + It 'makes no shortcut writes in SNIPIT_TEST_MODE' { + Remove-Item -LiteralPath $paths.StartupShortcut -Force -ErrorAction SilentlyContinue + $settings = Get-SnipDefaultSettings -PicturesDir $pictures + Sync-SnipStartupShortcut -Settings $settings -Paths $paths + Should-BeFalse (Test-Path -LiteralPath $paths.StartupShortcut) + } + + It 'makes no installation writes in SNIPIT_TEST_MODE' { + $installRoot = Join-Path $root 'guarded-install' + $guardedPaths = Get-InstallPaths -LocalAppData $installRoot ` + -DesktopDir (Join-Path $installRoot 'Desktop') ` + -StartupDir (Join-Path $installRoot 'Startup') + $result = Install-SnipIT -Paths $guardedPaths + Should-BeFalse $result + Should-BeFalse (Test-Path -LiteralPath $guardedPaths.AppDir) + Should-BeFalse (Test-Path -LiteralPath $guardedPaths.DesktopShortcut) + Should-BeFalse (Test-Path -LiteralPath $guardedPaths.StartupShortcut) + } + + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue +} + # ---- Build synthetic bitmap ---- $bmp = New-Object System.Drawing.Bitmap 1200, 800 $g = [System.Drawing.Graphics]::FromImage($bmp) From 2412d460bb55fd2cf8d6f26b252fd7129b987b9c Mon Sep 17 00:00:00 2001 From: Amit Date: Fri, 10 Jul 2026 19:15:33 +0800 Subject: [PATCH 10/42] fix: make settings persistence transactional --- README.md | 25 +++++++++--------- SnipIT.ps1 | 20 ++++++++------- Test-SnipIT-Interactive.ps1 | 51 ++++++++++++++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 202525f..fddc160 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,9 @@ A **professional snipping tool** for Windows 11 written in **pure PowerShell 7.5 - **System tray** with full menu (capture modes, open snips folder, about, uninstall, exit) - **Floating capture widget** — auto-hiding top-center pill with Smart/Full/Window buttons -- **Global hotkeys** registered via `RegisterHotKey` on a hidden message-only form +- **One configurable Smart-capture hotkey** registered via `RegisterHotKey` on a hidden message-only form; Full and Window capture stay available from the tray - **Single-instance** enforced by a per-session named mutex; a second launch shows a friendly message instead of stacking up -- **Self-installing**: first launch copies itself to `snipIT-Home/` next to the script, creates a Desktop shortcut, and adds an Auto-Start shortcut to `shell:startup`. **No admin. No registry writes outside HKCU. No UAC prompts.** +- **Self-installing**: first launch copies the app and durable settings to `%LOCALAPPDATA%\SnipIT`, creates a Desktop shortcut, and synchronizes the `shell:startup` shortcut from the `LaunchAtSignIn` setting. **No admin. No UAC prompts.** ## Hotkeys @@ -57,11 +57,11 @@ A **professional snipping tool** for Windows 11 written in **pure PowerShell 7.5 | Hotkey | Action | |---|---| -| `Ctrl+Shift+S` | Smart capture (hover-window or drag-region) | -| `Ctrl+Shift+F` | Full virtual-desktop capture | -| `Ctrl+Shift+W` | Active window capture | +| `Ctrl+Alt+Shift+Q` | Smart capture (hover-window or drag-region) | | `Esc` / right-click | Cancel an active capture | +Full virtual-desktop and active-window capture intentionally have no global hotkey; use the system-tray menu for those modes. + ### Preview window | Hotkey | Action | @@ -82,17 +82,18 @@ A **professional snipping tool** for Windows 11 written in **pure PowerShell 7.5 3. Double-click it (or run `pwsh -Sta -File .\SnipIT.ps1`) On first run SnipIT silently: -- Copies itself to `snipIT-Home\SnipIT.ps1` next to the source script +- Copies itself to `%LOCALAPPDATA%\SnipIT\SnipIT.ps1` +- Creates `%LOCALAPPDATA%\SnipIT\settings.json` for the Smart hotkey, save defaults, widget visibility, and launch-at-sign-in preference - Creates a Desktop shortcut -- Creates an Auto-Start shortcut in `shell:startup` (so it launches at login) +- Creates or removes the `shell:startup` shortcut to match `LaunchAtSignIn` (enabled by default) - Generates a `SnipIT.ico` on the fly -- Shows a tray balloon: *"SnipIT installed. Press Ctrl+Shift+S to capture."* +- Shows a tray balloon: *"SnipIT installed. Press Ctrl+Alt+Shift+Q to capture."* -To **uninstall**: right-click the tray icon → *Uninstall*. Removes both shortcuts and the `snipIT-Home` folder. +To **uninstall**: right-click the tray icon → *Uninstall*. Removes both shortcuts and the `%LOCALAPPDATA%\SnipIT` folder. ## Usage -After installation, SnipIT runs in the system tray. Press `Ctrl+Shift+S`, hover the window you want, click. The preview window opens with the captured image. From there: +After installation, SnipIT runs in the system tray. Press `Ctrl+Alt+Shift+Q`, hover the window you want, click. The preview window opens with the captured image. From there: - **Annotate** — click a tool, pick a color, drag on the image. Click the active tool again (or `Esc`) to return to pan mode. - **Zoom in to detail** — `Ctrl + mouse-wheel` or the zoom buttons; then drag the image to pan around. @@ -112,7 +113,7 @@ SnipIT.ps1 ├── #region Bootstrap ← STA self-relaunch, DPI, console hide, single-instance ├── #region PInvoke ← Win32 signatures (RegisterHotKey, DWM, etc.) ├── #region Icon generation ← Runtime .ico synthesis -├── #region First-Run Install ← Desktop + Startup shortcuts via WScript.Shell +├── #region First-Run Install ← LocalAppData install + settings-controlled shortcuts ├── #region Capture Core ← GDI+ bitmap capture / clipboard / save dialog ├── #region Smart Overlay ← WPF transparent overlay + hover + magnifier ├── #region Preview Window ← Fluent preview + annotation editor @@ -130,7 +131,7 @@ The preview window's mouse interaction, zoom, text-editing and color-picking are `Show-PreviewWindow` accepts an optional `-TestAction [scriptblock]` parameter that runs the callback during `Loaded` (while `ShowDialog` is blocking, so function-local variables stay alive) and then closes the window off-screen. The interactive harness uses this to run 42 end-to-end tests against a headless preview window. -Setting the environment variable `SNIPIT_TEST_MODE=1` before dot-sourcing `SnipIT.ps1` short-circuits the single-instance mutex, tray setup, hotkey registration and main loop, so a harness can load the functions without side effects. +Setting the environment variable `SNIPIT_TEST_MODE=1` before dot-sourcing `SnipIT.ps1` short-circuits installation and shortcut writes as well as the single-instance mutex, tray setup, hotkey registration, and main loop, so a harness can load the functions without side effects. ## Tests diff --git a/SnipIT.ps1 b/SnipIT.ps1 index 02689e0..b5bc033 100644 --- a/SnipIT.ps1 +++ b/SnipIT.ps1 @@ -784,9 +784,9 @@ function Write-SnipDiag { } $logDir = Split-Path $Path -Parent if (-not [string]::IsNullOrWhiteSpace($logDir)) { - New-Item -ItemType Directory -Force -Path $logDir | Out-Null + New-Item -ItemType Directory -Force -Path $logDir -ErrorAction Stop | Out-Null } - Add-Content -LiteralPath $Path -Value $line -Encoding utf8 + Add-Content -LiteralPath $Path -Value $line -Encoding utf8 -ErrorAction Stop } catch { # Diagnostics must never become a second application failure. } @@ -803,13 +803,14 @@ function Read-SnipSettings { $defaults = Get-SnipDefaultSettings -PicturesDir $PicturesDir $diagPath = Join-Path (Split-Path $Path -Parent) 'logs\snipit.log' - if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf -ErrorAction Stop)) { Write-SnipDiag -Message "Settings file not found; using defaults: $Path" -Path $diagPath return $defaults } try { - $loaded = Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json -ErrorAction Stop + $loaded = Get-Content -Raw -LiteralPath $Path -ErrorAction Stop | + ConvertFrom-Json -ErrorAction Stop } catch { Write-SnipDiag -Message "Settings file is malformed; using defaults: $Path" -ErrorRecord $_ -Path $diagPath return $defaults @@ -887,15 +888,16 @@ function Save-SnipSettings { $directory = Split-Path $Path -Parent if (-not [string]::IsNullOrWhiteSpace($directory)) { - New-Item -ItemType Directory -Force -Path $directory | Out-Null + New-Item -ItemType Directory -Force -Path $directory -ErrorAction Stop | Out-Null } $temporaryPath = Join-Path $directory ('.{0}.{1}.tmp' -f ([IO.Path]::GetFileName($Path)), [guid]::NewGuid()) try { - $Settings | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $temporaryPath -Encoding utf8NoBOM - Move-Item -LiteralPath $temporaryPath -Destination $Path -Force + $Settings | ConvertTo-Json -Depth 4 -ErrorAction Stop | + Set-Content -LiteralPath $temporaryPath -Encoding utf8NoBOM -ErrorAction Stop + Move-Item -LiteralPath $temporaryPath -Destination $Path -Force -ErrorAction Stop } finally { - if (Test-Path -LiteralPath $temporaryPath) { - Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $temporaryPath -ErrorAction Stop) { + Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction Stop } } } diff --git a/Test-SnipIT-Interactive.ps1 b/Test-SnipIT-Interactive.ps1 index cf7ac7b..7cfafc7 100644 --- a/Test-SnipIT-Interactive.ps1 +++ b/Test-SnipIT-Interactive.ps1 @@ -147,13 +147,19 @@ Describe 'Hotkey registration and replacement' { } It 'persists a successful replacement only after registration' { + Remove-Item -LiteralPath $settingsPath -Force -ErrorAction SilentlyContinue $context = [pscustomobject]@{ Settings = Get-SnipDefaultSettings -PicturesDir $pictures SettingsPath = $settingsPath RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } } $calls = [System.Collections.ArrayList]::new() - $register = { param($hwnd, $id, $mods, $vk) [void]$calls.Add($vk); $true } + $register = { + param($hwnd, $id, $mods, $vk) + Should-BeFalse (Test-Path -LiteralPath $settingsPath) + [void]$calls.Add($vk) + $true + } $result = Set-SnipHotkeyBinding -Context $context -Candidate @{ Modifiers = 0x4007; VirtualKey = 0x52 } -Register $register -Unregister { $true } Should-BeTrue $result.Success @@ -163,6 +169,49 @@ Describe 'Hotkey registration and replacement' { Should-Be (Read-SnipSettings -Path $settingsPath -PicturesDir $pictures).Hotkey.VirtualKey 0x52 } + It 'rolls back when persistence fails under Continue error semantics' { + $blockedParent = Join-Path $root 'blocked-parent' + Remove-Item -LiteralPath $blockedParent -Recurse -Force -ErrorAction SilentlyContinue + Set-Content -LiteralPath $blockedParent -Value 'this file cannot contain settings.json' + $blockedSettingsPath = Join-Path $blockedParent 'settings.json' + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings -PicturesDir $pictures + SettingsPath = $blockedSettingsPath + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + } + $originalSettings = $context.Settings | ConvertTo-Json -Depth 4 -Compress + $registerCalls = [System.Collections.ArrayList]::new() + $unregisterCalls = [System.Collections.ArrayList]::new() + $register = { param($hwnd, $id, $mods, $vk) [void]$registerCalls.Add($vk); $true } + $unregister = { param($hwnd, $id) [void]$unregisterCalls.Add($id); $true } + $priorErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $operationOutput = @(Set-SnipHotkeyBinding -Context $context ` + -Candidate @{ Modifiers = 0x4007; VirtualKey = 0x54 } ` + -Register $register -Unregister $unregister 2>&1) + } finally { + $ErrorActionPreference = $priorErrorActionPreference + } + $persistenceErrors = @($operationOutput | Where-Object { $_ -is [Management.Automation.ErrorRecord] }) + $result = @($operationOutput | Where-Object { $_ -isnot [Management.Automation.ErrorRecord] })[-1] + + Should-BeFalse $result.Success + Should-Be $result.ActiveBinding.VirtualKey 0x51 + Should-BeTrue (-not [string]::IsNullOrWhiteSpace($result.CandidateError)) + Should-Be $result.RollbackError $null + Should-Be $registerCalls.Count 2 + Should-Be $registerCalls[0] 0x54 + Should-Be $registerCalls[1] 0x51 + Should-Be $unregisterCalls.Count 2 + Should-Be $unregisterCalls[0] 1 + Should-Be $unregisterCalls[1] 1 + Should-Be @($persistenceErrors).Count 0 + Should-Be ($context.Settings | ConvertTo-Json -Depth 4 -Compress) $originalSettings + Should-Be $context.RegisteredHotkey.VirtualKey 0x51 + Should-BeFalse (Test-Path -LiteralPath $blockedSettingsPath) + } + It 'restores the previous binding when the candidate is rejected' { Remove-Item -LiteralPath $settingsPath -Force -ErrorAction SilentlyContinue $context = [pscustomobject]@{ From f08333637f027038d6c2c993b1e3978c52312159 Mon Sep 17 00:00:00 2001 From: Amit Date: Fri, 10 Jul 2026 20:02:12 +0800 Subject: [PATCH 11/42] refactor: serialize capture execution --- SnipIT.ps1 | 1086 ++++++++++++++++++++++++++++++----- Test-SnipIT-Interactive.ps1 | 218 ++++++- Test-SnipIT.ps1 | 355 +++++++++++- 3 files changed, 1489 insertions(+), 170 deletions(-) diff --git a/SnipIT.ps1 b/SnipIT.ps1 index b5bc033..7d758e9 100644 --- a/SnipIT.ps1 +++ b/SnipIT.ps1 @@ -8,7 +8,7 @@ Tray menu: Capture region / Capture full screen / Show widget / About / Uninstall / Exit - Run with -CoreOnly to dot-source only the pure logic functions (used by tests). + Run with -CoreOnly to dot-source cross-platform logic/contracts (used by tests). #> param([switch]$CoreOnly) @@ -741,7 +741,592 @@ function Get-SnipCoordinatorDecision { #endregion -# Tests dot-source this script with -CoreOnly to load only the pure functions above. +#region Capture Coordinator Contracts (cross-platform, injected) ============ + +function New-SnipCaptureRequest { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateSet('Smart', 'Full', 'Window')] + [string]$Mode, + + [timespan]$Delay = [timespan]::Zero, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$Source, + + [guid]$Id = [guid]::NewGuid(), + [datetimeoffset]$SubmittedAt = [datetimeoffset]::UtcNow + ) + + if ($Delay -lt [timespan]::Zero) { + throw [ArgumentOutOfRangeException]::new('Delay', $Delay, 'Capture delay cannot be negative.') + } + if ([string]::IsNullOrWhiteSpace($Source)) { + throw [ArgumentException]::new('Capture source cannot be blank.', 'Source') + } + + $normalizedMode = switch ($Mode.ToLowerInvariant()) { + 'smart' { 'Smart' } + 'full' { 'Full' } + 'window' { 'Window' } + } + [pscustomobject][ordered]@{ + Id = $Id + Mode = $normalizedMode + Delay = $Delay + Source = $Source + SubmittedAt = $SubmittedAt + } +} + +function Get-SnipCoordinatorService { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Coordinator, + [Parameter(Mandatory)] [string]$Name + ) + + $services = $Coordinator.Services + if ($null -eq $services) { return $null } + if ($services -is [System.Collections.IDictionary]) { + return $services[$Name] + } + $property = $services.PSObject.Properties[$Name] + if ($null -eq $property) { return $null } + $property.Value +} + +function Invoke-SnipResourceDispose { + [CmdletBinding()] + param([Parameter(Mandatory)] $Resource) + + $method = $Resource.PSObject.Methods['Dispose'] + if ($null -ne $method) { + $Resource.Dispose() + return + } + $property = $Resource.PSObject.Properties['Dispose'] + if ($null -ne $property -and $property.Value -is [scriptblock]) { + & $property.Value + } +} + +function Remove-SnipCoordinatorBitmap { + [CmdletBinding()] + param([Parameter(Mandatory)] $Coordinator) + + $owned = $Coordinator.OwnedBitmap + # Clear first so an exceptional Dispose cannot be attempted a second time + # by a recovery or shutdown path. + $Coordinator.OwnedBitmap = $null + if ($null -eq $owned) { return } + try { + Invoke-SnipResourceDispose -Resource $owned + } catch { + $Coordinator.LastError = $_ + } +} + +function ConvertTo-SnipSurfaceResult { + [CmdletBinding()] + param( + $Value, + [switch]$CaptureResult + ) + + $validResults = @('Completed', 'UserCancelled', 'Preempted', 'Failed', 'Shutdown') + if ($null -eq $Value) { + return [pscustomobject][ordered]@{ Result = 'UserCancelled'; Bitmap = $null; ErrorRecord = $null } + } + if ($Value -is [string]) { + if ($Value -notin $validResults) { + throw [InvalidOperationException]::new("Unknown surface result '$Value'.") + } + return [pscustomobject][ordered]@{ Result = $Value; Bitmap = $null; ErrorRecord = $null } + } + + $resultProperty = $Value.PSObject.Properties['Result'] + if ($null -ne $resultProperty) { + $result = [string]$resultProperty.Value + if ($result -notin $validResults) { + throw [InvalidOperationException]::new("Unknown surface result '$result'.") + } + $bitmapProperty = $Value.PSObject.Properties['Bitmap'] + $errorProperty = $Value.PSObject.Properties['ErrorRecord'] + return [pscustomobject][ordered]@{ + Result = $result + Bitmap = if ($null -ne $bitmapProperty) { $bitmapProperty.Value } else { $null } + ErrorRecord = if ($null -ne $errorProperty) { $errorProperty.Value } else { $null } + } + } + + if ($CaptureResult) { + return [pscustomobject][ordered]@{ Result = 'Completed'; Bitmap = $Value; ErrorRecord = $null } + } + throw [InvalidOperationException]::new('A modal surface must return a supported result value.') +} + +function New-SnipCaptureCoordinator { + [CmdletBinding()] + param( + [scriptblock]$Post = { param($work) & $work }, + $Services = $null, + $Settings = $null, + $RegisteredHotkey = $null + ) + + [pscustomobject][ordered]@{ + Phase = 'Idle' + ActiveRequest = $null + PendingRequest = $null + ActiveSurface = $null + OwnedBitmap = $null + PreviewWindow = $null + Settings = $Settings + RegisteredHotkey = $RegisteredHotkey + ShutdownRequested = $false + PumpScheduled = $false + Post = $Post + Services = $Services + DelayHandle = $null + LastResult = $null + LastError = $null + PumpRunning = $false + PumpDispatching = $false + PumpRescheduleRequested = $false + PumpDepth = 0 + MaxPumpDepth = 0 + SurfaceCloseRequested = $false + } +} + +function Invoke-SnipCancelDelay { + [CmdletBinding()] + param([Parameter(Mandatory)] $Coordinator) + + $handle = $Coordinator.DelayHandle + $Coordinator.DelayHandle = $null + if ($null -eq $handle) { return } + $cancel = Get-SnipCoordinatorService -Coordinator $Coordinator -Name CancelDelay + try { + if ($cancel -is [scriptblock]) { + & $cancel $handle + } else { + Invoke-SnipResourceDispose -Resource $handle + } + } catch { + $Coordinator.LastError = $_ + } +} + +function Send-SnipCapturePump { + [CmdletBinding()] + param([Parameter(Mandatory)] $Coordinator) + + if ($Coordinator.ShutdownRequested -or $Coordinator.PumpScheduled) { return } + $Coordinator.PumpScheduled = $true + $coordinatorForPost = $Coordinator + $work = { + # A synchronous test Post, or a WinForms BeginInvoke that runs through a + # nested modal dispatcher, may execute while an earlier posted work item + # is still active. Record a continuation instead of recursively pumping. + if ($coordinatorForPost.PumpDispatching) { + $coordinatorForPost.PumpRescheduleRequested = $true + return + } + $coordinatorForPost.PumpDispatching = $true + try { + do { + $coordinatorForPost.PumpRescheduleRequested = $false + $coordinatorForPost.PumpScheduled = $false + Invoke-SnipCapturePump -Coordinator $coordinatorForPost + } while ($coordinatorForPost.PumpRescheduleRequested -and + -not $coordinatorForPost.ShutdownRequested) + } finally { + $coordinatorForPost.PumpDispatching = $false + } + }.GetNewClosure() + & $Coordinator.Post $work +} + +function Request-SnipCapture { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Coordinator, + [ValidateSet('Smart', 'Full', 'Window')] [string]$Mode, + [timespan]$Delay = [timespan]::Zero, + [string]$Source, + $Request = $null, + [switch]$DelayElapsed + ) + + if ($Coordinator.ShutdownRequested -or $Coordinator.Phase -eq 'ShuttingDown') { + return $null + } + if ($null -eq $Request) { + if ([string]::IsNullOrWhiteSpace($Mode)) { + throw [ArgumentException]::new('Mode is required when Request is not supplied.', 'Mode') + } + if ([string]::IsNullOrWhiteSpace($Source)) { + throw [ArgumentException]::new('Source is required when Request is not supplied.', 'Source') + } + $Request = New-SnipCaptureRequest -Mode $Mode -Delay $Delay -Source $Source + } + + if ($DelayElapsed) { + if ($Coordinator.Phase -ne 'DelayPending' -or + $null -eq $Coordinator.ActiveRequest -or + $Coordinator.ActiveRequest.Id -ne $Request.Id) { + return $null + } + Invoke-SnipCancelDelay -Coordinator $Coordinator + $Coordinator.Phase = 'CaptureStarting' + Send-SnipCapturePump -Coordinator $Coordinator + return $Request + } + + $decision = Get-SnipCoordinatorDecision -Phase $Coordinator.Phase ` + -Event Submit -HasPending ($null -ne $Coordinator.PendingRequest) + if ($decision.Reject) { return $null } + + if ($Coordinator.Phase -eq 'Idle') { + $Coordinator.ActiveRequest = $Request + if ($Request.Delay -gt [timespan]::Zero) { + $Coordinator.Phase = 'DelayPending' + $startDelay = Get-SnipCoordinatorService -Coordinator $Coordinator -Name StartDelay + if ($startDelay -isnot [scriptblock]) { + $Coordinator.ActiveRequest = $null + $Coordinator.Phase = 'Idle' + throw [InvalidOperationException]::new('A delayed capture requires the StartDelay service.') + } + $coordinatorForDelay = $Coordinator + $requestForDelay = $Request + $elapsed = { + Request-SnipCapture -Coordinator $coordinatorForDelay ` + -Request $requestForDelay -DelayElapsed | Out-Null + }.GetNewClosure() + try { + $Coordinator.DelayHandle = & $startDelay $Request.Delay $elapsed $Request $Coordinator + } catch { + $Coordinator.LastError = $_ + $Coordinator.Phase = 'Recovering' + Send-SnipCapturePump -Coordinator $Coordinator + } + return $Request + } + $Coordinator.Phase = 'CaptureStarting' + Send-SnipCapturePump -Coordinator $Coordinator + return $Request + } + + if ($Coordinator.Phase -eq 'DelayPending') { + Invoke-SnipCancelDelay -Coordinator $Coordinator + $Coordinator.ActiveRequest = $Request + $Coordinator.PendingRequest = $null + # Per the approved transition table, a new request superseding a delay + # starts immediately even if the new request also carries a delay. + $Coordinator.Phase = 'CaptureStarting' + Send-SnipCapturePump -Coordinator $Coordinator + return $Request + } + + $Coordinator.PendingRequest = $Request + if ($decision.CloseSurface) { + Close-SnipActiveSurface -Coordinator $Coordinator -Result Preempted | Out-Null + } + $Request +} + +function Invoke-SnipPreviewTransfer { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Bitmap, + [Parameter(Mandatory)] [scriptblock]$Preview + ) + + $ownership = [pscustomobject]@{ Accepted = $false } + $accept = { + $ownership.Accepted = $true + }.GetNewClosure() + $result = 'Failed' + $errorRecord = $null + try { + $output = @(& $Preview $Bitmap $accept) + $value = if ($output.Count -gt 0) { $output[-1] } else { $null } + $surfaceResult = ConvertTo-SnipSurfaceResult -Value $value + $result = $surfaceResult.Result + $errorRecord = $surfaceResult.ErrorRecord + } catch { + $result = 'Failed' + $errorRecord = $_ + } finally { + if (-not $ownership.Accepted) { + try { Invoke-SnipResourceDispose -Resource $Bitmap } + catch { if ($null -eq $errorRecord) { $errorRecord = $_ } } + } + } + + [pscustomobject][ordered]@{ + Result = $result + OwnershipTransferred = [bool]$ownership.Accepted + ErrorRecord = $errorRecord + } +} + +function Close-SnipActiveSurface { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Coordinator, + [ValidateSet('Completed', 'UserCancelled', 'Preempted', 'Failed', 'Shutdown')] + [string]$Result = 'Preempted' + ) + + if ($Coordinator.Phase -eq 'DelayPending') { + Invoke-SnipCancelDelay -Coordinator $Coordinator + $Coordinator.LastResult = $Result + $Coordinator.ActiveRequest = $null + if ($null -ne $Coordinator.PendingRequest) { + $Coordinator.ActiveRequest = $Coordinator.PendingRequest + $Coordinator.PendingRequest = $null + $Coordinator.Phase = 'CaptureStarting' + Send-SnipCapturePump -Coordinator $Coordinator + } elseif ($Result -eq 'Shutdown') { + $Coordinator.Phase = 'ShuttingDown' + } else { + $Coordinator.Phase = 'Idle' + } + return $true + } + + if ($Coordinator.SurfaceCloseRequested) { return $true } + $surface = $Coordinator.ActiveSurface + if ($null -eq $surface -and $null -ne $Coordinator.PreviewWindow) { + $surface = $Coordinator.PreviewWindow + } + if ($null -eq $surface) { return $false } + + $Coordinator.SurfaceCloseRequested = $true + try { + $closeProperty = $surface.PSObject.Properties['Close'] + if ($null -ne $closeProperty -and $closeProperty.Value -is [scriptblock]) { + & $closeProperty.Value $Result + } elseif ($null -ne $surface.PSObject.Methods['Close']) { + $requestedProperty = $surface.PSObject.Properties['RequestedResult'] + if ($null -ne $requestedProperty) { $requestedProperty.Value = $Result } + $surface.Close() + } else { + $Coordinator.SurfaceCloseRequested = $false + return $false + } + } catch { + $Coordinator.LastError = $_ + $Coordinator.SurfaceCloseRequested = $false + return $false + } + $true +} + +function Complete-SnipSurface { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Coordinator, + [Parameter(Mandatory)] + [ValidateSet('Completed', 'UserCancelled', 'Preempted', 'Failed', 'Shutdown')] + [string]$Result, + [ValidateSet('Capture', 'Selection', 'Preview', 'Copy', 'Save')] + [string]$Operation = 'Preview' + ) + + $Coordinator.LastResult = $Result + if ($Result -eq 'Shutdown') { + Stop-SnipCaptureCoordinator -Coordinator $Coordinator + return + } + + # Output operations are deliberately non-preemptible. A successful Save + # (and today's keep-open Copy) returns to Preview unless a pending request + # exists, in which case the installed Preview surface is asked to close. + if ($Coordinator.Phase -eq 'Completing') { + if ($Result -eq 'Completed' -and $Operation -eq 'Save') { + $Coordinator.Phase = 'Previewing' + if ($null -ne $Coordinator.PendingRequest) { + Close-SnipActiveSurface -Coordinator $Coordinator -Result Preempted | Out-Null + } + return + } + if ($Result -in @('UserCancelled', 'Failed') -and + $Operation -in @('Copy', 'Save')) { + $Coordinator.Phase = 'Previewing' + if ($null -ne $Coordinator.PendingRequest) { + Close-SnipActiveSurface -Coordinator $Coordinator -Result Preempted | Out-Null + } + return + } + } + + Remove-SnipCoordinatorBitmap -Coordinator $Coordinator + $Coordinator.ActiveSurface = $null + $Coordinator.PreviewWindow = $null + $Coordinator.SurfaceCloseRequested = $false + $Coordinator.ActiveRequest = $null + + if ($Result -eq 'Failed') { + $Coordinator.Phase = 'Recovering' + Send-SnipCapturePump -Coordinator $Coordinator + return + } + + if ($null -ne $Coordinator.PendingRequest) { + $Coordinator.ActiveRequest = $Coordinator.PendingRequest + $Coordinator.PendingRequest = $null + $Coordinator.Phase = 'CaptureStarting' + Send-SnipCapturePump -Coordinator $Coordinator + } else { + $Coordinator.Phase = 'Idle' + } +} + +function Stop-SnipCaptureCoordinator { + [CmdletBinding()] + param([Parameter(Mandatory)] $Coordinator) + + # Idempotence here means every call may finish newly-available cleanup + # (for example after a modal surface returns), while one-shot handles and + # close signals are never repeated. + $Coordinator.ShutdownRequested = $true + $Coordinator.Phase = 'ShuttingDown' + $Coordinator.PendingRequest = $null + Invoke-SnipCancelDelay -Coordinator $Coordinator + Remove-SnipCoordinatorBitmap -Coordinator $Coordinator + Close-SnipActiveSurface -Coordinator $Coordinator -Result Shutdown | Out-Null + if ($null -eq $Coordinator.ActiveSurface -and $null -eq $Coordinator.PreviewWindow) { + $Coordinator.ActiveRequest = $null + } +} + +function Invoke-SnipCapturePump { + [CmdletBinding()] + param([Parameter(Mandatory)] $Coordinator) + + if ($Coordinator.PumpRunning) { + $Coordinator.PumpRescheduleRequested = $true + return + } + if ($Coordinator.ShutdownRequested) { return } + + if ($Coordinator.Phase -eq 'Recovering') { + if ($null -ne $Coordinator.PendingRequest) { + $Coordinator.ActiveRequest = $Coordinator.PendingRequest + $Coordinator.PendingRequest = $null + $Coordinator.Phase = 'CaptureStarting' + Send-SnipCapturePump -Coordinator $Coordinator + } else { + $Coordinator.ActiveRequest = $null + $Coordinator.Phase = 'Idle' + } + return + } + if ($Coordinator.Phase -ne 'CaptureStarting' -or + $null -eq $Coordinator.ActiveRequest) { + return + } + + $Coordinator.PumpRunning = $true + $Coordinator.PumpDepth++ + if ($Coordinator.PumpDepth -gt $Coordinator.MaxPumpDepth) { + $Coordinator.MaxPumpDepth = $Coordinator.PumpDepth + } + try { + $request = $Coordinator.ActiveRequest + $captureServiceName = switch ($request.Mode) { + 'Smart' { 'SmartCapture' } + 'Full' { 'FullCapture' } + 'Window' { 'WindowCapture' } + } + if ($request.Mode -eq 'Smart') { + # The overlay is modal, so publish Selecting before invoking it; + # its OnSurfaceReady callback then installs ActiveSurface. + $Coordinator.Phase = 'Selecting' + } + $captureService = Get-SnipCoordinatorService -Coordinator $Coordinator -Name $captureServiceName + if ($captureService -isnot [scriptblock]) { + Complete-SnipSurface -Coordinator $Coordinator -Result UserCancelled ` + -Operation $(if ($request.Mode -eq 'Smart') { 'Selection' } else { 'Capture' }) + return + } + + try { + $captureOutput = @(& $captureService $Coordinator $request) + $captureValue = if ($captureOutput.Count -gt 0) { $captureOutput[-1] } else { $null } + $captureResult = ConvertTo-SnipSurfaceResult -Value $captureValue -CaptureResult + } catch { + $Coordinator.LastError = $_ + Complete-SnipSurface -Coordinator $Coordinator -Result Failed ` + -Operation $(if ($request.Mode -eq 'Smart') { 'Selection' } else { 'Capture' }) + return + } + + $Coordinator.ActiveSurface = $null + $Coordinator.PreviewWindow = $null + $Coordinator.SurfaceCloseRequested = $false + if ($null -ne $captureResult.ErrorRecord) { $Coordinator.LastError = $captureResult.ErrorRecord } + if ($captureResult.Result -ne 'Completed') { + Complete-SnipSurface -Coordinator $Coordinator -Result $captureResult.Result ` + -Operation $(if ($request.Mode -eq 'Smart') { 'Selection' } else { 'Capture' }) + return + } + if ($null -eq $captureResult.Bitmap) { + $Coordinator.LastError = [InvalidOperationException]::new('Completed capture returned no bitmap.') + Complete-SnipSurface -Coordinator $Coordinator -Result Failed -Operation Capture + return + } + + $Coordinator.OwnedBitmap = $captureResult.Bitmap + if ($Coordinator.ShutdownRequested) { + Complete-SnipSurface -Coordinator $Coordinator -Result Shutdown -Operation Capture + return + } + if ($null -ne $Coordinator.PendingRequest) { + # CaptureStarting cannot be interrupted, but a stale completed + # bitmap is still coordinator-owned and must be disposed before the + # latest request is posted. + Complete-SnipSurface -Coordinator $Coordinator -Result Preempted -Operation Capture + return + } + + $Coordinator.Phase = 'Previewing' + $previewService = Get-SnipCoordinatorService -Coordinator $Coordinator -Name Preview + if ($previewService -isnot [scriptblock]) { + Complete-SnipSurface -Coordinator $Coordinator -Result UserCancelled -Operation Preview + return + } + $coordinatorForPreview = $Coordinator + $requestForPreview = $request + $previewInvoker = { + param($bitmap,$accept) + & $previewService $bitmap $accept $coordinatorForPreview $requestForPreview + }.GetNewClosure() + $transfer = Invoke-SnipPreviewTransfer -Bitmap $Coordinator.OwnedBitmap -Preview $previewInvoker + # Transfer either handed ownership to Preview or disposed the bitmap; + # in both cases the coordinator must forget it before any continuation. + $Coordinator.OwnedBitmap = $null + $Coordinator.ActiveSurface = $null + $Coordinator.PreviewWindow = $null + $Coordinator.SurfaceCloseRequested = $false + if ($null -ne $transfer.ErrorRecord) { $Coordinator.LastError = $transfer.ErrorRecord } + Complete-SnipSurface -Coordinator $Coordinator -Result $transfer.Result -Operation Preview + } finally { + $Coordinator.PumpDepth-- + $Coordinator.PumpRunning = $false + } +} + +#endregion + +# Tests dot-source with -CoreOnly to load pure helpers plus the injected, +# cross-platform coordinator contracts above, before any Windows bootstrap. if ($CoreOnly) { return } #region Bootstrap =========================================================== @@ -1430,17 +2015,41 @@ function Set-MicaBackdrop { #region Smart Overlay (hover + drag + magnifier) ============================ function Show-SmartOverlay { - $vs = Get-VirtualScreenBounds - # Hide SnipIT-owned chrome (widget, preview) before snapshotting the - # desktop, otherwise topmost SnipIT UI would get baked into the smart - # overlay's background image and into any region the user picks. - $hidden = Hide-OwnSnipITWindowsForCapture + [CmdletBinding()] + param( + [scriptblock]$OnSurfaceReady, + [scriptblock]$HideWindows = { Hide-OwnSnipITWindowsForCapture }, + [scriptblock]$RestoreWindows = { + param($hidden) + Show-OwnSnipITWindowsForCapture -Hidden $hidden + }, + [scriptblock]$CaptureFactory = { + param($bounds) + New-ScreenBitmap -X $bounds.X -Y $bounds.Y ` + -Width $bounds.Width -Height $bounds.Height + }, + [scriptblock]$BitmapSourceFactory = { + param($bitmap) + Convert-BitmapToBitmapSource $bitmap + } + ) + + $snap = $null + $cropped = $null + $graphics = $null + $win = $null + $overlayHelper = $null try { - $snap = New-ScreenBitmap -X $vs.X -Y $vs.Y -Width $vs.Width -Height $vs.Height - } finally { - Show-OwnSnipITWindowsForCapture -Hidden $hidden - } - $snapSrc = Convert-BitmapToBitmapSource $snap + $vs = Get-VirtualScreenBounds + # The overlay owns this frozen virtual snapshot on every result path. + # SnipIT chrome is restored even when allocation or conversion fails. + $hidden = & $HideWindows + try { + $snap = & $CaptureFactory $vs + } finally { + & $RestoreWindows $hidden + } + $snapSrc = & $BitmapSourceFactory $snap [xml]$xaml = @"