diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6cb5e75 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +*.ps1 text eol=lf +*.xaml text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 6c09665..c86807e 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -16,7 +16,8 @@ name: Security (OSS-CLI) # - Semgrep packs: `p/security-audit` + `p/owasp-top-ten` only. Semgrep # has no first-party PowerShell pack today; the language-specific gate # is PSScriptAnalyzer (added below) — codeiq-equivalent of `p/java`. -# - jscpd format set to `powershell`, scoped to the three .ps1 files. +# - jscpd format set to `powershell`, with generated release and authoritative +# development sources scanned separately to exclude intentional parity. # - Added `psscriptanalyzer` job — language lint per # `shared/runbooks/engineering-standards.md` (PowerShell variant). on: @@ -68,7 +69,8 @@ jobs: --config p/security-audit \ --config p/owasp-top-ten \ --severity ERROR \ - --metrics off + --metrics off \ + ./SnipIT.ps1 ./Build-SnipIT.ps1 ./SnipIT.Dev.ps1 ./src psscriptanalyzer: name: PSScriptAnalyzer (Error severity gate) @@ -84,19 +86,41 @@ jobs: shell: pwsh run: | Import-Module PSScriptAnalyzer - $w = Invoke-ScriptAnalyzer -Path ./SnipIT.ps1 -Severity Warning + $paths = @( + './SnipIT.ps1' + './Build-SnipIT.ps1' + './SnipIT.Dev.ps1' + './scripts/Export-SnipITModules.ps1' + ) + @(Get-ChildItem -LiteralPath ./src -Filter *.ps1 -File -Recurse | + Sort-Object FullName | ForEach-Object FullName) + $w = foreach ($path in $paths) { + Invoke-ScriptAnalyzer -Path $path -Severity Warning | ForEach-Object { + $_ | Add-Member -NotePropertyName SourcePath -NotePropertyValue $path -PassThru + } + } Write-Host "Warning count: $((@($w)).Count)" - $w | Group-Object RuleName | Sort-Object Count -Descending | - Select-Object Count, Name | Format-Table -AutoSize | Out-String | Write-Host + $w | Format-Table -AutoSize SourcePath, RuleName, Line, Message | + Out-String | Write-Host - name: Fail on Error-severity findings shell: pwsh run: | Import-Module PSScriptAnalyzer - $errs = Invoke-ScriptAnalyzer -Path ./SnipIT.ps1 -Severity Error + $paths = @( + './SnipIT.ps1' + './Build-SnipIT.ps1' + './SnipIT.Dev.ps1' + './scripts/Export-SnipITModules.ps1' + ) + @(Get-ChildItem -LiteralPath ./src -Filter *.ps1 -File -Recurse | + Sort-Object FullName | ForEach-Object FullName) + $errs = foreach ($path in $paths) { + Invoke-ScriptAnalyzer -Path $path -Severity Error | ForEach-Object { + $_ | Add-Member -NotePropertyName SourcePath -NotePropertyValue $path -PassThru + } + } $count = (@($errs)).Count Write-Host "Error count: $count" if ($count -gt 0) { - $errs | Format-Table -AutoSize Severity, RuleName, Line, Message | + $errs | Format-Table -AutoSize SourcePath, Severity, RuleName, Line, Message | Out-String | Write-Host exit 1 } @@ -139,15 +163,6 @@ jobs: with: node-version: '20' - run: | - # snipIT is three PowerShell files at repo root: - # - SnipIT.ps1 (production) - # - Test-SnipIT.ps1 (headless tests) - # - Test-SnipIT-Interactive.ps1 (interactive tests) - # - # Production-only scope per AC interpretation (matches codeiq - # convention where tests are excluded from the dup gate). Tests - # share fixture / Assert-* shape by design. - # # `--min-tokens 100` is calibrated to PowerShell's medium verbosity # floor (Add-Type/[CmdletBinding] blocks, `param()` headers, P/Invoke # signatures). The Java floor of 200 over-suppresses; the jscpd @@ -161,7 +176,17 @@ jobs: --reporters consoleFull \ --format "powershell" \ --ignore "**/Test-SnipIT*.ps1,**/docs/**" \ - ./SnipIT.ps1 + --max-size 1mb --max-lines 20000 ./SnipIT.ps1 + # Scan authoritative development inputs separately so their + # intentional parity with the generated release is not a clone. + npx --yes jscpd@4 \ + --threshold 3 \ + --min-tokens 100 \ + --reporters consoleFull \ + --format "powershell" \ + --ignore "**/Test-SnipIT*.ps1,**/docs/**" \ + --max-size 1mb --max-lines 20000 \ + ./src ./Build-SnipIT.ps1 ./SnipIT.Dev.ps1 sbom: name: SBOM (SPDX + CycloneDX) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1960649..a7ff1b2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,14 +24,29 @@ jobs: contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2 - - name: Install PowerShell 7.5+ (SnipIT.ps1 requires it) + - name: Install PowerShell 7.5.7 shell: bash run: | - dotnet tool install --global PowerShell + dotnet tool install --global PowerShell --version 7.5.7 echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" - - name: Show pwsh version - shell: bash - run: pwsh --version + - name: Verify PowerShell 7.5 on .NET 9 + shell: pwsh + run: | + Write-Host "pwsh executable: $((Get-Process -Id $PID).Path)" + Write-Host "PowerShell: $($PSVersionTable.PSVersion)" + Write-Host ".NET runtime: $([Environment]::Version)" + if (-not ($PSVersionTable.PSVersion -eq [version]'7.5.7')) { + throw "Expected PowerShell 7.5.7, got $($PSVersionTable.PSVersion)" + } + if ([Environment]::Version.Major -ne 9) { + throw "Expected .NET 9, got $([Environment]::Version)" + } + - name: Rebuild generated release and reject stale output + shell: pwsh + run: | + pwsh -NoProfile -File ./Build-SnipIT.ps1 + git diff --exit-code -- SnipIT.ps1 + pwsh -NoProfile -File ./Test-SnipIT-Build.ps1 - name: Run headless tests shell: bash run: pwsh -NoProfile -File ./Test-SnipIT.ps1 @@ -47,27 +62,55 @@ jobs: contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2 - - name: Install PowerShell 7.5+ (SnipIT.ps1 requires it) + - name: Install PowerShell 7.5.7 shell: pwsh run: | - dotnet tool install --global PowerShell + dotnet tool install --global PowerShell --version 7.5.7 "$HOME\.dotnet\tools" | Out-File -Append -FilePath $env:GITHUB_PATH -Encoding utf8 - - name: Show pwsh version - shell: bash - run: pwsh --version - - name: Parse script (AST parser works with any pwsh version) - shell: bash + - name: Verify PowerShell 7.5 on .NET 9 + shell: pwsh + run: | + Write-Host "pwsh executable: $((Get-Process -Id $PID).Path)" + Write-Host "PowerShell: $($PSVersionTable.PSVersion)" + Write-Host ".NET runtime: $([Environment]::Version)" + if (-not ($PSVersionTable.PSVersion -eq [version]'7.5.7')) { + throw "Expected PowerShell 7.5.7, got $($PSVersionTable.PSVersion)" + } + if ([Environment]::Version.Major -ne 9) { + throw "Expected .NET 9, got $([Environment]::Version)" + } + - name: Rebuild generated release and reject stale output + shell: pwsh run: | - pwsh -NoProfile -Command " - \$errors = \$null - [System.Management.Automation.Language.Parser]::ParseFile( - (Resolve-Path ./SnipIT.ps1), [ref]\$null, [ref]\$errors) | Out-Null - if (\$errors) { - \$errors | ForEach-Object { Write-Host \" \$(\$_.Message) at line \$(\$_.Extent.StartLineNumber)\" } - exit 1 + pwsh -NoProfile -File ./Build-SnipIT.ps1 + git diff --exit-code -- SnipIT.ps1 + pwsh -NoProfile -File ./Test-SnipIT-Build.ps1 + - name: Parse every tracked PowerShell script + shell: pwsh + run: | + $parseFailures = [Collections.Generic.List[string]]::new() + $trackedScripts = @(git ls-files -- '*.ps1') | Sort-Object + foreach ($path in $trackedScripts) { + $tokens = $null + $errors = $null + [void][Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path -LiteralPath $path), [ref]$tokens, [ref]$errors) + foreach ($errorRecord in @($errors)) { + $parseFailures.Add(('{0}:{1}: {2}' -f $path, + $errorRecord.Extent.StartLineNumber, $errorRecord.Message)) } - Write-Host 'Parsed cleanly.' - " + } + if ($parseFailures.Count) { + $parseFailures | ForEach-Object { Write-Host $_ } + exit 1 + } + Write-Host "Parsed $($trackedScripts.Count) tracked PowerShell scripts cleanly." - name: Run headless tests on Windows shell: bash run: pwsh -NoProfile -File ./Test-SnipIT.ps1 + - name: Smoke-test modular development launcher + shell: pwsh + env: + SNIPIT_SCRIPT_UNDER_TEST: ./SnipIT.Dev.ps1 + SNIPIT_TEST_GROUP: Floating Studio preview shell + run: pwsh -NoProfile -Sta -File ./Test-SnipIT-Interactive.ps1 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 *~ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7c4b135 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,114 @@ +# snipIT — Agent brief + +Read this at session start. It is the standing context for any agent touching this repo. + +## What it is + +A **professional snipping tool** for Windows 11 written in **pure PowerShell +7.5+** on **.NET 9**. Smart hover-to-highlight capture, magnifier loupe, +floating widget, system tray, chromeless WPF Fluent preview, and a full +annotation editor — zero external runtime dependencies and no admin elevation. + +**Board reversal (accepted 2026-07-12):** modular `src/` PowerShell and +external `xaml/` files are now the authoritative development source. +`SnipIT.ps1` remains the headline single-file distribution and is generated +deterministically. Never edit `SnipIT.ps1` directly. + +## Repo layout + +```text +src/ +├── 00-Core.ps1 pure cross-platform logic and -CoreOnly boundary +├── 10-Bootstrap.ps1 startup, settings, install, XAML loader +├── 20-Native.ps1 Win32 interop and native window services +├── 30-Capture.ps1 capture coordinator, bitmaps, overlays +├── 40-Preview.ps1 preview and annotation editor +├── 50-Tray.ps1 tray, settings/about windows, floating widget +└── 90-Main.ps1 application entry point and cleanup +xaml/ six authoritative WPF surfaces/resources +Build-SnipIT.ps1 sole manifest/order authority and release generator +SnipIT.Dev.ps1 repository development launcher +SnipIT.ps1 generated checked-in standalone distribution +Test-SnipIT-Build.ps1 deterministic-build and failure fixtures +Test-SnipIT.ps1 pure-logic tests (Linux + Windows, no Pester) +Test-SnipIT-Interactive.ps1 WPF integration tests (Windows only) +scripts/Export-SnipITModules.ps1 retained AST/provenance round-trip tool +README.md install, usage, architecture, contributor workflow +SECURITY.md disclosure policy and scope +shared/runbooks/engineering-standards.md PowerShell engineering runbook +``` + +The generated `SnipIT.ps1` must stay portable: no runtime lookup of `src/`, +`xaml/`, the builder, repository paths, external modules, or network resources. + +## Build / test / run + +| Action | Command | +|---|---| +| Run the distribution | `pwsh -Sta -File ./SnipIT.ps1` | +| Run development sources | `pwsh -Sta -File ./SnipIT.Dev.ps1` | +| Regenerate distribution | `pwsh -NoProfile -File ./Build-SnipIT.ps1` | +| Reject stale distribution | `git diff --exit-code -- SnipIT.ps1` | +| Build contract tests | `pwsh -NoProfile -File ./Test-SnipIT-Build.ps1` | +| Release pure tests | `pwsh -NoProfile -File ./Test-SnipIT.ps1` | +| Development pure tests | `$env:SNIPIT_SCRIPT_UNDER_TEST='./SnipIT.Dev.ps1'; pwsh -NoProfile -File ./Test-SnipIT.ps1` | +| Release WPF tests | `pwsh -NoProfile -Sta -File ./Test-SnipIT-Interactive.ps1` | +| Development WPF smoke | `$env:SNIPIT_SCRIPT_UNDER_TEST='./SnipIT.Dev.ps1'; $env:SNIPIT_TEST_GROUP='Floating Studio preview shell'; pwsh -NoProfile -Sta -File ./Test-SnipIT-Interactive.ps1` | + +Always regenerate after changing `src/` or `xaml/`. Commit the source change and +generated release together. CI rebuilds and rejects any stale `SnipIT.ps1`. + +## Conventions + +- **PowerShell 7.5+ only.** No PowerShell 5.1 fallbacks. +- Functions use approved `Verb-Noun` PascalCase names. Use `[CmdletBinding()]` + and `param()` for anything with more than one argument. +- Pure helpers belong in `src/00-Core.ps1`; `-CoreOnly` must remain usable on + Linux without loading WPF, WinForms, or other Windows-only assemblies. +- The builder is the only module/XAML order authority. Generated provenance + uses manifest-relative forward-slash paths and never absolute paths. +- Preview mouse handlers are named closures captured by `New-SnipPreviewWindow`; + real WPF handlers remain one-line wrappers. Tests drive these closures through + the `Show-PreviewWindow -TestAction` seam. +- `SNIPIT_SCRIPT_UNDER_TEST` redirects either test harness to the development + launcher. Keep injection step-scoped; release tests must remain release tests. +- `SNIPIT_TEST_MODE=1` suppresses mutex, install, tray, hotkeys, and the main + loop so either launch path can be dot-sourced without side effects. +- All commits on `main` are signed. Run `scripts/setup-git-signed.sh` once in a + fresh worktree; Task commits must use signing as required by the workflow. + +## Engineering standards + +The repo follows `shared/runbooks/engineering-standards.md`. + +- Merge gates: deterministic build plus stale-release diff, tests, AST parse, + **PSScriptAnalyzer Error**, **Trivy HIGH/CRITICAL**, **Semgrep ERROR**, + **Gitleaks**, **jscpd < 3%**, OpenSSF Best Practices `passing`, and signed commits. +- SBOM, OpenSSF Scorecard, and Dependabot are surfaces, not merge gates. +- **OSS-CLI only.** No Sonar, CodeQL, or NVD-direct tools. +- Every GitHub Action reference must remain pinned to a full commit SHA. + +OpenSSF Best Practices project: . +Scorecard workflow is observational; best-effort target is at least 8.0/10 and +material regressions create a security chore rather than blocking a PR. + +## Gotchas + +- **Capture loop ownership.** `Invoke-CaptureLoop` owns every captured bitmap. + Preview disposes it on close, and `CaptureFactory` creates a fresh bitmap per + iteration. Never dispose inside the factory or reuse a preallocated bitmap. +- **SnipIT-window exclusion.** Register every new top-level SnipIT window through + `Hide-OwnSnipITWindowsForCapture` so it is excluded from captured frames. +- **Per-monitor DPI.** Capture math supports mixed scaling and negative virtual + desktop origins. Never assume `(0,0)` is the virtual desktop top-left. +- **Single instance.** A second launch notifies and exits unless + `SNIPIT_TEST_MODE=1` is set. +- **Generated-file ownership.** Do not patch `SnipIT.ps1`; change authoritative + source and run `Build-SnipIT.ps1`. +- **Pinned dependencies.** Never replace a full action SHA with a tag such as + `actions/checkout@v4`. + +## Issue tracker + +Paperclip uses prefix `RAN`. Link tickets as `[RAN-XX](/RAN/issues/RAN-XX)` in +PR descriptions and code comments. diff --git a/Build-SnipIT.ps1 b/Build-SnipIT.ps1 new file mode 100644 index 0000000..a8719c5 --- /dev/null +++ b/Build-SnipIT.ps1 @@ -0,0 +1,254 @@ +#requires -Version 7.5 +[CmdletBinding(DefaultParameterSetName = 'Build')] +param( + [Parameter(ParameterSetName = 'Manifest')] + [switch]$ManifestOnly, + + [Parameter(ParameterSetName = 'Build')] + [string]$OutputPath = (Join-Path $PSScriptRoot 'SnipIT.ps1') +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-SnipSourceManifest { + [CmdletBinding()] + param() + + [pscustomobject][ordered]@{ + Modules = @( + 'src/00-Core.ps1' + 'src/10-Bootstrap.ps1' + 'src/20-Native.ps1' + 'src/30-Capture.ps1' + 'src/40-Preview.ps1' + 'src/50-Tray.ps1' + 'src/90-Main.ps1' + ) + Xaml = [ordered]@{ + ThemeResources = 'xaml/ThemeResources.xaml' + PreviewWindow = 'xaml/PreviewWindow.xaml' + SmartOverlay = 'xaml/SmartOverlay.xaml' + SettingsWindow = 'xaml/SettingsWindow.xaml' + AboutWindow = 'xaml/AboutWindow.xaml' + FloatingWidget = 'xaml/FloatingWidget.xaml' + } + } +} + +function Resolve-SnipManifestPath { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$RelativePath + ) + + if ([string]::IsNullOrWhiteSpace($RelativePath) -or + [IO.Path]::IsPathRooted($RelativePath) -or + @($RelativePath -split '[\\/]').Contains('..')) { + throw "Invalid rooted or traversing SnipIT manifest path: $RelativePath" + } + + $repositoryRoot = [IO.Path]::GetFullPath($PSScriptRoot).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar) + $resolved = [IO.Path]::GetFullPath((Join-Path $repositoryRoot $RelativePath)) + $rootPrefix = $repositoryRoot + [IO.Path]::DirectorySeparatorChar + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } + else { + [StringComparison]::Ordinal + } + if (-not $resolved.StartsWith($rootPrefix, $comparison)) { + throw "Invalid rooted or traversing SnipIT manifest path: $RelativePath" + } + + $componentPaths = [Collections.Generic.List[string]]::new() + $componentPath = $repositoryRoot + $componentPaths.Add($componentPath) + $relativeResolved = [IO.Path]::GetRelativePath($repositoryRoot, $resolved) + foreach ($component in @($relativeResolved -split '[\\/]')) { + $componentPath = Join-Path $componentPath $component + $componentPaths.Add($componentPath) + } + foreach ($path in $componentPaths) { + if (-not (Test-Path -LiteralPath $path)) { + continue + } + $item = Get-Item -LiteralPath $path -Force + $linkTypeProperty = $item.PSObject.Properties['LinkType'] + $linkType = if ($null -eq $linkTypeProperty) { + $null + } + else { + [string]$linkTypeProperty.Value + } + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + -not [string]::IsNullOrEmpty($linkType)) { + throw ("SnipIT manifest path uses a reparse point or symbolic link: " + + "$RelativePath (component: $path)") + } + } + + $resolved +} + +function ConvertTo-SnipLfText { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string]$Text + ) + + ($Text -replace "`r`n", "`n") -replace "`r", "`n" +} + +function ConvertTo-SnipEmbeddedXaml { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [Collections.Specialized.OrderedDictionary]$Xaml + ) + + $lines = [Collections.Generic.List[string]]::new() + $lines.Add('$script:SnipEmbeddedXaml = [ordered]@{') + foreach ($entry in $Xaml.GetEnumerator()) { + if (@($entry.Value -split "`n").Contains("'@")) { + throw "XAML for '$($entry.Key)' contains the reserved here-string delimiter line: '@" + } + + $lines.Add(" $($entry.Key) = @'") + $lines.Add($entry.Value) + $lines.Add("'@") + } + $lines.Add('}') + + $lines -join "`n" +} + +function Read-SnipBuildSource { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$RelativePath, + [Parameter(Mandatory)][Text.UTF8Encoding]$Encoding + ) + + $sourcePath = Resolve-SnipManifestPath -RelativePath $RelativePath + if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { + throw "Missing SnipIT build source: $sourcePath" + } + + try { + ConvertTo-SnipLfText ([IO.File]::ReadAllText($sourcePath, $Encoding)) + } + catch [Text.DecoderFallbackException] { + throw "Invalid UTF-8 SnipIT build source: $RelativePath ($sourcePath)" + } +} + +function Assert-SnipManifest { + [CmdletBinding()] + param([Parameter(Mandatory)]$Manifest) + + $modulePaths = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase) + foreach ($relativePath in $Manifest.Modules) { + if (-not $modulePaths.Add($relativePath)) { + throw "Duplicate SnipIT manifest module: $relativePath" + } + [void](Resolve-SnipManifestPath -RelativePath $relativePath) + } + + $xamlPaths = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase) + foreach ($entry in $Manifest.Xaml.GetEnumerator()) { + if (-not $xamlPaths.Add([string]$entry.Value)) { + throw "Duplicate SnipIT manifest XAML source: $($entry.Value)" + } + [void](Resolve-SnipManifestPath -RelativePath $entry.Value) + } +} + +$manifest = Get-SnipSourceManifest +if ($ManifestOnly) { + return $manifest +} + +Assert-SnipManifest -Manifest $manifest + +$utf8 = [Text.UTF8Encoding]::new($false, $true) +$moduleSources = [ordered]@{} +$xamlSources = [ordered]@{} + +foreach ($relativePath in $manifest.Modules) { + $moduleText = Read-SnipBuildSource -RelativePath $relativePath -Encoding $utf8 + $tokens = $null + $parseErrors = $null + [void][Management.Automation.Language.Parser]::ParseInput( + $moduleText, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count) { + $sourcePath = Resolve-SnipManifestPath -RelativePath $relativePath + throw "PowerShell parse failed for $relativePath ($sourcePath): $($parseErrors.Message -join '; ')" + } + $moduleSources[$relativePath] = $moduleText +} + +foreach ($entry in $manifest.Xaml.GetEnumerator()) { + $xamlText = Read-SnipBuildSource -RelativePath $entry.Value -Encoding $utf8 + try { + [void][xml]$xamlText + } + catch { + throw "Invalid XAML '$($entry.Key)' from $($entry.Value): $($_.Exception.Message)" + } + $xamlSources[$entry.Key] = $xamlText +} + +$embedMarker = '# ' +$sections = [Collections.Generic.List[string]]::new() +foreach ($relativePath in $manifest.Modules) { + $header = '# source: {0}' -f $relativePath.Replace('\', '/') + $sections.Add($header + "`n" + $moduleSources[$relativePath].TrimEnd("`n")) + if ($relativePath -eq 'src/00-Core.ps1') { + $sections.Add($embedMarker) + } +} +$composition = (($sections -join "`n`n").TrimEnd("`n")) + "`n" +$markerMatches = [regex]::Matches($composition, [regex]::Escape($embedMarker)) +if ($markerMatches.Count -ne 1) { + throw "XAML embed marker must occur exactly once; found $($markerMatches.Count)" +} +$content = $composition.Replace( + $embedMarker, + (ConvertTo-SnipEmbeddedXaml -Xaml $xamlSources)) +if ($content.Contains($embedMarker)) { + throw 'Unresolved XAML embed marker remains in generated SnipIT release' +} +$content = $content.TrimEnd("`n") + "`n" + +$tokens = $null +$parseErrors = $null +[void][Management.Automation.Language.Parser]::ParseInput( + $content, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count) { + throw "Generated SnipIT PowerShell parse failed: $($parseErrors.Message -join '; ')" +} + +$resolvedOutput = [IO.Path]::GetFullPath($OutputPath) +$outputDirectory = [IO.Path]::GetDirectoryName($resolvedOutput) +if (-not [IO.Directory]::Exists($outputDirectory)) { + throw "SnipIT build output directory does not exist: $outputDirectory" +} + +$temporaryPath = "$resolvedOutput.tmp.$PID" +try { + [IO.File]::WriteAllText($temporaryPath, $content, [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $temporaryPath -Destination $resolvedOutput -Force +} +finally { + if (Test-Path -LiteralPath $temporaryPath) { + Remove-Item -LiteralPath $temporaryPath -Force + } +} diff --git a/CLAUDE.md b/CLAUDE.md index adbc9f5..2102f2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,100 +1,8 @@ -# snipIT — Agent brief - -Read this at session start. It is the standing context for any agent touching this repo. - -## What it is - -A **professional snipping tool** for Windows 11 written in **pure PowerShell 7.5+** on **.NET 9**. Smart hover-to-highlight capture, magnifier loupe, floating widget, system tray, chromeless WPF Fluent preview with a full annotation editor — **single script, zero external runtime dependencies, no admin elevation**. - -The single-file shape (`SnipIT.ps1`) is a **headline product feature** — do not split it into modules without explicit board reversal. - -## Repo layout - -``` -SnipIT.ps1 113K the whole app (regions: Core / Bootstrap / PInvoke / Capture / Preview / Tray / Main) -Test-SnipIT.ps1 29K 40 pure-logic unit tests (Linux + Windows pwsh, no Pester) -Test-SnipIT-Interactive.ps1 26K 42 WPF integration tests against a real off-screen preview window (Windows only) -README.md install / hotkeys / usage / architecture (also: badges) -SECURITY.md disclosure policy + scope -LICENSE MIT — Amit Kumar -CLAUDE.md this file -.bestpractices.json OpenSSF Best Practices self-assessment (project_id 12647) -shared/runbooks/engineering-standards.md PowerShell variant of the company runbook -scripts/setup-git-signed.sh one-shot signed-commit setup for a fresh worktree -docs/ design notes + screenshots -.github/ -├── workflows/ -│ ├── test.yml headless tests (Linux + Windows) + Windows AST parse -│ ├── security.yml OSS-CLI stack: PSScriptAnalyzer · Trivy · Semgrep · Gitleaks · jscpd · SBOM -│ └── scorecard.yml OpenSSF Scorecard (push to main + Mondays 06:00 UTC) -└── dependabot.yml github-actions ecosystem, weekly, grouped -``` - -## Build / test / run - -snipIT has no compile or package step — `SnipIT.ps1` *is* the deliverable. - -| Action | Command | -|---|---| -| Run the app | `pwsh -Sta -File ./SnipIT.ps1` (Windows; double-click also works) | -| Headless unit tests (any platform) | `pwsh -NoProfile -File ./Test-SnipIT.ps1` | -| Interactive WPF tests (Windows only) | `pwsh -NoProfile -Sta -File ./Test-SnipIT-Interactive.ps1` | -| PSScriptAnalyzer (lint) | `pwsh -c "Invoke-ScriptAnalyzer -Path ./SnipIT.ps1 -Severity Error"` | -| Parse-only (Windows) | `pwsh -NoProfile -Command "[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path ./SnipIT.ps1), [ref]\$null, [ref]\$errors)"` | - -CI runs the same matrix on every PR (see `.github/workflows/test.yml`). - -## Conventions - -- **PowerShell 7.5+ only.** No PS5.1 fallbacks, no `Add-Type` shims that only compile on Windows PowerShell. -- Functions: `Verb-Noun` PascalCase, [approved verbs](https://learn.microsoft.com/powershell/scripting/developer/cmdlet/approved-verbs-for-windows-powershell-commands), `[CmdletBinding()]` + `param()` for anything > 1 arg. -- The `Core` region exports 10 pure functions designed to run on Linux pwsh. Adding a new pure helper? Put it in `Core` so the test suite picks it up via `-CoreOnly`. -- Preview-window mouse handlers are **named closures** captured at window-creation time (see `Build-PreviewWindow` and the closures `$beginPan`, `$pickColor`, `$handleMouseDown`, …). The real WPF event handlers are one-line wrappers that call them. Tests drive every code path through these closures via the `-TestAction` hook on `Show-PreviewWindow`. -- `SNIPIT_TEST_MODE=1` short-circuits the single-instance mutex, tray, hotkeys, and main loop so a harness can dot-source `SnipIT.ps1` without side effects. -- All commits on `main` are **signed** (SSH key, registered as both auth + signing key on GitHub). Run `scripts/setup-git-signed.sh` once per worktree. - -## Engineering standards - -The repo follows `shared/runbooks/engineering-standards.md` (PowerShell variant of the company canonical at `/home/dev/.paperclip/instances/default/companies/31b9e445-1e14-45b6-9457-cfbb5cb17144/shared/runbooks/engineering-standards.md`). TL;DR: - -- Quality gates that block merge: tests, AST parse, **PSScriptAnalyzer Error**, **Trivy HIGH/CRITICAL**, **Semgrep ERROR**, **Gitleaks**, **jscpd < 3%**, **signed commits**. -- Surfaces only (do not block merge): SBOM, OpenSSF Scorecard score, Dependabot PRs. -- **OSS-CLI only.** No Sonar, no CodeQL, no NVD-direct tools (no PowerShell pack for CodeQL today; Semgrep + PSScriptAnalyzer cover SAST + lint). - -## OpenSSF Scorecard — baseline + target - -| Aspect | Value | -|---|---| -| **Workflow** | `.github/workflows/scorecard.yml` (push to `main` + weekly Mondays 06:00 UTC + manual dispatch) | -| **Engine** | `ossf/scorecard-action@v2.4.3` (SHA-pinned to `4eaacf0543bb3f2c246792bd56e8cdeffafb205a`) | -| **Output** | SARIF → GitHub Security tab + public dashboard at | -| **Baseline (RAN-54 land)** | TBD — first scoreboard score will land on the first push to `main` after this PR merges. Recorded here in the next PR that touches CLAUDE.md. | -| **Target** | **Best-effort, do not regress.** Stretch ≥ 8.0 / 10. **Best Practices `passing` is the only OpenSSF gate that blocks merge** — Scorecard is observational. (Per `shared/runbooks/engineering-standards.md` §1 and the company runbook §9b.) | -| **Configured-for-pass checks (RAN-54 bootstrap)** | Branch-Protection (per §7), Code-Review (TechLead via Codex), Signed-Releases (`tag.gpgsign=true`), Dependency-Update-Tool (Dependabot, weekly, grouped), Pinned-Dependencies (every action SHA-pinned), CI-Tests (test.yml + security.yml required), CII-Best-Practices (`.bestpractices.json` + project 12647), Dangerous-Workflow (no `pull_request_target` + untrusted-checkout), License (MIT at root), Maintained (active commit cadence), Packaging (no public binary publish — script is the package), SAST (Semgrep + PSScriptAnalyzer), Security-Policy (`SECURITY.md`), Token-Permissions (`permissions: read-all` top-level), Vulnerabilities (Trivy gate), Webhooks (none configured). | -| **Known not-a-pass (and why)** | Packaging — snipIT does not publish a versioned binary artifact (the script is the deliverable; `git clone` + run is the install path). Scorecard's `Packaging` check looks for a published-via-CI release; absent here by design until a tagged-release flow is added. | - -A **material** Scorecard regression on a PR files a follow-up chore (`type:chore`, `area:security`); it does **not** block the PR. - -## OpenSSF Best Practices - -- Project page: . -- In-repo self-assessment: `.bestpractices.json` (project_id 12647, target `passing`). -- **`passing` is the only OpenSSF gate that blocks merge.** Any PR that would fail a passing-level criterion is blocked at review. - -## Live integrations - -- GitHub repo: (public, MIT, secret-scanning + push-protection on). -- Paperclip Project: `snipIT` (id `e6a01833-e7df-4068-849c-6a7c5154b70c`). -- Paperclip parent issue: [RAN-50](/RAN/issues/RAN-50) — OpenSSF rollout across all 5 paperclip projects. - -## Gotchas - -- **Capture loop ownership.** `Invoke-CaptureLoop` (RAN-14 contract) takes ownership of each captured `System.Drawing.Bitmap` — the preview disposes it on close, the loop creates a fresh one for each iteration via the `CaptureFactory` closure. Do not dispose the bitmap inside the factory or pre-allocate one outside the loop. -- **SnipIT-window exclusion in capture.** The RAN-15 fix (shipped in v0.1.1) excludes the SnipIT widget / preview / tray windows from the capture targets. If you add a new top-level window, register it via `Hide-OwnSnipITWindowsForCapture` so it's not baked into the frame. -- **Per-monitor DPI.** Capture math is DPI-aware on virtual desktops with mixed scaling. Negative-origin layouts (monitor to the left of the primary) are handled in `Get-VirtualScreenBounds`; do not assume `(0,0)` is the top-left of the virtual desktop. -- **Single-instance mutex.** A second launch shows a friendly notification and exits — *unless* `SNIPIT_TEST_MODE=1` is set (test-harness escape hatch). -- **`actions/checkout@v4` vs SHA-pin.** Workflows in this repo MUST pin every action by commit SHA (Scorecard `Pinned-Dependencies`). Dependabot opens routine bumps; do not manually downgrade to a tag-ref. - -## Issue tracker - -Paperclip. Issue prefix `RAN` (e.g., RAN-54 — this OpenSSF land). Link tickets as `[RAN-XX](/RAN/issues/RAN-XX)` in PR descriptions and code comments. +# snipIT agent compatibility note + +The authoritative repository instructions are in [`AGENTS.md`](AGENTS.md). +Read that file before modifying this repository. It records the accepted +2026-07-12 board reversal to modular authoritative `src/` and `xaml/` sources, +the deterministic `Build-SnipIT.ps1` workflow, and the rule that generated +`SnipIT.ps1` must never be edited directly. `SnipIT.ps1` is the standalone +single-file distribution, while `src/` and `xaml/` are authoritative. diff --git a/README.md b/README.md index 202525f..9168bf0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![.NET 9](https://img.shields.io/badge/.NET-9-512BD4?logo=dotnet&logoColor=white)](https://dotnet.microsoft.com/) [![Windows 11](https://img.shields.io/badge/Windows-11-0078D4?logo=windows11&logoColor=white)](https://www.microsoft.com/windows/windows-11) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Tests](https://img.shields.io/badge/tests-82%2F82%20passing-brightgreen)](#tests) +[![Tests](https://img.shields.io/badge/tests-CI%20passing-brightgreen)](#tests) [![No Admin](https://img.shields.io/badge/admin-not%20required-success)](#install) [![Single File](https://img.shields.io/badge/single%20file-yes-informational)](SnipIT.ps1) @@ -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. @@ -103,52 +104,91 @@ After installation, SnipIT runs in the system tray. Press `Ctrl+Shift+S`, hover ## Architecture -Single file, organized into regions: +SnipIT keeps a portable single-file distribution while using modular, +reviewable development sources. The ownership and generation flow is: ``` -SnipIT.ps1 -├── #region Core ← pure logic, no UI/Win32 (testable cross-platform) -├── if ($CoreOnly) { return } ← unit-test gate -├── #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 Capture Core ← GDI+ bitmap capture / clipboard / save dialog -├── #region Smart Overlay ← WPF transparent overlay + hover + magnifier -├── #region Preview Window ← Fluent preview + annotation editor -├── #region Capture Orchestration -├── #region Floating Widget ← Auto-hiding top-center capsule -├── #region Tray + Hotkeys ← NotifyIcon, ContextMenuStrip, RegisterHotKey -└── #region Main loop ← WinForms message pump + cleanup +src/*.ps1 + xaml/*.xaml + │ + ▼ + Build-SnipIT.ps1 + │ + ▼ + SnipIT.ps1 +``` + +- `src/00-Core.ps1` owns cross-platform pure logic and the `-CoreOnly` boundary. +- `src/10-Bootstrap.ps1` owns startup, settings, installation, and XAML loading. +- `src/20-Native.ps1`, `src/30-Capture.ps1`, `src/40-Preview.ps1`, + `src/50-Tray.ps1`, and `src/90-Main.ps1` own their named runtime layers. +- The six files in `xaml/` own UI markup. +- `Build-SnipIT.ps1` is the sole authority for module/XAML order and generation. + +`SnipIT.ps1` is generated, checked in, portable, and has no runtime dependency +on the repository. Never edit it directly; edit `src/` or `xaml/` and rebuild. + +## Development + +```powershell +# Run authoritative modular development sources (Windows) +pwsh -Sta -File ./SnipIT.Dev.ps1 + +# Regenerate the portable checked-in release +pwsh -NoProfile -File ./Build-SnipIT.ps1 ``` -The `Core` region exports 10 pure functions: `Get-DragRectangle`, `Test-IsClickVsDrag`, `Get-LoupeSourceRect`, `Get-LoupePosition`, `Get-DefaultSnipFilename`, `Get-ImageFormatNameFromPath`, `Test-CaptureRectValid`, `Get-CropBounds`, `Get-InstallPaths`, `Get-ShortcutArguments`. None of them touch WPF, Win32, or the file system, so they run on Linux/macOS pwsh too. +Commit the regenerated `SnipIT.ps1` with every source or XAML change. CI runs +the builder and rejects a stale release with `git diff --exit-code -- SnipIT.ps1`. ### Preview-window internals The preview window's mouse interaction, zoom, text-editing and color-picking are all organized as **named closures** captured at window-creation time (`$beginPan`, `$updatePan`, `$endPan`, `$beginDraw`, `$updateDraw`, `$finishDraw`, `$openText`, `$pickColor`, `$handleMouseDown`, `$setZoom`, `$zoomBy`, `$fitToViewport`). The real WPF event handlers are one-line wrappers that compute mouse positions and delegate to these closures. This keeps the event handlers trivial and — more importantly — gives the test harness a way to drive every code path without synthesizing real `MouseButtonEventArgs`. -`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. +`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 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 the +generated release or development launcher 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 functions without side effects. ## Tests -**82 tests total**, two suites, both zero-dependency (no Pester): +All suites are zero-dependency (no Pester). The harness reports current totals +at runtime, so this documentation does not become stale as coverage grows. ```powershell -# 40 pure-logic unit tests (runs on any platform with pwsh) +# Build/generation contract and failure fixtures (any platform) +pwsh -NoProfile -File .\Test-SnipIT-Build.ps1 + +# Pure-logic suite against the generated release (any platform) +pwsh -NoProfile -File .\Test-SnipIT.ps1 + +# Pure-logic parity against authoritative development sources +$env:SNIPIT_SCRIPT_UNDER_TEST = '.\SnipIT.Dev.ps1' pwsh -NoProfile -File .\Test-SnipIT.ps1 -# 42 interactive WPF tests against a real Show-PreviewWindow (Windows only) +# Full generated-release WPF suite (Windows only) +Remove-Item Env:SNIPIT_SCRIPT_UNDER_TEST -ErrorAction Ignore pwsh -NoProfile -Sta -File .\Test-SnipIT-Interactive.ps1 + +# Focused development-launcher WPF smoke (Windows only) +$env:SNIPIT_SCRIPT_UNDER_TEST = '.\SnipIT.Dev.ps1' +$env:SNIPIT_TEST_GROUP = 'Floating Studio preview shell' +pwsh -NoProfile -Sta -File .\Test-SnipIT-Interactive.ps1 +Remove-Item Env:SNIPIT_TEST_GROUP,Env:SNIPIT_SCRIPT_UNDER_TEST ``` -### `Test-SnipIT.ps1` (40) +### `Test-SnipIT-Build.ps1` + +Locks manifest order, module/function parity, embedded XAML parity, deterministic +generation, formatting, provenance, parser safety, and transactional failure behavior. + +### `Test-SnipIT.ps1` -Covers rectangle math, click-vs-drag thresholding, loupe clamping for negative-origin multi-monitor setups, filename + image-format derivation, capture-rect validation, install-path computation, and shortcut argument formatting. Dot-sources `SnipIT.ps1 -CoreOnly` so only the pure functions load. +Covers pure logic and behavioral contracts through `-CoreOnly`, against either +`SnipIT.ps1` or the injected `SNIPIT_SCRIPT_UNDER_TEST` development launcher. -### `Test-SnipIT-Interactive.ps1` (42) +### `Test-SnipIT-Interactive.ps1` Drives a real off-screen preview window via `-TestAction`. Coverage: @@ -167,13 +207,19 @@ Drives a real off-screen preview window via `-TestAction`. Coverage: | File / folder | Purpose | |---|---| -| `SnipIT.ps1` | The whole app | -| `Test-SnipIT.ps1` | 40 unit tests, no dependencies | -| `Test-SnipIT-Interactive.ps1` | 42 WPF integration tests, no dependencies | +| `src/` | Authoritative PowerShell implementation modules | +| `xaml/` | Authoritative WPF markup | +| `Build-SnipIT.ps1` | Deterministic manifest, embedding, validation, and release generator | +| `SnipIT.Dev.ps1` | Development launcher for authoritative repository sources | +| `SnipIT.ps1` | Generated, checked-in, standalone distribution; never edit directly | +| `Test-SnipIT-Build.ps1` | Build determinism and failure-contract fixtures | +| `Test-SnipIT.ps1` | Cross-platform pure-logic tests, no dependencies | +| `Test-SnipIT-Interactive.ps1` | Windows WPF integration tests, no dependencies | | [`docs/`](docs/) | Long-form docs (design mocks, deeper write-ups). [`docs/README.md`](docs/README.md) is the index. | | [`CHANGELOG.md`](CHANGELOG.md) | Per-merge change history ([Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format). | | [`SECURITY.md`](SECURITY.md) | Vulnerability disclosure policy + supported versions. | -| [`CLAUDE.md`](CLAUDE.md) | Agent / contributor brief — build, test, run, conventions, OpenSSF Scorecard baseline. | +| [`AGENTS.md`](AGENTS.md) | Authoritative agent/contributor brief and engineering constraints | +| [`CLAUDE.md`](CLAUDE.md) | Compatibility pointer to `AGENTS.md` | | [`shared/runbooks/engineering-standards.md`](shared/runbooks/engineering-standards.md) | PowerShell variant of the company-canonical engineering-standards runbook. | | [`.bestpractices.json`](.bestpractices.json) | OpenSSF Best Practices self-assessment (project [12647](https://www.bestpractices.dev/en/projects/12647)). | | `LICENSE` | MIT | diff --git a/SnipIT.Dev.ps1 b/SnipIT.Dev.ps1 new file mode 100644 index 0000000..e649b5c --- /dev/null +++ b/SnipIT.Dev.ps1 @@ -0,0 +1,83 @@ +#requires -Version 7.5 +[CmdletBinding()] +param( + [switch]$CoreOnly +) + +$ErrorActionPreference = 'Stop' +$script:SnipEntryPath = $PSCommandPath +$script:SnipInstallSourcePath = Join-Path $PSScriptRoot 'SnipIT.ps1' +$script:SnipEmbeddedXaml = [ordered]@{} + +$manifest = & (Join-Path $PSScriptRoot 'Build-SnipIT.ps1') -ManifestOnly +$strictUtf8 = [Text.UTF8Encoding]::new($false, $true) +$developmentModulePaths = [ordered]@{} +foreach ($relativePath in $manifest.Modules) { + $modulePath = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot $relativePath)) + if (-not (Test-Path -LiteralPath $modulePath -PathType Leaf)) { + throw "Missing SnipIT development module: $modulePath" + } + + try { + $moduleText = [IO.File]::ReadAllText($modulePath, $strictUtf8) + } + catch [Text.DecoderFallbackException] { + throw "Invalid UTF-8 SnipIT development module: $modulePath" + } + $tokens = $null + $parseErrors = $null + [void][Management.Automation.Language.Parser]::ParseInput( + $moduleText, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count) { + throw ("Invalid PowerShell SnipIT development module: $modulePath :: " + + ($parseErrors.Message -join '; ')) + } + $developmentModulePaths[$relativePath] = $modulePath +} + +$developmentXamlText = [ordered]@{} +foreach ($entry in $manifest.Xaml.GetEnumerator()) { + $xamlPath = [IO.Path]::GetFullPath((Join-Path -Path $PSScriptRoot ` + -ChildPath ([string]$entry.Value))) + if (-not (Test-Path -LiteralPath $xamlPath -PathType Leaf)) { + throw "XAML source file for '$($entry.Key)' does not exist: $xamlPath" + } + + try { + $xamlText = [IO.File]::ReadAllText($xamlPath, $strictUtf8) + } + catch [Text.DecoderFallbackException] { + throw "Invalid UTF-8 XAML '$($entry.Key)' source: $xamlPath" + } + try { + [void][xml]$xamlText + } + catch { + throw "Invalid XML XAML '$($entry.Key)' source: $xamlPath :: $($_.Exception.Message)" + } + $developmentXamlText[$entry.Key] = $xamlText +} + +$script:SnipDevelopmentXamlResolver = { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Name + ) + + if (-not $developmentXamlText.Contains($Name)) { + throw "Unknown XAML key '$Name'." + } + [string]$developmentXamlText[$Name] +}.GetNewClosure() + +foreach ($relativePath in $manifest.Modules) { + $modulePath = [string]$developmentModulePaths[$relativePath] + + if ($relativePath -eq 'src/00-Core.ps1' -and $CoreOnly) { + . $modulePath -CoreOnly + return + } + + . $modulePath +} diff --git a/SnipIT.ps1 b/SnipIT.ps1 index 05e6d69..74c8576 100644 --- a/SnipIT.ps1 +++ b/SnipIT.ps1 @@ -1,20 +1,27 @@ +# source: src/00-Core.ps1 #requires -Version 7.5 <# 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 - 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) #region Core (pure logic, no UI / no Win32, cross-platform testable) ========= +if (-not (Get-Variable -Name SnipEntryPath -Scope Script -ErrorAction Ignore)) { + $script:SnipEntryPath = $PSCommandPath +} +if (-not (Get-Variable -Name SnipInstallSourcePath -Scope Script -ErrorAction Ignore)) { + $script:SnipInstallSourcePath = $PSCommandPath +} + function Get-DragRectangle { param( [Parameter(Mandatory)] [double]$AnchorX, @@ -213,24 +220,952 @@ function Get-ZoomCenteredOffset { [pscustomobject]@{ X = $newX; Y = $newY } } +function Get-SnipRecordValue { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $InputObject, + [Parameter(Mandatory)] [string]$Name, + [AllowNull()] $Default = $null, + [switch]$Required + ) + + $found = $false + $value = $null + if ($InputObject -is [System.Collections.IDictionary]) { + foreach ($key in $InputObject.Keys) { + if ([string]$key -ieq $Name) { + $value = $InputObject[$key] + $found = $true + break + } + } + } elseif ($null -ne $InputObject.PSObject) { + $property = $InputObject.PSObject.Properties[$Name] + if ($null -ne $property) { + $value = $property.Value + $found = $true + } + } + + if ($found) { return $value } + if ($Required) { + throw [ArgumentException]::new("Expected property '$Name'.", $Name) + } + return $Default +} + +function Test-SnipAtomicSemanticValue { + param([AllowNull()] $Value) + + if ($null -eq $Value) { return $false } + # Semantic records currently need only immutable text, booleans, characters, + # and numeric scalars. An explicit list prevents arbitrary structs from + # smuggling mutable or disposable references into history snapshots. + $typeName = $Value.GetType().FullName + $typeName -in @( + 'System.String', 'System.Boolean', 'System.Char', + 'System.SByte', 'System.Byte', 'System.Int16', 'System.UInt16', + 'System.Int32', 'System.UInt32', 'System.Int64', 'System.UInt64', + 'System.Single', 'System.Double', 'System.Decimal' + ) +} + +function Copy-SnipSemanticKey { + param([AllowNull()] $Key) + + if ($null -eq $Key -or -not (Test-SnipAtomicSemanticValue -Value $Key)) { + $typeName = if ($null -eq $Key) { '' } else { $Key.GetType().FullName } + throw [ArgumentException]::new( + "Unsupported semantic dictionary key type '$typeName'.", 'Key') + } + if ($Key -is [string]) { + # Materialize a separate immutable key object instead of retaining the + # source dictionary's reference. + return [string]::new($Key.ToCharArray()) + } + return $Key +} + +function Copy-SnipSemanticValue { + param([AllowNull()] $Value) + + if ($null -eq $Value) { return $null } + # Disposable preview/cache objects are deliberately not semantic record + # data. Reject them without disposing; Task 9 owns caches outside history. + if ($Value -is [System.IDisposable]) { + throw [ArgumentException]::new( + "Disposable semantic values are unsupported ($($Value.GetType().FullName)).", + 'Value') + } + if (Test-SnipAtomicSemanticValue -Value $Value) { return $Value } + if ($Value.GetType().IsValueType) { + throw [ArgumentException]::new( + "Unsupported semantic value type '$($Value.GetType().FullName)'.", 'Value') + } + + if ($Value -is [System.Collections.Specialized.OrderedDictionary]) { + $copy = [ordered]@{} + foreach ($entry in $Value.GetEnumerator()) { + $keyCopy = Copy-SnipSemanticKey -Key $entry.Key + $valueCopy = Copy-SnipSemanticValue -Value $entry.Value + $copy.Add($keyCopy, $valueCopy) + } + return $copy + } + if ($Value -is [System.Collections.IDictionary]) { + $copy = @{} + foreach ($entry in $Value.GetEnumerator()) { + $keyCopy = Copy-SnipSemanticKey -Key $entry.Key + $valueCopy = Copy-SnipSemanticValue -Value $entry.Value + $copy.Add($keyCopy, $valueCopy) + } + return $copy + } + if ($Value.GetType().IsArray) { + $copy = @($Value | ForEach-Object { Copy-SnipSemanticValue -Value $_ }) + return ,$copy + } + if ($Value -is [System.Collections.IList]) { + $copy = [System.Collections.ArrayList]::new() + foreach ($item in $Value) { + [void]$copy.Add((Copy-SnipSemanticValue -Value $item)) + } + return ,$copy + } + if ($Value -is [pscustomobject]) { + $properties = [ordered]@{} + foreach ($property in $Value.PSObject.Properties) { + if (-not $property.IsGettable) { continue } + $properties[$property.Name] = Copy-SnipSemanticValue -Value $property.Value + } + return [pscustomobject]$properties + } + + # Unknown reference types could be mutable. Reject rather than introducing + # a hidden alias or guessing at clone/ownership semantics. + throw [ArgumentException]::new( + "Unsupported semantic reference type '$($Value.GetType().FullName)'.", 'Value') +} + +function ConvertTo-SnipAnnotationGeometry { + param([Parameter(Mandatory)] $Geometry) + + $type = [string](Get-SnipRecordValue -InputObject $Geometry -Name Type -Required) + switch ($type.ToLowerInvariant()) { + 'bounds' { + return [pscustomobject][ordered]@{ + Type = 'Bounds' + X = [int](Get-SnipRecordValue -InputObject $Geometry -Name X -Required) + Y = [int](Get-SnipRecordValue -InputObject $Geometry -Name Y -Required) + Width = [int](Get-SnipRecordValue -InputObject $Geometry -Name Width -Required) + Height = [int](Get-SnipRecordValue -InputObject $Geometry -Name Height -Required) + } + } + 'textbounds' { + return [pscustomobject][ordered]@{ + Type = 'TextBounds' + X = [int](Get-SnipRecordValue -InputObject $Geometry -Name X -Required) + Y = [int](Get-SnipRecordValue -InputObject $Geometry -Name Y -Required) + Width = [int](Get-SnipRecordValue -InputObject $Geometry -Name Width -Required) + Height = [int](Get-SnipRecordValue -InputObject $Geometry -Name Height -Required) + } + } + 'stepbounds' { + return [pscustomobject][ordered]@{ + Type = 'StepBounds' + X = [int](Get-SnipRecordValue -InputObject $Geometry -Name X -Required) + Y = [int](Get-SnipRecordValue -InputObject $Geometry -Name Y -Required) + Width = [int](Get-SnipRecordValue -InputObject $Geometry -Name Width -Required) + Height = [int](Get-SnipRecordValue -InputObject $Geometry -Name Height -Required) + } + } + 'line' { + $start = Get-SnipRecordValue -InputObject $Geometry -Name Start -Required + $end = Get-SnipRecordValue -InputObject $Geometry -Name End -Required + return [pscustomobject][ordered]@{ + Type = 'Line' + Start = [pscustomobject][ordered]@{ + X = [int](Get-SnipRecordValue -InputObject $start -Name X -Required) + Y = [int](Get-SnipRecordValue -InputObject $start -Name Y -Required) + } + End = [pscustomobject][ordered]@{ + X = [int](Get-SnipRecordValue -InputObject $end -Name X -Required) + Y = [int](Get-SnipRecordValue -InputObject $end -Name Y -Required) + } + } + } + 'points' { + $sourcePoints = Get-SnipRecordValue -InputObject $Geometry -Name Points -Required + $points = [System.Collections.ArrayList]::new() + if ($null -ne $sourcePoints) { + foreach ($point in $sourcePoints) { + [void]$points.Add([pscustomobject][ordered]@{ + X = [int](Get-SnipRecordValue -InputObject $point -Name X -Required) + Y = [int](Get-SnipRecordValue -InputObject $point -Name Y -Required) + }) + } + } + return [pscustomobject][ordered]@{ Type = 'Points'; Points = @($points) } + } + default { + # Unknown future geometry remains copyable and harmless. Consumers + # that do not understand its discriminator must safely ignore it. + return Copy-SnipSemanticValue -Value $Geometry + } + } +} + +function New-SnipAnnotation { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Kind, + [Parameter(Mandatory)] $Geometry, + [Parameter(Mandatory)] [AllowEmptyString()] [string]$Color, + [Parameter(Mandatory)] [double]$StrokeWidth, + [Parameter(Mandatory)] [double]$Opacity, + [Parameter(Mandatory)] [AllowNull()] $Properties, + [Parameter(Mandatory)] [double]$Z, + [AllowNull()] [AllowEmptyString()] [string]$Id + ) + + if ($PSBoundParameters.ContainsKey('Id')) { + if ([string]::IsNullOrWhiteSpace($Id)) { + throw [ArgumentException]::new('An explicit annotation Id must be nonempty.', 'Id') + } + $recordId = $Id + } else { + $recordId = [guid]::NewGuid().ToString() + } + $semanticProperties = if ($null -eq $Properties) { + [ordered]@{} + } else { + Copy-SnipSemanticValue -Value $Properties + } + + [pscustomobject][ordered]@{ + Id = $recordId + Kind = $Kind + Geometry = ConvertTo-SnipAnnotationGeometry -Geometry $Geometry + Color = $Color + StrokeWidth = $StrokeWidth + Opacity = $Opacity + Properties = $semanticProperties + Z = $Z + } +} + +function Copy-SnipAnnotation { + param([Parameter(Mandatory)] $Annotation) + + $kind = Get-SnipRecordValue -InputObject $Annotation -Name Kind + $geometry = Get-SnipRecordValue -InputObject $Annotation -Name Geometry + $id = [string](Get-SnipRecordValue -InputObject $Annotation -Name Id) + if (-not [string]::IsNullOrWhiteSpace([string]$kind) -and $null -ne $geometry) { + if ([string]::IsNullOrWhiteSpace($id)) { + throw [ArgumentException]::new( + 'A canonical annotation must already own a nonempty stable Id.', 'Annotation') + } + $arguments = @{ + Kind = [string]$kind + Geometry = $geometry + Color = [string](Get-SnipRecordValue -InputObject $Annotation -Name Color -Default '') + StrokeWidth = [double](Get-SnipRecordValue -InputObject $Annotation -Name StrokeWidth -Default 1.0) + Opacity = [double](Get-SnipRecordValue -InputObject $Annotation -Name Opacity -Default 1.0) + Properties = Get-SnipRecordValue -InputObject $Annotation -Name Properties -Default ([ordered]@{}) + Z = [double](Get-SnipRecordValue -InputObject $Annotation -Name Z -Default 0) + Id = $id + } + return New-SnipAnnotation @arguments + } + + $legacyType = [string](Get-SnipRecordValue -InputObject $Annotation -Name Type -Required) + $x = [int](Get-SnipRecordValue -InputObject $Annotation -Name X -Required) + $y = [int](Get-SnipRecordValue -InputObject $Annotation -Name Y -Required) + $width = [int](Get-SnipRecordValue -InputObject $Annotation -Name W -Required) + $height = [int](Get-SnipRecordValue -InputObject $Annotation -Name H -Required) + $properties = [ordered]@{} + switch ($legacyType.ToLowerInvariant()) { + 'highlight' { + $canonicalKind = 'Highlight' + $canonicalGeometry = [pscustomobject]@{ + Type='Bounds'; X=$x; Y=$y; Width=$width; Height=$height + } + } + 'rect' { + $canonicalKind = 'Rectangle' + $canonicalGeometry = [pscustomobject]@{ + Type='Bounds'; X=$x; Y=$y; Width=$width; Height=$height + } + } + 'arrow' { + $canonicalKind = 'Arrow' + $canonicalGeometry = [pscustomobject]@{ + Type='Line' + Start=[pscustomobject]@{X=$x;Y=$y} + End=[pscustomobject]@{X=($x + $width);Y=($y + $height)} + } + } + 'text' { + $canonicalKind = 'Text' + $canonicalGeometry = [pscustomobject]@{ + Type='TextBounds'; X=$x; Y=$y; Width=$width; Height=$height + } + $properties.Text = Get-SnipRecordValue -InputObject $Annotation -Name Text + $properties.FontSize = Get-SnipRecordValue -InputObject $Annotation -Name FontSize -Default 0 + } + default { + throw [ArgumentException]::new("Unsupported legacy annotation type '$legacyType'.", 'Annotation') + } + } + + $arguments = @{ + Kind = $canonicalKind + Geometry = $canonicalGeometry + Color = [string](Get-SnipRecordValue -InputObject $Annotation -Name Color -Default '') + StrokeWidth = [double](Get-SnipRecordValue -InputObject $Annotation -Name StrokeWidth -Default 1.0) + Opacity = [double](Get-SnipRecordValue -InputObject $Annotation -Name Opacity -Default 1.0) + Properties = $properties + Z = [double](Get-SnipRecordValue -InputObject $Annotation -Name Z -Default 0) + } + if (-not [string]::IsNullOrWhiteSpace($id)) { $arguments.Id = $id } + New-SnipAnnotation @arguments +} + function Copy-AnnotationList { - # Deep-clone an enumerable of annotation pscustomobjects into a new - # ArrayList. ArrayList stores references, so Snapshot-State / Do-Undo / - # Do-Redo need fresh copies to avoid letting undo entries mutate each - # other when the live Annotations list is edited. + # Keep the historical non-enumerated ArrayList shape used by Preview history, + # while routing every member through the one canonical stable-record copier. param([AllowNull()][AllowEmptyCollection()] $Annotations) - $copy = New-Object System.Collections.ArrayList + $copy = [System.Collections.ArrayList]::new() if ($null -eq $Annotations) { return ,$copy } - foreach ($a in $Annotations) { - [void]$copy.Add([pscustomobject]@{ - Type=$a.Type; Color=$a.Color - X=$a.X; Y=$a.Y; W=$a.W; H=$a.H - Text=$a.Text; FontSize=$a.FontSize - }) + foreach ($annotation in $Annotations) { + [void]$copy.Add((Copy-SnipAnnotation -Annotation $annotation)) } return ,$copy } +function New-SnipEditorSnapshot { + [CmdletBinding()] + param( + [AllowNull()][AllowEmptyCollection()] $Annotations, + [AllowNull()] $CropRectangle + ) + + $cropCopy = $null + if ($null -ne $CropRectangle) { + $cropCopy = [pscustomobject][ordered]@{ + X = [int](Get-SnipRecordValue -InputObject $CropRectangle -Name X -Required) + Y = [int](Get-SnipRecordValue -InputObject $CropRectangle -Name Y -Required) + Width = [int](Get-SnipRecordValue -InputObject $CropRectangle -Name Width -Required) + Height = [int](Get-SnipRecordValue -InputObject $CropRectangle -Name Height -Required) + } + } + [pscustomobject][ordered]@{ + Version = 1 + Annotations = Copy-AnnotationList -Annotations $Annotations + CropRectangle = $cropCopy + } +} + +function Test-SnipPointNearSegment { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [double]$PointX, + [Parameter(Mandatory)] [double]$PointY, + [Parameter(Mandatory)] [double]$StartX, + [Parameter(Mandatory)] [double]$StartY, + [Parameter(Mandatory)] [double]$EndX, + [Parameter(Mandatory)] [double]$EndY, + [Parameter(Mandatory)] [double]$Tolerance + ) + + $dx = $EndX - $StartX + $dy = $EndY - $StartY + $lengthSquared = ($dx * $dx) + ($dy * $dy) + if ($lengthSquared -le 0) { + $nearestX = $StartX + $nearestY = $StartY + } else { + $projection = ((($PointX - $StartX) * $dx) + (($PointY - $StartY) * $dy)) / $lengthSquared + $projection = [math]::Max(0.0, [math]::Min(1.0, $projection)) + $nearestX = $StartX + ($projection * $dx) + $nearestY = $StartY + ($projection * $dy) + } + $distanceX = $PointX - $nearestX + $distanceY = $PointY - $nearestY + (($distanceX * $distanceX) + ($distanceY * $distanceY)) -le ($Tolerance * $Tolerance) +} + +function Test-SnipAnnotationHit { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Annotation, + [Parameter(Mandatory)] [double]$ImageX, + [Parameter(Mandatory)] [double]$ImageY, + [Parameter(Mandatory)] [double]$Tolerance + ) + + $geometry = $Annotation.Geometry + if ($null -eq $geometry) { return $false } + $geometryType = [string](Get-SnipRecordValue -InputObject $geometry -Name Type) + switch ($geometryType.ToLowerInvariant()) { + { $_ -in 'bounds', 'textbounds', 'stepbounds' } { + $x = [double](Get-SnipRecordValue -InputObject $geometry -Name X -Required) + $y = [double](Get-SnipRecordValue -InputObject $geometry -Name Y -Required) + $width = [double](Get-SnipRecordValue -InputObject $geometry -Name Width -Required) + $height = [double](Get-SnipRecordValue -InputObject $geometry -Name Height -Required) + if ($width -le 0 -or $height -le 0) { return $false } + return ($ImageX -ge ($x - $Tolerance) -and + $ImageY -ge ($y - $Tolerance) -and + $ImageX -lt ($x + $width + $Tolerance) -and + $ImageY -lt ($y + $height + $Tolerance)) + } + 'line' { + $start = Get-SnipRecordValue -InputObject $geometry -Name Start -Required + $end = Get-SnipRecordValue -InputObject $geometry -Name End -Required + $startX = [double](Get-SnipRecordValue -InputObject $start -Name X -Required) + $startY = [double](Get-SnipRecordValue -InputObject $start -Name Y -Required) + $endX = [double](Get-SnipRecordValue -InputObject $end -Name X -Required) + $endY = [double](Get-SnipRecordValue -InputObject $end -Name Y -Required) + if ($startX -eq $endX -and $startY -eq $endY) { return $false } + return Test-SnipPointNearSegment -PointX $ImageX -PointY $ImageY ` + -StartX $startX -StartY $startY -EndX $endX -EndY $endY ` + -Tolerance $Tolerance + } + 'points' { + $points = @(Get-SnipRecordValue -InputObject $geometry -Name Points -Required) + if ($points.Count -eq 0) { return $false } + if ($points.Count -eq 1) { + $point = $points[0] + return Test-SnipPointNearSegment -PointX $ImageX -PointY $ImageY ` + -StartX (Get-SnipRecordValue -InputObject $point -Name X -Required) ` + -StartY (Get-SnipRecordValue -InputObject $point -Name Y -Required) ` + -EndX (Get-SnipRecordValue -InputObject $point -Name X -Required) ` + -EndY (Get-SnipRecordValue -InputObject $point -Name Y -Required) ` + -Tolerance $Tolerance + } + for ($index = 1; $index -lt $points.Count; $index++) { + $start = $points[$index - 1] + $end = $points[$index] + if (Test-SnipPointNearSegment -PointX $ImageX -PointY $ImageY ` + -StartX (Get-SnipRecordValue -InputObject $start -Name X -Required) ` + -StartY (Get-SnipRecordValue -InputObject $start -Name Y -Required) ` + -EndX (Get-SnipRecordValue -InputObject $end -Name X -Required) ` + -EndY (Get-SnipRecordValue -InputObject $end -Name Y -Required) ` + -Tolerance $Tolerance) { + return $true + } + } + return $false + } + default { return $false } + } +} + +function Get-SnipAnnotationHitView { + param([Parameter(Mandatory)] $Annotation) + + $kind = Get-SnipRecordValue -InputObject $Annotation -Name Kind + $geometry = Get-SnipRecordValue -InputObject $Annotation -Name Geometry + if (-not [string]::IsNullOrWhiteSpace([string]$kind) -and $null -ne $geometry) { + $id = [string](Get-SnipRecordValue -InputObject $Annotation -Name Id) + if ([string]::IsNullOrWhiteSpace($id)) { + throw [ArgumentException]::new( + 'A canonical annotation must own a nonempty stable Id.', 'Annotation') + } + return [pscustomobject]@{ + Geometry = $geometry + Z = [double](Get-SnipRecordValue -InputObject $Annotation -Name Z -Default 0) + } + } + + $legacyType = [string](Get-SnipRecordValue -InputObject $Annotation -Name Type -Required) + $x = [int](Get-SnipRecordValue -InputObject $Annotation -Name X -Required) + $y = [int](Get-SnipRecordValue -InputObject $Annotation -Name Y -Required) + $width = [int](Get-SnipRecordValue -InputObject $Annotation -Name W -Required) + $height = [int](Get-SnipRecordValue -InputObject $Annotation -Name H -Required) + $geometry = switch ($legacyType.ToLowerInvariant()) { + { $_ -in 'highlight', 'rect' } { + [pscustomobject]@{ Type='Bounds'; X=$x; Y=$y; Width=$width; Height=$height } + break + } + 'arrow' { + [pscustomobject]@{ + Type='Line'; Start=[pscustomobject]@{X=$x;Y=$y} + End=[pscustomobject]@{X=($x + $width);Y=($y + $height)} + } + break + } + 'text' { + [pscustomobject]@{ Type='TextBounds'; X=$x; Y=$y; Width=$width; Height=$height } + break + } + default { + throw [ArgumentException]::new( + "Unsupported legacy annotation type '$legacyType'.", 'Annotation') + } + } + [pscustomobject]@{ + Geometry = $geometry + Z = [double](Get-SnipRecordValue -InputObject $Annotation -Name Z -Default 0) + } +} + +function Find-SnipAnnotation { + [CmdletBinding()] + param( + [AllowNull()][AllowEmptyCollection()] $Annotations, + [Parameter(Mandatory)] [double]$ImageX, + [Parameter(Mandatory)] [double]$ImageY, + [double]$Tolerance = 6.0 + ) + + if ($null -eq $Annotations) { return $null } + $safeTolerance = [math]::Max(0.0, $Tolerance) + $bestSource = $null + $bestZ = [double]::NegativeInfinity + $bestIndex = -1 + $index = 0 + foreach ($annotation in $Annotations) { + try { + $hitView = Get-SnipAnnotationHitView -Annotation $annotation + if (-not (Test-SnipAnnotationHit -Annotation $hitView -ImageX $ImageX ` + -ImageY $ImageY -Tolerance $safeTolerance)) { + $index++ + continue + } + $z = [double]$hitView.Z + if ($null -eq $bestSource -or $z -gt $bestZ -or ($z -eq $bestZ -and $index -gt $bestIndex)) { + $bestSource = $annotation + $bestZ = $z + $bestIndex = $index + } + } catch { + # Malformed or unsupported records are not selectable; one bad + # compatibility record must not break selection for the whole list. + Write-Debug "Ignoring malformed annotation at list index $index." + } + $index++ + } + if ($null -eq $bestSource) { return $null } + try { + Copy-SnipAnnotation -Annotation $bestSource + } catch { + Write-Debug 'The selected annotation could not be normalized safely.' + return $null + } +} + +function Select-SnipAnnotation { + [CmdletBinding()] + param( + [AllowNull()][AllowEmptyCollection()] $Annotations, + [Parameter(Mandatory)] [double]$ImageX, + [Parameter(Mandatory)] [double]$ImageY, + [double]$Tolerance = 6.0 + ) + + $annotation = Find-SnipAnnotation -Annotations $Annotations -ImageX $ImageX ` + -ImageY $ImageY -Tolerance $Tolerance + if ($null -eq $annotation) { return $null } + [string]$annotation.Id +} + +function Move-SnipAnnotation { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Annotation, + [Parameter(Mandatory)] [int]$DeltaX, + [Parameter(Mandatory)] [int]$DeltaY, + [Parameter(Mandatory)] [int]$SourceWidth, + [Parameter(Mandatory)] [int]$SourceHeight + ) + + if ($SourceWidth -le 0) { + throw [ArgumentOutOfRangeException]::new('SourceWidth', 'Source width must be positive.') + } + if ($SourceHeight -le 0) { + throw [ArgumentOutOfRangeException]::new('SourceHeight', 'Source height must be positive.') + } + $copy = Copy-SnipAnnotation -Annotation $Annotation + $geometry = $copy.Geometry + switch ([string]$geometry.Type) { + { $_ -in 'Bounds', 'TextBounds', 'StepBounds' } { + if ([int]$geometry.Width -le 0 -or [int]$geometry.Height -le 0 -or + [int]$geometry.Width -gt $SourceWidth -or [int]$geometry.Height -gt $SourceHeight) { + throw [ArgumentException]::new( + 'Bounds geometry must have positive dimensions no larger than the source.', + 'Annotation') + } + $maxX = [math]::Max(0, $SourceWidth - [int]$geometry.Width) + $maxY = [math]::Max(0, $SourceHeight - [int]$geometry.Height) + $geometry.X = [int][math]::Max(0, [math]::Min($maxX, ([int]$geometry.X + $DeltaX))) + $geometry.Y = [int][math]::Max(0, [math]::Min($maxY, ([int]$geometry.Y + $DeltaY))) + } + 'Line' { + $points = @($geometry.Start, $geometry.End) + $minimumX = [int](($points | Measure-Object X -Minimum).Minimum) + $maximumX = [int](($points | Measure-Object X -Maximum).Maximum) + $minimumY = [int](($points | Measure-Object Y -Minimum).Minimum) + $maximumY = [int](($points | Measure-Object Y -Maximum).Maximum) + if (($maximumX - $minimumX) -gt ($SourceWidth - 1) -or + ($maximumY - $minimumY) -gt ($SourceHeight - 1)) { + throw [ArgumentException]::new( + 'Line geometry span is larger than the source.', 'Annotation') + } + $actualDeltaX = [math]::Max(-$minimumX, [math]::Min(($SourceWidth - 1) - $maximumX, $DeltaX)) + $actualDeltaY = [math]::Max(-$minimumY, [math]::Min(($SourceHeight - 1) - $maximumY, $DeltaY)) + foreach ($point in $points) { + $point.X = [int]$point.X + [int]$actualDeltaX + $point.Y = [int]$point.Y + [int]$actualDeltaY + } + } + 'Points' { + $points = @($geometry.Points) + if ($points.Count -gt 0) { + $minimumX = [int](($points | Measure-Object X -Minimum).Minimum) + $maximumX = [int](($points | Measure-Object X -Maximum).Maximum) + $minimumY = [int](($points | Measure-Object Y -Minimum).Minimum) + $maximumY = [int](($points | Measure-Object Y -Maximum).Maximum) + if (($maximumX - $minimumX) -gt ($SourceWidth - 1) -or + ($maximumY - $minimumY) -gt ($SourceHeight - 1)) { + throw [ArgumentException]::new( + 'Point geometry span is larger than the source.', 'Annotation') + } + $actualDeltaX = [math]::Max(-$minimumX, [math]::Min(($SourceWidth - 1) - $maximumX, $DeltaX)) + $actualDeltaY = [math]::Max(-$minimumY, [math]::Min(($SourceHeight - 1) - $maximumY, $DeltaY)) + foreach ($point in $points) { + $point.X = [int]$point.X + [int]$actualDeltaX + $point.Y = [int]$point.Y + [int]$actualDeltaY + } + } + } + } + $copy +} + +function Resize-SnipAnnotation { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Annotation, + [Parameter(Mandatory)] + [ValidateSet('TopLeft','Top','TopRight','Right','BottomRight','Bottom','BottomLeft','Left','Start','End')] + [string]$Handle, + [Parameter(Mandatory)] [int]$DeltaX, + [Parameter(Mandatory)] [int]$DeltaY, + [Parameter(Mandatory)] [int]$SourceWidth, + [Parameter(Mandatory)] [int]$SourceHeight + ) + + if ($SourceWidth -le 0) { + throw [ArgumentOutOfRangeException]::new('SourceWidth', 'Source width must be positive.') + } + if ($SourceHeight -le 0) { + throw [ArgumentOutOfRangeException]::new('SourceHeight', 'Source height must be positive.') + } + $copy = Copy-SnipAnnotation -Annotation $Annotation + $geometry = $copy.Geometry + $boundsHandles = @('TopLeft','Top','TopRight','Right','BottomRight','Bottom','BottomLeft','Left') + $clampCoordinate = { + param([long]$Value, [long]$Maximum) + [int][math]::Max(0L, [math]::Min($Maximum, $Value)) + }.GetNewClosure() + $normalizePointGeometry = { + param( + [AllowNull()][AllowEmptyCollection()][object[]]$Points, + [Parameter(Mandatory)][string]$Label + ) + + if ($null -eq $Points -or $Points.Count -eq 0) { + throw [ArgumentException]::new( + "$Label geometry must contain points.", 'Annotation') + } + $minimumX = [int]$Points[0].X + $maximumX = $minimumX + $minimumY = [int]$Points[0].Y + $maximumY = $minimumY + for ($index = 1; $index -lt $Points.Count; $index++) { + $pointX = [int]$Points[$index].X + $pointY = [int]$Points[$index].Y + $minimumX = [math]::Min($minimumX, $pointX) + $maximumX = [math]::Max($maximumX, $pointX) + $minimumY = [math]::Min($minimumY, $pointY) + $maximumY = [math]::Max($maximumY, $pointY) + } + $spanX = [long]$maximumX - [long]$minimumX + $spanY = [long]$maximumY - [long]$minimumY + if (($spanX -eq 0 -and $spanY -eq 0) -or + $spanX -gt ($SourceWidth - 1) -or $spanY -gt ($SourceHeight - 1)) { + throw [ArgumentException]::new( + "$Label geometry must have positive extent and fit inside the source.", + 'Annotation') + } + + $shiftX = 0L + if ($minimumX -lt 0) { + $shiftX = -[long]$minimumX + } elseif ($maximumX -gt ($SourceWidth - 1)) { + $shiftX = [long]($SourceWidth - 1) - [long]$maximumX + } + $shiftY = 0L + if ($minimumY -lt 0) { + $shiftY = -[long]$minimumY + } elseif ($maximumY -gt ($SourceHeight - 1)) { + $shiftY = [long]($SourceHeight - 1) - [long]$maximumY + } + foreach ($point in $Points) { + $point.X = [int]([long]$point.X + $shiftX) + $point.Y = [int]([long]$point.Y + $shiftY) + } + + [pscustomobject]@{ + Points = $Points + MinimumX = [int]([long]$minimumX + $shiftX) + MaximumX = [int]([long]$maximumX + $shiftX) + MinimumY = [int]([long]$minimumY + $shiftY) + MaximumY = [int]([long]$maximumY + $shiftY) + } + }.GetNewClosure() + + if ([string]$geometry.Type -in @('Bounds','TextBounds','StepBounds')) { + if ($Handle -notin $boundsHandles) { + throw [ArgumentException]::new("Handle '$Handle' cannot resize $($geometry.Type) geometry.", 'Handle') + } + $width = [int]$geometry.Width + $height = [int]$geometry.Height + if ($width -le 0 -or $height -le 0 -or + $width -gt $SourceWidth -or $height -gt $SourceHeight) { + throw [ArgumentException]::new( + 'Bounds geometry must have positive dimensions no larger than the source.', + 'Annotation') + } + # Shift the intact starting rectangle inside the source before applying + # a handle delta, including axes whose edges remain stationary. + $left = [int][math]::Max(0, [math]::Min($SourceWidth - $width, [int]$geometry.X)) + $top = [int][math]::Max(0, [math]::Min($SourceHeight - $height, [int]$geometry.Y)) + $right = $left + $width + $bottom = $top + $height + $movesLeft = $Handle -in @('TopLeft','Left','BottomLeft') + $movesRight = $Handle -in @('TopRight','Right','BottomRight') + $movesTop = $Handle -in @('TopLeft','Top','TopRight') + $movesBottom = $Handle -in @('BottomLeft','Bottom','BottomRight') + if ($movesLeft) { + $left = & $clampCoordinate ([long]$left + [long]$DeltaX) ([long]$SourceWidth) + } + if ($movesRight) { + $right = & $clampCoordinate ([long]$right + [long]$DeltaX) ([long]$SourceWidth) + } + if ($movesTop) { + $top = & $clampCoordinate ([long]$top + [long]$DeltaY) ([long]$SourceHeight) + } + if ($movesBottom) { + $bottom = & $clampCoordinate ([long]$bottom + [long]$DeltaY) ([long]$SourceHeight) + } + + if ($left -eq $right) { + if ($movesLeft) { + $normalizedLeft = [math]::Max(0, $right - 1) + $normalizedRight = $normalizedLeft + 1 + } else { + $normalizedLeft = [math]::Min($SourceWidth - 1, $left) + $normalizedRight = $normalizedLeft + 1 + } + } else { + $normalizedLeft = [math]::Min($left, $right) + $normalizedRight = [math]::Max($left, $right) + } + if ($top -eq $bottom) { + if ($movesTop) { + $normalizedTop = [math]::Max(0, $bottom - 1) + $normalizedBottom = $normalizedTop + 1 + } else { + $normalizedTop = [math]::Min($SourceHeight - 1, $top) + $normalizedBottom = $normalizedTop + 1 + } + } else { + $normalizedTop = [math]::Min($top, $bottom) + $normalizedBottom = [math]::Max($top, $bottom) + } + $geometry.X = [int]$normalizedLeft + $geometry.Y = [int]$normalizedTop + $geometry.Width = [int][math]::Max(1, $normalizedRight - $normalizedLeft) + $geometry.Height = [int][math]::Max(1, $normalizedBottom - $normalizedTop) + return $copy + } + + if ([string]$geometry.Type -eq 'Line') { + if ($Handle -notin @('Start','End')) { + throw [ArgumentException]::new("Handle '$Handle' cannot resize Line geometry.", 'Handle') + } + [void](& $normalizePointGeometry -Points @($geometry.Start, $geometry.End) -Label Line) + $point = if ($Handle -eq 'Start') { $geometry.Start } else { $geometry.End } + $point.X = & $clampCoordinate ([long]$point.X + [long]$DeltaX) ([long]($SourceWidth - 1)) + $point.Y = & $clampCoordinate ([long]$point.Y + [long]$DeltaY) ([long]($SourceHeight - 1)) + [void](& $normalizePointGeometry -Points @($geometry.Start, $geometry.End) -Label Line) + return $copy + } + + if ([string]$geometry.Type -eq 'Points') { + if ($Handle -notin $boundsHandles) { + throw [ArgumentException]::new("Handle '$Handle' cannot resize Points geometry.", 'Handle') + } + $normalized = & $normalizePointGeometry -Points @($geometry.Points) -Label Points + $points = @($normalized.Points) + $minimumX = [double]$normalized.MinimumX + $maximumX = [double]$normalized.MaximumX + $minimumY = [double]$normalized.MinimumY + $maximumY = [double]$normalized.MaximumY + $movesLeft = $Handle -in @('TopLeft','Left','BottomLeft') + $movesRight = $Handle -in @('TopRight','Right','BottomRight') + $movesTop = $Handle -in @('TopLeft','Top','TopRight') + $movesBottom = $Handle -in @('BottomLeft','Bottom','BottomRight') + + $anchorX = if ($movesLeft) { $maximumX } else { $minimumX } + $movingOriginalX = if ($movesLeft) { $minimumX } else { $maximumX } + $movingNewX = if ($movesLeft -or $movesRight) { + & $clampCoordinate ([long]$movingOriginalX + [long]$DeltaX) ([long]($SourceWidth - 1)) + } else { $movingOriginalX } + $anchorY = if ($movesTop) { $maximumY } else { $minimumY } + $movingOriginalY = if ($movesTop) { $minimumY } else { $maximumY } + $movingNewY = if ($movesTop -or $movesBottom) { + & $clampCoordinate ([long]$movingOriginalY + [long]$DeltaY) ([long]($SourceHeight - 1)) + } else { $movingOriginalY } + + foreach ($point in $points) { + if ($movesLeft -or $movesRight) { + $ratioX = if ($movingOriginalX -eq $anchorX) { 0.0 } else { + ([double]$point.X - $anchorX) / ($movingOriginalX - $anchorX) + } + $scaledX = $anchorX + ($ratioX * ($movingNewX - $anchorX)) + $point.X = [int][math]::Max(0, [math]::Min($SourceWidth - 1, + [math]::Round($scaledX, 0, [MidpointRounding]::AwayFromZero))) + } + if ($movesTop -or $movesBottom) { + $ratioY = if ($movingOriginalY -eq $anchorY) { 0.0 } else { + ([double]$point.Y - $anchorY) / ($movingOriginalY - $anchorY) + } + $scaledY = $anchorY + ($ratioY * ($movingNewY - $anchorY)) + $point.Y = [int][math]::Max(0, [math]::Min($SourceHeight - 1, + [math]::Round($scaledY, 0, [MidpointRounding]::AwayFromZero))) + } + } + [void](& $normalizePointGeometry -Points $points -Label Points) + return $copy + } + + # Unknown future geometry is copied but intentionally not transformed. + $copy +} + +function Get-SnipCropRectangle { + [CmdletBinding()] + param( + [AllowNull()] $Candidate, + [Parameter(Mandatory)] [int]$SourceWidth, + [Parameter(Mandatory)] [int]$SourceHeight, + [ValidateSet('Free','Original','1:1','4:3','16:9')] [string]$Preset = 'Free' + ) + + if ($SourceWidth -le 0) { + throw [ArgumentOutOfRangeException]::new('SourceWidth', 'Source width must be positive.') + } + if ($SourceHeight -le 0) { + throw [ArgumentOutOfRangeException]::new('SourceHeight', 'Source height must be positive.') + } + $toIntegerRectangle = { + param([double]$RectX, [double]$RectY, [double]$RectWidth, [double]$RectHeight) + $integerX = [int][math]::Round($RectX, 0, [MidpointRounding]::AwayFromZero) + $integerY = [int][math]::Round($RectY, 0, [MidpointRounding]::AwayFromZero) + $integerWidth = [int][math]::Round($RectWidth, 0, [MidpointRounding]::AwayFromZero) + $integerHeight = [int][math]::Round($RectHeight, 0, [MidpointRounding]::AwayFromZero) + if ($integerWidth -le 0 -or $integerHeight -le 0 -or + $integerX -ge $SourceWidth -or $integerY -ge $SourceHeight) { + return $null + } + $integerX = [int][math]::Max(0, $integerX) + $integerY = [int][math]::Max(0, $integerY) + $integerWidth = [int][math]::Min($integerWidth, $SourceWidth - $integerX) + $integerHeight = [int][math]::Min($integerHeight, $SourceHeight - $integerY) + if ($integerWidth -le 0 -or $integerHeight -le 0) { return $null } + [pscustomobject][ordered]@{ + X=$integerX; Y=$integerY; Width=$integerWidth; Height=$integerHeight + } + }.GetNewClosure() + + $usedFullSource = $null -eq $Candidate + if ($usedFullSource) { + $left = 0 + $top = 0 + $right = $SourceWidth + $bottom = $SourceHeight + } else { + $candidateX = [double](Get-SnipRecordValue -InputObject $Candidate -Name X -Required) + $candidateY = [double](Get-SnipRecordValue -InputObject $Candidate -Name Y -Required) + $candidateWidth = [double](Get-SnipRecordValue -InputObject $Candidate -Name Width -Required) + $candidateHeight = [double](Get-SnipRecordValue -InputObject $Candidate -Name Height -Required) + if ($candidateWidth -eq 0 -or $candidateHeight -eq 0) { return $null } + $candidateRight = $candidateX + $candidateWidth + $candidateBottom = $candidateY + $candidateHeight + # Keep the clamp in floating-point space. Mixing integer and double + # arguments makes PowerShell bind Math.Min/Max to an integer overload, + # which truncates midpoint coordinates before the explicit rounding step. + $left = [math]::Max(0.0, [math]::Min($candidateX, $candidateRight)) + $top = [math]::Max(0.0, [math]::Min($candidateY, $candidateBottom)) + $right = [math]::Min([double]$SourceWidth, [math]::Max($candidateX, $candidateRight)) + $bottom = [math]::Min([double]$SourceHeight, [math]::Max($candidateY, $candidateBottom)) + } + $width = [double]($right - $left) + $height = [double]($bottom - $top) + if ($width -le 0 -or $height -le 0) { return $null } + + if ($Preset -eq 'Free') { + return & $toIntegerRectangle $left $top $width $height + } + + $isPortrait = if ($usedFullSource) { + $SourceHeight -gt $SourceWidth + } else { + $height -gt $width + } + $targetAspect = switch ($Preset) { + 'Original' { [double]$SourceWidth / [double]$SourceHeight } + '1:1' { 1.0 } + '4:3' { if ($isPortrait) { 3.0 / 4.0 } else { 4.0 / 3.0 } } + '16:9' { if ($isPortrait) { 9.0 / 16.0 } else { 16.0 / 9.0 } } + } + $currentAspect = [double]$width / [double]$height + if ($currentAspect -gt $targetAspect) { + $targetHeight = $height + $targetWidth = [int][math]::Round($targetHeight * $targetAspect, 0, + [MidpointRounding]::AwayFromZero) + } else { + $targetWidth = $width + $targetHeight = [int][math]::Round($targetWidth / $targetAspect, 0, + [MidpointRounding]::AwayFromZero) + } + $targetWidth = [int][math]::Max(1, [math]::Min($width, $targetWidth)) + $targetHeight = [int][math]::Max(1, [math]::Min($height, $targetHeight)) + $offsetX = [int][math]::Round(($width - $targetWidth) / 2.0, 0, + [MidpointRounding]::AwayFromZero) + $offsetY = [int][math]::Round(($height - $targetHeight) / 2.0, 0, + [MidpointRounding]::AwayFromZero) + & $toIntegerRectangle ($left + $offsetX) ($top + $offsetY) $targetWidth $targetHeight +} + +function Set-SnipCrop { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('Apply','Reset')] [string]$Action, + [AllowNull()] $Candidate, + [Parameter(Mandatory)] [int]$SourceWidth, + [Parameter(Mandatory)] [int]$SourceHeight, + [ValidateSet('Free','Original','1:1','4:3','16:9')] [string]$Preset = 'Free' + ) + + if ($Action -eq 'Reset') { return $null } + Get-SnipCropRectangle -Candidate $Candidate -SourceWidth $SourceWidth ` + -SourceHeight $SourceHeight -Preset $Preset +} + function Get-TrimmedRecent { # Keep only the top N items (for capping unbounded undo/redo stacks). # $Items is expected in most-recent-first order, matching [Stack].ToArray(). @@ -314,2150 +1249,9468 @@ function Invoke-CaptureLoop { return $iterations } -#endregion - -# Tests dot-source this script with -CoreOnly to load only the pure functions above. -if ($CoreOnly) { return } +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 + } +} -#region Bootstrap =========================================================== +function Get-SnipContrastRatio { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$Foreground, + [Parameter(Mandatory)] [string]$Background + ) -# Preview-window settings (tuned constants shared across functions) -$script:UndoStackMaxDepth = 100 + function ConvertFrom-SnipHexColor { + param([Parameter(Mandatory)] [string]$Hex) -# Diagnostic ring buffer — lightweight log for previously-silent catch {} blocks. -# Inspect with: Get-SnipDiag (shows the last N entries; default 200 deep) -$script:DiagRingSize = 200 -$script:DiagRing = New-Object System.Collections.Generic.Queue[string] + $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 Write-SnipDiag { - param([string]$Message, $ErrorRecord = $null) - $ts = (Get-Date).ToString('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() } -} + function Get-RelativeLuminance { + param([Parameter(Mandatory)] [double[]]$Rgb) -function Get-SnipDiag { $script:DiagRing.ToArray() } + $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] + } -# 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 - Start-Process -FilePath $pwsh ` - -ArgumentList @('-Sta','-NoProfile','-WindowStyle','Hidden','-File',$PSCommandPath) ` - -WindowStyle Hidden - return + $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 + ) } -# Hide console window if launched visibly -if (-not ('ConsoleHider' -as [type])) { - Add-Type -TypeDefinition @' -using System; -using System.Runtime.InteropServices; -public static class ConsoleHider { - [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); - [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); -} -'@ -} -$h = [ConsoleHider]::GetConsoleWindow() -if ($h -ne [IntPtr]::Zero) { - [ConsoleHider]::ShowWindow($h, 0) | Out-Null - # Track even though hidden — a future ShowWindow we don't control could - # bring it back, and the capture-target guard wants it in the self set. - $script:ConsoleHwnd = $h +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 + } } -Add-Type -AssemblyName PresentationFramework -Add-Type -AssemblyName PresentationCore -Add-Type -AssemblyName WindowsBase -Add-Type -AssemblyName System.Windows.Forms -Add-Type -AssemblyName System.Drawing +function Test-SnipHotkeyDefinition { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [int]$Modifiers, + [Parameter(Mandatory)] [int]$VirtualKey + ) -# Single-instance guard via named mutex (per user session). -# Must happen AFTER WinForms is loaded so we can MessageBox on conflict. -# Skipped in test mode so a harness can dot-source this script while the -# real app is also running. -if (-not $env:SNIPIT_TEST_MODE) { - $script:SingleInstanceCreated = $false - $script:SingleInstanceMutex = New-Object System.Threading.Mutex( - $true, 'Local\SnipIT-SingleInstance-v1', [ref]$script:SingleInstanceCreated) - 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', 'OK', 'Information') | Out-Null - } catch {} - try { $script:SingleInstanceMutex.Dispose() } catch {} - return + $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 } -# 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' + 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) } -New-Item -ItemType Directory -Force -Path $script:AppHomeDir | Out-Null -# .NET 9 WPF Fluent theme -try { [System.Windows.Application]::Current.ThemeMode = 'System' } catch {} +function Format-SnipHotkey { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [int]$Modifiers, + [Parameter(Mandatory)] [int]$VirtualKey + ) -#endregion + if (-not (Test-SnipHotkeyDefinition -Modifiers $Modifiers -VirtualKey $VirtualKey)) { + throw [ArgumentException]::new('The hotkey definition is not supported.') + } -#region PInvoke ============================================================= + $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 '+' +} -$pinvoke = @' -using System; -using System.Runtime.InteropServices; -using System.Drawing; +function Get-PreviewResponsiveMode { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [double]$Width, + [Parameter(Mandatory)] [double]$Height + ) -public static class Native { - // DPI awareness - [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); - public static readonly IntPtr DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = new IntPtr(-4); + if ($Width -lt 900 -or $Height -lt 600) { return 'Narrow' } + if ($Width -lt 1200 -or $Height -lt 700) { return 'Compact' } + 'Wide' +} - // Hotkey - [DllImport("user32.dll")] public static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); - [DllImport("user32.dll")] public static extern bool UnregisterHotKey(IntPtr hWnd, int id); +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 + ) - // Window discovery - [StructLayout(LayoutKind.Sequential)] - public struct POINT { public int X; public int Y; } - [StructLayout(LayoutKind.Sequential)] - public struct RECT { public int Left, Top, Right, Bottom; } + 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) + } +} - [DllImport("user32.dll")] public static extern IntPtr WindowFromPoint(POINT p); - [DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr hWnd, uint flags); - public const uint GA_ROOT = 2; +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 + ) - [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); - [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); + 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 + ) + } +} - // Visibility + show/hide for SnipIT-owned windows. We hide our chrome - // around CopyFromScreen so widget / preview UI doesn't get baked into - // captures, then SW_SHOWNA back without stealing focus. - [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); - [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); - public const int SW_HIDE = 0; - public const int SW_SHOWNA = 8; // show without activating (don't steal focus) +function Get-SnipMonitorLayouts { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object[]]$MonitorDescriptors + ) - // DWM extended frame bounds (no drop shadow) - [DllImport("dwmapi.dll")] - public static extern int DwmGetWindowAttribute(IntPtr hWnd, int attr, out RECT rect, int size); - public const int DWMWA_EXTENDED_FRAME_BOUNDS = 9; + if ($null -eq $MonitorDescriptors -or $MonitorDescriptors.Count -eq 0) { + throw [ArgumentException]::new( + 'At least one monitor descriptor is required.', 'MonitorDescriptors') + } - // Mica backdrop (Win11) - [DllImport("dwmapi.dll")] - public static extern int DwmSetWindowAttribute(IntPtr hWnd, int attr, ref int value, int size); - public const int DWMWA_SYSTEMBACKDROP_TYPE = 38; - public const int DWMSBT_MAINWINDOW = 2; // Mica - public const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; + $layouts = [System.Collections.Generic.List[object]]::new() + for ($index = 0; $index -lt $MonitorDescriptors.Count; $index++) { + $descriptor = $MonitorDescriptors[$index] + if ($null -eq $descriptor) { + throw [ArgumentException]::new( + "Monitor descriptor at index $index is null.", 'MonitorDescriptors') + } + foreach ($propertyName in 'Id','X','Y','Width','Height','DpiX','DpiY') { + if ($null -eq $descriptor.PSObject.Properties[$propertyName]) { + throw [ArgumentException]::new( + "Monitor descriptor at index $index is missing '$propertyName'.", + 'MonitorDescriptors') + } + } - // Cursor pos in screen pixels - [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p); + try { + $id = [string]$descriptor.Id + $physicalX = [double]$descriptor.X + $physicalY = [double]$descriptor.Y + $physicalWidth = [double]$descriptor.Width + $physicalHeight = [double]$descriptor.Height + $dpiX = [double]$descriptor.DpiX + $dpiY = [double]$descriptor.DpiY + } catch { + throw [ArgumentException]::new( + "Monitor descriptor at index $index contains a non-numeric coordinate, size, or DPI.", + 'MonitorDescriptors', $_.Exception) + } - // GDI cleanup for handles returned by Bitmap.GetHbitmap() - [DllImport("gdi32.dll")] public static extern bool DeleteObject(IntPtr hObject); -} -'@ -if (-not ('Native' -as [type])) { - Add-Type -TypeDefinition $pinvoke -ReferencedAssemblies ([System.Drawing.Bitmap].Assembly.Location) -} -[Native]::SetProcessDpiAwarenessContext([Native]::DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) | Out-Null + if ([string]::IsNullOrWhiteSpace($id)) { + throw [ArgumentException]::new( + "Monitor descriptor at index $index has an empty Id.", 'MonitorDescriptors') + } + if (-not [double]::IsFinite($physicalX)) { + throw [ArgumentOutOfRangeException]::new('X', 'X must be finite.') + } + if (-not [double]::IsFinite($physicalY)) { + throw [ArgumentOutOfRangeException]::new('Y', 'Y must be finite.') + } + if (-not [double]::IsFinite($physicalWidth) -or $physicalWidth -le 0) { + throw [ArgumentOutOfRangeException]::new('Width', 'Width must be greater than zero.') + } + if (-not [double]::IsFinite($physicalHeight) -or $physicalHeight -le 0) { + throw [ArgumentOutOfRangeException]::new('Height', 'Height must be greater than zero.') + } + if (-not [double]::IsFinite($dpiX) -or $dpiX -le 0) { + throw [ArgumentOutOfRangeException]::new('DpiX', 'DpiX must be greater than zero.') + } + if (-not [double]::IsFinite($dpiY) -or $dpiY -le 0) { + throw [ArgumentOutOfRangeException]::new('DpiY', 'DpiY must be greater than zero.') + } -#endregion + $physicalX = [int][math]::Round( + $physicalX, 0, [MidpointRounding]::AwayFromZero) + $physicalY = [int][math]::Round( + $physicalY, 0, [MidpointRounding]::AwayFromZero) + $physicalWidth = [int][math]::Round( + $physicalWidth, 0, [MidpointRounding]::AwayFromZero) + $physicalHeight = [int][math]::Round( + $physicalHeight, 0, [MidpointRounding]::AwayFromZero) + if ($physicalWidth -le 0) { + throw [ArgumentOutOfRangeException]::new( + 'Width', 'Width must normalize to at least one physical pixel.') + } + if ($physicalHeight -le 0) { + throw [ArgumentOutOfRangeException]::new( + 'Height', 'Height must normalize to at least one physical pixel.') + } -#region Icon generation ===================================================== -# Defined before First-Run Install because Install-SnipIT needs Get-SnipITIconPath -# at script-load time. + $scaleX = $dpiX / 96.0 + $scaleY = $dpiY / 96.0 + $isPrimaryProperty = $descriptor.PSObject.Properties['IsPrimary'] + $layouts.Add([pscustomobject][ordered]@{ + Id = $id + Index = $index + IsPrimary = if ($null -ne $isPrimaryProperty) { + [bool]$isPrimaryProperty.Value + } else { + $false + } + PhysicalX = $physicalX + PhysicalY = $physicalY + PhysicalWidth = $physicalWidth + PhysicalHeight = $physicalHeight + PhysicalRight = $physicalX + $physicalWidth + PhysicalBottom = $physicalY + $physicalHeight + DpiX = $dpiX + DpiY = $dpiY + ScaleX = $scaleX + ScaleY = $scaleY + DipX = $physicalX / $scaleX + DipY = $physicalY / $scaleY + DipWidth = $physicalWidth / $scaleX + DipHeight = $physicalHeight / $scaleY + Descriptor = $descriptor + }) + } -function New-SnipITIcon { - param([Parameter(Mandatory)] [string]$Path) - # Draw at 256x256 so the icon is sharp at every shortcut size. - $size = 256 - $bmp = New-Object System.Drawing.Bitmap $size, $size - $g = [System.Drawing.Graphics]::FromImage($bmp) - $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias - $g.Clear([System.Drawing.Color]::Transparent) - # Rounded square background in system accent - $bg = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 0, 120, 212)) - $rect = New-Object System.Drawing.Rectangle 24, 24, 208, 208 - $gp = New-Object System.Drawing.Drawing2D.GraphicsPath - $r = 36 - $gp.AddArc($rect.X, $rect.Y, $r, $r, 180, 90) - $gp.AddArc($rect.Right - $r, $rect.Y, $r, $r, 270, 90) - $gp.AddArc($rect.Right - $r, $rect.Bottom - $r, $r, $r, 0, 90) - $gp.AddArc($rect.X, $rect.Bottom - $r, $r, $r, 90, 90) - $gp.CloseFigure() - $g.FillPath($bg, $gp) - $bg.Dispose(); $gp.Dispose() - # White selection-corner brackets - $pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::White), 18 - $pen.StartCap = 'Round'; $pen.EndCap = 'Round' - $g.DrawLine($pen, 70, 70, 70, 110); $g.DrawLine($pen, 70, 70, 110, 70) - $g.DrawLine($pen, 186, 70, 146, 70); $g.DrawLine($pen, 186, 70, 186, 110) - $g.DrawLine($pen, 70, 186, 70, 146); $g.DrawLine($pen, 70, 186, 110, 186) - $g.DrawLine($pen, 186, 186, 186, 146); $g.DrawLine($pen, 186, 186, 146, 186) - $pen.Dispose(); $g.Dispose() + $layouts +} - # Encode bitmap as PNG, then wrap in a real PNG-embedded .ICO container. - $ms = New-Object System.IO.MemoryStream - $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) - $png = $ms.ToArray() - $ms.Dispose(); $bmp.Dispose() +function Get-SnipOverlayIntersections { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Rectangle, + [Parameter(Mandatory)] [object[]]$MonitorLayouts + ) - $fs = [System.IO.File]::Open($Path, 'Create') - $bw = New-Object System.IO.BinaryWriter $fs + if ($null -eq $Rectangle) { + throw [ArgumentNullException]::new('Rectangle') + } + foreach ($propertyName in 'X','Y','Width','Height') { + if ($null -eq $Rectangle.PSObject.Properties[$propertyName]) { + throw [ArgumentException]::new( + "Selection rectangle is missing '$propertyName'.", 'Rectangle') + } + } try { - # ICONDIR (6 bytes) - $bw.Write([uint16]0) # reserved - $bw.Write([uint16]1) # type = icon - $bw.Write([uint16]1) # count - # ICONDIRENTRY (16 bytes) - $bw.Write([byte]0) # width (0 = 256) - $bw.Write([byte]0) # height (0 = 256) - $bw.Write([byte]0) # color count - $bw.Write([byte]0) # reserved - $bw.Write([uint16]1) # planes - $bw.Write([uint16]32) # bit count - $bw.Write([uint32]$png.Length) # bytes in resource - $bw.Write([uint32]22) # offset = 6 + 16 - # PNG payload - $bw.Write($png) - $bw.Flush() - } finally { - $bw.Close(); $fs.Close() + $rectangleX = [double]$Rectangle.X + $rectangleY = [double]$Rectangle.Y + $rectangleWidth = [double]$Rectangle.Width + $rectangleHeight = [double]$Rectangle.Height + } catch { + throw [ArgumentException]::new( + 'Selection rectangle contains a non-numeric coordinate or size.', + 'Rectangle', $_.Exception) } - return $Path -} + if (-not [double]::IsFinite($rectangleX) -or + -not [double]::IsFinite($rectangleY) -or + -not [double]::IsFinite($rectangleWidth) -or + -not [double]::IsFinite($rectangleHeight)) { + throw [ArgumentException]::new( + 'Selection rectangle coordinates and size must be finite.', 'Rectangle') + } + if ($rectangleWidth -le 0 -or $rectangleHeight -le 0) { return } + + $rectangleRight = $rectangleX + $rectangleWidth + $rectangleBottom = $rectangleY + $rectangleHeight + foreach ($layout in @($MonitorLayouts)) { + if ($null -eq $layout) { continue } + foreach ($propertyName in 'Id','Index','PhysicalX','PhysicalY', + 'PhysicalWidth','PhysicalHeight','ScaleX','ScaleY') { + if ($null -eq $layout.PSObject.Properties[$propertyName]) { + throw [ArgumentException]::new( + "Monitor layout is missing '$propertyName'.", 'MonitorLayouts') + } + } -function Get-SnipITIconPath { - $p = Join-Path $script:AppHomeDir 'SnipIT.ico' - # Always regenerate so upgrades pick up icon changes - New-SnipITIcon -Path $p | Out-Null - return $p + $left = [math]::Max($rectangleX, [double]$layout.PhysicalX) + $top = [math]::Max($rectangleY, [double]$layout.PhysicalY) + $right = [math]::Min( + $rectangleRight, + [double]$layout.PhysicalX + [double]$layout.PhysicalWidth) + $bottom = [math]::Min( + $rectangleBottom, + [double]$layout.PhysicalY + [double]$layout.PhysicalHeight) + if ($right -le $left -or $bottom -le $top) { continue } + + $localPhysicalX = $left - [double]$layout.PhysicalX + $localPhysicalY = $top - [double]$layout.PhysicalY + $physicalWidth = $right - $left + $physicalHeight = $bottom - $top + [pscustomobject][ordered]@{ + MonitorId = [string]$layout.Id + MonitorIndex = [int]$layout.Index + PhysicalX = $left + PhysicalY = $top + PhysicalWidth = $physicalWidth + PhysicalHeight = $physicalHeight + LocalPhysicalX = $localPhysicalX + LocalPhysicalY = $localPhysicalY + DipX = $localPhysicalX / [double]$layout.ScaleX + DipY = $localPhysicalY / [double]$layout.ScaleY + DipWidth = $physicalWidth / [double]$layout.ScaleX + DipHeight = $physicalHeight / [double]$layout.ScaleY + } + } } -#endregion +function ConvertTo-SnipCropLocalRect { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Rectangle, + [Parameter(Mandatory)] $Crop + ) -#region First-Run Install =================================================== + if ($Rectangle.Width -le 0 -or $Rectangle.Height -le 0 -or + $Crop.Width -le 0 -or $Crop.Height -le 0) { + return $null + } -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') - ) - 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() + $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 Install-SnipIT { - $appDir = $script:AppHomeDir - $marker = Join-Path $appDir '.installed' - $target = Join-Path $appDir 'SnipIT.ps1' +function Resolve-PreviewKeyCommand { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$FocusedRole, + [Parameter(Mandatory)] [hashtable]$EditorState, + [Parameter(Mandatory)] [string]$Key, + [AllowNull()][AllowEmptyCollection()] [string[]]$Modifiers = @() + ) - $fresh = -not (Test-Path $marker) + $hasCtrl = @($Modifiers) -contains 'Ctrl' + $hasShift = @($Modifiers) -contains 'Shift' + $hasAlt = @($Modifiers) -contains 'Alt' - # Copy the running script into snipIT-Home unless we're already running - # from there (compare normalized absolute paths). - $runningFull = [System.IO.Path]::GetFullPath($PSCommandPath) - $targetFull = [System.IO.Path]::GetFullPath($target) - if ($runningFull -ne $targetFull) { - Copy-Item -LiteralPath $PSCommandPath -Destination $target -Force + 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 } - Write-SnipITShortcuts -AppDir $appDir -ScriptTarget $target + 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 ($fresh) { Set-Content -LiteralPath $marker -Value (Get-Date -Format o) } - return $fresh -} + if ($FocusedRole -eq 'PropertyEditor' -or $EditorState.EditingProperty) { + if ($Key -eq 'Escape') { return 'CancelPropertyEdit' } + return 'PropertyInput' + } -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). - if (Test-Path $script:AppHomeDir) { - Get-ChildItem -LiteralPath $script:AppHomeDir -Force | - Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + if ($FocusedRole -eq 'Button' -and @($Modifiers).Count -eq 0 -and + $Key -in 'Space', 'Enter') { + return 'ActivateFocusedButton' + } + if ($null -ne $EditorState.Draft -and $Key -eq 'Escape') { return 'CancelDraft' } + if ($null -ne $EditorState.Draft -and $hasCtrl -and $Key -eq 'Z') { return $null } + if ($null -ne $EditorState.SelectionId -and $Key -eq 'Escape') { + return 'ClearSelection' } -} -$freshInstall = Install-SnipIT + # Annotation movement/deletion belongs exclusively to the canvas focus + # scope. Selection Escape is global after popup/editor/draft precedence so + # it cannot fall through to tool deactivation or window close. + if ($FocusedRole -eq 'Canvas' -and $null -ne $EditorState.SelectionId) { + 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" + } + } -#endregion + if ($Key -eq 'Escape' -and + -not [string]::IsNullOrEmpty([string]$EditorState.ActiveTool) -and + $EditorState.ActiveTool -ne 'Select') { + return 'ActivateSelect' + } -#region Capture Core ======================================================== + if ($hasCtrl) { + switch ($Key.ToLowerInvariant()) { + 'c' { return 'CopyKeepOpen' } + 'z' { if ($hasShift) { return 'Redo' } else { return 'Undo' } } + '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' } + } + } -function Get-VirtualScreenBounds { - $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen - [pscustomobject]@{ X=$vs.X; Y=$vs.Y; Width=$vs.Width; Height=$vs.Height } + if ($FocusedRole -eq 'Canvas' -and $Key -eq 'Space') { return 'TemporaryPan' } + if ($Key -eq 'Escape') { return 'ClosePreview' } + return $null } -function New-ScreenBitmap { - param($X, $Y, $Width, $Height) - $bmp = New-Object System.Drawing.Bitmap $Width, $Height, - ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb) - $g = [System.Drawing.Graphics]::FromImage($bmp) - $g.CopyFromScreen($X, $Y, 0, 0, - (New-Object System.Drawing.Size $Width, $Height), - [System.Drawing.CopyPixelOperation]::SourceCopy) - $g.Dispose() - return $bmp -} +function Get-SnipCoordinatorDecision { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$Phase, + [Parameter(Mandatory)] [string]$Event, + [Parameter(Mandatory)] [bool]$HasPending + ) -$script:SelfWindowHandles = New-Object System.Collections.Generic.List[IntPtr] + $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') + } -function Register-SelfWindowHandle { - # Tracks an HWND we own (widget, preview, hotkey form, console) so the - # capture path can exclude it via Resolve-WindowCaptureTarget and the - # snapshot path can hide it via Hide-OwnSnipITWindowsForCapture. - param([IntPtr]$Hwnd) - if ($Hwnd -eq [IntPtr]::Zero) { return } - if (-not $script:SelfWindowHandles.Contains($Hwnd)) { - [void]$script:SelfWindowHandles.Add($Hwnd) + $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 + } } -} -function Unregister-SelfWindowHandle { - param([IntPtr]$Hwnd) - [void]$script:SelfWindowHandles.Remove($Hwnd) + 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'." + ) } -function Hide-OwnSnipITWindowsForCapture { - # Hides every registered SnipIT-owned hwnd that is currently visible so - # our widget / preview / etc. don't get baked into a desktop snapshot. - # Returns the list of hidden hwnds; pass it to - # Show-OwnSnipITWindowsForCapture to restore them without stealing focus. - [OutputType([System.Collections.Generic.List[IntPtr]])] - $hidden = New-Object System.Collections.Generic.List[IntPtr] - foreach ($h in @($script:SelfWindowHandles)) { - if ($h -eq [IntPtr]::Zero) { continue } - if (-not [Native]::IsWindowVisible($h)) { continue } - if ([Native]::ShowWindow($h, [Native]::SW_HIDE)) { $hidden.Add($h) } +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 ($hidden.Count -gt 0) { - # Pump pending UI work and yield briefly so DWM composes a frame - # without our chrome before CopyFromScreen samples the desktop. - try { [System.Windows.Forms.Application]::DoEvents() } catch {} - Start-Sleep -Milliseconds 80 + 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 } - # Wrap in a single-element array so PowerShell does not unroll the List - # across the output stream. - ,$hidden } -function Show-OwnSnipITWindowsForCapture { - param($Hidden) - if (-not $Hidden -or $Hidden.Count -eq 0) { return } - foreach ($h in $Hidden) { - # SW_SHOWNA = show without activating, so we don't yank focus from - # whatever window the user was on while the snapshot ran. - [void][Native]::ShowWindow([IntPtr]$h, [Native]::SW_SHOWNA) +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 Convert-BitmapToBitmapSource { - param([System.Drawing.Bitmap]$Bitmap) - # DeleteObject lives on the main [Native] class defined at startup — no per-call JIT. - $hbmp = [IntPtr]::Zero +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 { - $hbmp = $Bitmap.GetHbitmap() - $src = [System.Windows.Interop.Imaging]::CreateBitmapSourceFromHBitmap( - $hbmp, [IntPtr]::Zero, - [System.Windows.Int32Rect]::Empty, - [System.Windows.Media.Imaging.BitmapSizeOptions]::FromEmptyOptions()) - $src.Freeze() - return $src - } finally { - if ($hbmp -ne [IntPtr]::Zero) { [Native]::DeleteObject($hbmp) | Out-Null } + Invoke-SnipResourceDispose -Resource $owned + } catch { + $Coordinator.LastError = $_ } } -function Save-CaptureToFile { - param([System.Drawing.Bitmap]$Bitmap) - $defaultDir = Join-Path ([Environment]::GetFolderPath('MyPictures')) 'Snips' - New-Item -ItemType Directory -Force -Path $defaultDir | Out-Null - $dlg = New-Object Microsoft.Win32.SaveFileDialog - $dlg.Filter = 'PNG image (*.png)|*.png|JPEG (*.jpg)|*.jpg|Bitmap (*.bmp)|*.bmp' - $dlg.FileName = Get-DefaultSnipFilename - $dlg.InitialDirectory = $defaultDir - if ($dlg.ShowDialog()) { - $filterFormat = switch ($dlg.FilterIndex) { - 2 { 'Jpeg' } - 3 { 'Bmp' } - default { 'Png' } +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'.") } - $savePath = Resolve-SaveImagePath -Path $dlg.FileName -FilterFormat $filterFormat - $fmt = switch (Get-ImageFormatNameFromPath $savePath) { - 'Jpeg' { [System.Drawing.Imaging.ImageFormat]::Jpeg } - 'Bmp' { [System.Drawing.Imaging.ImageFormat]::Bmp } - default { [System.Drawing.Imaging.ImageFormat]::Png } + 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 } } - $Bitmap.Save($savePath, $fmt) - return $savePath } - return $null -} -function Show-AboutWindow { - [xml]$xaml = @" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + - - - - - + + + + - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - # Initial window size is fixed by the XAML (Width=980 Height=700). The user - # can resize freely (ResizeMode=CanResize). Fit-to-viewport runs on Loaded - # so the image is auto-scaled to the initial viewport regardless of size. +'@ +} - # Mica backdrop intentionally not applied: DwmSetWindowAttribute(DWMSBT_MAINWINDOW) - # is a no-op on windows declared AllowsTransparency="True" (this one is — see the - # XAML above). See Set-MicaBackdrop for the P/Invoke if that constraint is ever lifted. +# source: src/10-Bootstrap.ps1 +#requires -Version 7.5 +# Generated deterministically from the reviewed SnipIT.ps1 AST by scripts/Export-SnipITModules.ps1. - # Surface ANY WPF dispatcher exception. Copy to clipboard AND write to - # %LOCALAPPDATA%\SnipIT\last-error.txt — a plain MessageBox doesn't always - # let you select text on Win11. - $win.Dispatcher.add_UnhandledException({ - param($sender, $e) - $ex = $e.Exception - $msg = "$($ex.GetType().FullName)`n$($ex.Message)`n`n$($ex.StackTrace)" - try { - $logFile = Join-Path $script:AppHomeDir 'last-error.txt' - Set-Content -LiteralPath $logFile -Value $msg -Encoding UTF8 - } catch {} - try { [System.Windows.Clipboard]::SetText($msg) } catch {} - try { - [System.Windows.Forms.MessageBox]::Show( - "$msg`n`n--- ALSO COPIED TO CLIPBOARD ---`nFile: $logFile", - 'SnipIT preview error (text on clipboard)', 'OK', 'Error') | Out-Null - } catch {} - $e.Handled = $true - }) +$script:UndoStackMaxDepth = 100 - $highlightLayer = $win.FindName('HighlightLayer') - $highlightBtn = $win.FindName('HighlightBtn') - $rectBtn = $win.FindName('RectBtn') - $arrowBtn = $win.FindName('ArrowBtn') - $textBtn = $win.FindName('TextBtn') - $imageHost = $win.FindName('ImageHost') - $colorBar = $win.FindName('ColorBar') - $scroller = $win.FindName('Scroller') - $zoomText = $win.FindName('ZoomText') +$script:DiagRingSize = 200 - # Color palette: name → highlight (low alpha) and text (full alpha) variants - # Each entry: HiR/HiG/HiB used for highlights @ alpha 110, text @ alpha 255. - $palette = [ordered]@{ - Yellow = @{ R=255; G=222; B=0 } - Green = @{ R=70; G=210; B=110 } - Pink = @{ R=255; G=90; B=180 } - Blue = @{ R=80; G=170; B=255 } - Orange = @{ R=255; G=150; B=40 } - Red = @{ R=255; G=60; B=60 } - } +$script:DiagRing = New-Object System.Collections.Generic.Queue[string] - $state = [pscustomobject]@{ - Annotations = New-Object System.Collections.ArrayList # image-pixel coords - UndoStack = New-Object System.Collections.Stack - RedoStack = New-Object System.Collections.Stack - ActiveColor = 'Yellow' - Drawing = $false - DrawingTool = $null - AnchorCanvas = $null - DraftRect = $null - EditingText = $false - Zoom = 1.0 - # Pan (Hand) mode: active when no annotation tool is checked. - Panning = $false - PanStartSv = $null # mouse position at pan-begin, in Scroller-local coords - PanOrigX = 0.0 # Scroller.HorizontalOffset at pan-begin - PanOrigY = 0.0 # Scroller.VerticalOffset at pan-begin - } +$script:SnipITAppVersion = '0.1.1' - function script:Get-DisplayedImageBounds { - # Canvas coordinates are in natural image-pixel space — the - # LayoutTransform only affects rendering, not local coords. Image - # and Canvas are both sized to Bitmap.Width x Bitmap.Height. - [pscustomobject]@{ - X = 0 - Y = 0 - W = $Bitmap.Width - H = $Bitmap.Height - Scale = 1.0 - } - } +$script:UtilityContext = $null - function script:To-WpfColor { - param([int]$A, [int]$R, [int]$G, [int]$B) - [System.Windows.Media.Color]::FromArgb($A, $R, $G, $B) - } +$script:SingleInstanceMutex = $null - function script:Render-Annotations { - $highlightLayer.Children.Clear() - $b = Get-DisplayedImageBounds - if (-not $b) { return } - foreach ($a in $state.Annotations) { - $rgb = $palette[$a.Color] - if (-not $rgb) { continue } - if ($a.Type -eq 'highlight' -or $a.Type -eq 'rect') { - $rect = New-Object System.Windows.Shapes.Rectangle - if ($a.Type -eq 'highlight') { - $rect.Fill = New-Object System.Windows.Media.SolidColorBrush( - (To-WpfColor 110 $rgb.R $rgb.G $rgb.B)) - } - $rect.Stroke = New-Object System.Windows.Media.SolidColorBrush( - (To-WpfColor 255 $rgb.R $rgb.G $rgb.B)) - $rect.StrokeThickness = if ($a.Type -eq 'rect') { 3 } else { 1.5 } - $rect.Width = $a.W * $b.Scale - $rect.Height = $a.H * $b.Scale - $rect.IsHitTestVisible = $false - [System.Windows.Controls.Canvas]::SetLeft($rect, $b.X + $a.X * $b.Scale) - [System.Windows.Controls.Canvas]::SetTop($rect, $b.Y + $a.Y * $b.Scale) - [void]$highlightLayer.Children.Add($rect) - } elseif ($a.Type -eq 'arrow') { - $line = New-Object System.Windows.Shapes.Line - $line.X1 = $b.X + $a.X * $b.Scale - $line.Y1 = $b.Y + $a.Y * $b.Scale - $line.X2 = $b.X + ($a.X + $a.W) * $b.Scale - $line.Y2 = $b.Y + ($a.Y + $a.H) * $b.Scale - $line.Stroke = New-Object System.Windows.Media.SolidColorBrush( - (To-WpfColor 255 $rgb.R $rgb.G $rgb.B)) - $line.StrokeThickness = 4 - $line.StrokeStartLineCap = 'Round' - $line.StrokeEndLineCap = 'Triangle' - $line.IsHitTestVisible = $false - [void]$highlightLayer.Children.Add($line) - } elseif ($a.Type -eq 'text') { - $tb = New-Object System.Windows.Controls.TextBlock - $tb.Text = $a.Text - $tb.FontFamily = New-Object System.Windows.Media.FontFamily 'Segoe UI' - $tb.FontWeight = [System.Windows.FontWeights]::SemiBold - $tb.FontSize = $a.FontSize * $b.Scale - $tb.Foreground = New-Object System.Windows.Media.SolidColorBrush( - (To-WpfColor 255 $rgb.R $rgb.G $rgb.B)) - $tb.IsHitTestVisible = $false - [System.Windows.Controls.Canvas]::SetLeft($tb, $b.X + $a.X * $b.Scale) - [System.Windows.Controls.Canvas]::SetTop($tb, $b.Y + $a.Y * $b.Scale) - [void]$highlightLayer.Children.Add($tb) - } - } - } +$script:SnipBootstrapInitializer = { - function script:Trim-SnipStack { - # Cap a Stack to its $Max most recent entries (oldest drop off the bottom). - param($Stack, [int]$Max = $script:UndoStackMaxDepth) - if ($Stack.Count -le $Max) { return } - $keep = Get-TrimmedRecent -Items $Stack.ToArray() -MaxDepth $Max - $Stack.Clear() - for ($i = $keep.Count - 1; $i -ge 0; $i--) { [void]$Stack.Push($keep[$i]) } + param([ValidateSet('Bootstrap', 'Settings')][string]$Phase) + + if ($Phase -eq 'Bootstrap') { + +if ([System.Threading.Thread]::CurrentThread.GetApartmentState() -ne 'STA') { + $pwsh = (Get-Process -Id $PID).Path + Start-Process -FilePath $pwsh ` + -ArgumentList @('-Sta','-NoProfile','-WindowStyle','Hidden','-File',$script:SnipEntryPath) ` + -WindowStyle Hidden + return +} + +if (-not ('ConsoleHider' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class ConsoleHider { + [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); +} +'@ +} + +$h = [ConsoleHider]::GetConsoleWindow() + +if ($h -ne [IntPtr]::Zero) { + [ConsoleHider]::ShowWindow($h, 0) | Out-Null + # Track even though hidden — a future ShowWindow we don't control could + # bring it back, and the capture-target guard wants it in the self set. + $script:ConsoleHwnd = $h +} + +Add-Type -AssemblyName PresentationFramework + +Add-Type -AssemblyName PresentationCore + +Add-Type -AssemblyName WindowsBase + +Add-Type -AssemblyName System.Windows.Forms + +Add-Type -AssemblyName System.Drawing + +if (-not $env:SNIPIT_TEST_MODE) { + $script:SingleInstanceCreated = $false + $script:SingleInstanceMutex = New-Object System.Threading.Mutex( + $true, 'Local\SnipIT-SingleInstance-v1', [ref]$script:SingleInstanceCreated) + if (-not $script:SingleInstanceCreated) { + try { + [System.Windows.Forms.MessageBox]::Show( + '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 {} + return } +} + +$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 +} + +try { [System.Windows.Application]::Current.ThemeMode = 'System' } catch {} + + return $true - function script:Snapshot-State { - $state.UndoStack.Push( (Copy-AnnotationList $state.Annotations) ) - $state.RedoStack.Clear() - Trim-SnipStack $state.UndoStack } - function script:Restore-State { - param($snapshot) - $state.Annotations.Clear() - foreach ($a in (Copy-AnnotationList $snapshot)) { [void]$state.Annotations.Add($a) } - Render-Annotations +$script:Settings = Get-SnipDefaultSettings + +$script:SnipFreshInstall = $false + +if (-not $env:SNIPIT_TEST_MODE) { + $script:Settings = Read-SnipSettings -Path $script:SettingsPath + Save-SnipSettings -Settings $script:Settings -Path $script:SettingsPath + $script:SnipFreshInstall = 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 +} + + $true + +} + +function Get-SnipXamlText { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Name + ) + + if ($null -ne $script:SnipEmbeddedXaml -and + $script:SnipEmbeddedXaml.Contains($Name)) { + return [string]$script:SnipEmbeddedXaml[$Name] } - function script:Do-Undo { - if ($state.UndoStack.Count -eq 0) { return } - $state.RedoStack.Push( (Copy-AnnotationList $state.Annotations) ) - Trim-SnipStack $state.RedoStack - Restore-State $state.UndoStack.Pop() + $resolver = Get-Variable -Name SnipDevelopmentXamlResolver -Scope Script ` + -ValueOnly -ErrorAction Ignore + if ($resolver -is [scriptblock]) { + return & $resolver $Name } - function script:Do-Redo { - if ($state.RedoStack.Count -eq 0) { return } - $state.UndoStack.Push( (Copy-AnnotationList $state.Annotations) ) - Trim-SnipStack $state.UndoStack - Restore-State $state.RedoStack.Pop() + throw ("Embedded XAML '$Name' is unavailable and no development XAML " + + 'resolver is configured.') +} + +function Get-SnipSettingsPath { + param([string]$LocalAppData = $env:LOCALAPPDATA) + + if ([string]::IsNullOrWhiteSpace($LocalAppData)) { + $LocalAppData = [Environment]::GetFolderPath('LocalApplicationData') } + Join-Path $LocalAppData 'SnipIT\settings.json' +} - # Named color picker. Tests and the real swatch click handler both call - # this. Also live-updates the foreground of any text box that is - # currently being edited, so the user sees the color change immediately. - $pickColor = { - param([string]$Name) - if (-not $palette.Contains($Name)) { return } - $state.ActiveColor = $Name - if ($state.EditingText) { - foreach ($child in $highlightLayer.Children) { - if ($child -is [System.Windows.Controls.TextBox]) { - $rgbL = $palette[$Name] - $child.Foreground = New-Object System.Windows.Media.SolidColorBrush( - (To-WpfColor 255 $rgbL.R $rgbL.G $rgbL.B)) - $child.BorderBrush = New-Object System.Windows.Media.SolidColorBrush( - (To-WpfColor 200 $rgbL.R $rgbL.G $rgbL.B)) - break - } - } +function Write-SnipDiag { + [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' } - # Update the active swatch in-place instead of rebuilding the bar. - # Rebuilding would re-attach click handlers, and because $pickColor - # self-reference inside its own body resolves to $null (the closure - # captured it before assignment completed), the rebuilt handlers - # would carry a dead reference. Keep the original handlers alive. - $accent = [System.Windows.Media.Color]::FromArgb(255, 0x5B, 0x8D, 0xEF) - foreach ($ring in $colorBar.Children) { - if ($ring.Tag -eq $Name) { - $ring.BorderBrush = New-Object System.Windows.Media.SolidColorBrush($accent) - } else { - $ring.BorderBrush = [System.Windows.Media.Brushes]::Transparent - } + $logDir = Split-Path $Path -Parent + if (-not [string]::IsNullOrWhiteSpace($logDir)) { + New-Item -ItemType Directory -Force -Path $logDir -ErrorAction Stop | Out-Null } - }.GetNewClosure() + Add-Content -LiteralPath $Path -Value $line -Encoding utf8 -ErrorAction Stop + } catch { + # Diagnostics must never become a second application failure. + } +} - # Build color swatches. - # $pickColor is passed explicitly: `function script:` creates a new scope - # that does NOT inherit Show-PreviewWindow's locals for closure purposes, - # so a { & $pickColor ... }.GetNewClosure() inside this body would capture - # $null. Accept it as a parameter so the closure has a real reference. - function script:Build-ColorBar { - param([scriptblock]$pickColor) - $colorBar.Children.Clear() - # Circular swatches with a 2px accent outline ring on the active one. - # Fixed size (no jump) — the ring lives on an outer Border + padding. - $accentColor = [System.Windows.Media.Color]::FromArgb(255, 0x5B, 0x8D, 0xEF) - foreach ($name in $palette.Keys) { - $rgb = $palette[$name] - $isActive = ($state.ActiveColor -eq $name) +function Get-SnipDiag { $script:DiagRing.ToArray() } - $ring = New-Object System.Windows.Controls.Border - $ring.Width = 22 - $ring.Height = 22 - $ring.CornerRadius = New-Object System.Windows.CornerRadius 11 - $ring.Margin = New-Object System.Windows.Thickness 3, 0, 3, 0 - $ring.Padding = New-Object System.Windows.Thickness 2 - if ($isActive) { - $ring.BorderBrush = New-Object System.Windows.Media.SolidColorBrush($accentColor) - $ring.BorderThickness = New-Object System.Windows.Thickness 2 - } else { - $ring.BorderBrush = [System.Windows.Media.Brushes]::Transparent - $ring.BorderThickness = New-Object System.Windows.Thickness 2 - } - $ring.Cursor = [System.Windows.Input.Cursors]::Hand - # Non-focusable so clicking a swatch while a text box is open - # doesn't steal keyboard focus (which would fire LostFocus → - # commit the text in the OLD color before we can update it). - $ring.Focusable = $false - $ring.ToolTip = $name - $ring.Tag = $name +function Read-SnipSettings { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$Path, + [string]$PicturesDir = [Environment]::GetFolderPath('MyPictures') + ) - $dot = New-Object System.Windows.Controls.Border - $dot.Width = 14 - $dot.Height = 14 - $dot.CornerRadius = New-Object System.Windows.CornerRadius 7 - $dot.Background = New-Object System.Windows.Media.SolidColorBrush( - (To-WpfColor 255 $rgb.R $rgb.G $rgb.B)) - $ring.Child = $dot + $defaults = Get-SnipDefaultSettings -PicturesDir $PicturesDir + $diagPath = Join-Path (Split-Path $Path -Parent) 'logs\snipit.log' + if (-not (Test-Path -LiteralPath $Path -PathType Leaf -ErrorAction Stop)) { + Write-SnipDiag -Message "Settings file not found; using defaults: $Path" -Path $diagPath + return $defaults + } - $ring.Add_MouseLeftButtonDown({ & $pickColor $this.Tag }.GetNewClosure()) - [void]$colorBar.Children.Add($ring) - } + try { + $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 } - Build-ColorBar $pickColor - # Tool toggle interlock — at most one tool active. No tool = pan (Hand) mode. - $tools = @($highlightBtn, $rectBtn, $arrowBtn, $textBtn) - foreach ($t in $tools) { - $t.Add_Checked({ - $me = $this - foreach ($other in $tools) { if ($other -ne $me) { $other.IsChecked = $false } } - }.GetNewClosure()) + function Get-LoadedSetting { + param([Parameter(Mandatory)] [string]$Name) + $property = $loaded.PSObject.Properties[$Name] + if ($null -eq $property) { return $null } + $property.Value } - # No tool checked by default → pan mode is active. - # Note: $scroller is resolved later (XAML lookup). We bind the cursor - # refresh to tool-button state changes so it stays in sync as the - # user toggles tools on/off. - $updateCursor = { - $anyTool = $highlightBtn.IsChecked -or $rectBtn.IsChecked -or - $arrowBtn.IsChecked -or $textBtn.IsChecked - $highlightLayer.Cursor = if ($anyTool) { - [System.Windows.Input.Cursors]::Cross - } else { - [System.Windows.Input.Cursors]::Hand + + # 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 } - }.GetNewClosure() - foreach ($t in $tools) { - $t.Add_Checked($updateCursor) - $t.Add_Unchecked($updateCursor) } - & $updateCursor # initial: Hand (no tool) - # ---- Named core helpers (closures so tests can drive them directly) ---- + $saveFolderValue = Get-LoadedSetting -Name 'SaveFolder' + $saveFolder = if ($saveFolderValue -is [string] -and + -not [string]::IsNullOrWhiteSpace($saveFolderValue)) { + $saveFolderValue + } else { + $defaults.SaveFolder + } - $beginPan = { - param([System.Windows.Point]$SvPoint) - $state.Panning = $true - $state.PanStartSv = $SvPoint - $state.PanOrigX = $scroller.HorizontalOffset - $state.PanOrigY = $scroller.VerticalOffset - $highlightLayer.Cursor = [System.Windows.Input.Cursors]::SizeAll - try { $highlightLayer.CaptureMouse() | Out-Null } catch {} - }.GetNewClosure() + $saveFormatValue = Get-LoadedSetting -Name 'SaveFormat' + $saveFormat = if ($saveFormatValue -is [string] -and + $saveFormatValue -cin @('Png', 'Jpeg', 'Bmp')) { + $saveFormatValue + } else { + $defaults.SaveFormat + } - $updatePan = { - param([System.Windows.Point]$SvPoint) - if (-not $state.Panning) { return } - $dx = $SvPoint.X - $state.PanStartSv.X - $dy = $SvPoint.Y - $state.PanStartSv.Y - $scroller.ScrollToHorizontalOffset($state.PanOrigX - $dx) - $scroller.ScrollToVerticalOffset( $state.PanOrigY - $dy) - }.GetNewClosure() + $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 + } +} - $endPan = { - if (-not $state.Panning) { return } - $state.Panning = $false - try { $highlightLayer.ReleaseMouseCapture() } catch {} - $highlightLayer.Cursor = [System.Windows.Input.Cursors]::Hand - }.GetNewClosure() +function Save-SnipSettings { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object]$Settings, + [Parameter(Mandatory)] [string]$Path + ) - $beginDraw = { - param([string]$Tool, [System.Windows.Point]$P) - $state.Drawing = $true - $state.DrawingTool = $Tool - $state.AnchorCanvas = $P - $rgb = $palette[$state.ActiveColor] - if ($Tool -eq 'arrow') { - $line = New-Object System.Windows.Shapes.Line - $line.X1 = $P.X; $line.Y1 = $P.Y; $line.X2 = $P.X; $line.Y2 = $P.Y - $line.Stroke = New-Object System.Windows.Media.SolidColorBrush( - (To-WpfColor 255 $rgb.R $rgb.G $rgb.B)) - $line.StrokeThickness = 4 - $line.StrokeStartLineCap = 'Round' - $line.StrokeEndLineCap = 'Triangle' - $line.IsHitTestVisible = $false - [void]$highlightLayer.Children.Add($line) - $state.DraftRect = $line - } else { + $directory = Split-Path $Path -Parent + if (-not [string]::IsNullOrWhiteSpace($directory)) { + 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 -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 -ErrorAction Stop) { + Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction Stop + } + } +} + +function New-SnipITIcon { + param([Parameter(Mandatory)] [string]$Path) + # Draw at 256x256 so the icon is sharp at every shortcut size. + $size = 256 + $bmp = New-Object System.Drawing.Bitmap $size, $size + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $g.Clear([System.Drawing.Color]::Transparent) + # Rounded square background in system accent + $bg = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 0, 120, 212)) + $rect = New-Object System.Drawing.Rectangle 24, 24, 208, 208 + $gp = New-Object System.Drawing.Drawing2D.GraphicsPath + $r = 36 + $gp.AddArc($rect.X, $rect.Y, $r, $r, 180, 90) + $gp.AddArc($rect.Right - $r, $rect.Y, $r, $r, 270, 90) + $gp.AddArc($rect.Right - $r, $rect.Bottom - $r, $r, $r, 0, 90) + $gp.AddArc($rect.X, $rect.Bottom - $r, $r, $r, 90, 90) + $gp.CloseFigure() + $g.FillPath($bg, $gp) + $bg.Dispose(); $gp.Dispose() + # White selection-corner brackets + $pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::White), 18 + $pen.StartCap = 'Round'; $pen.EndCap = 'Round' + $g.DrawLine($pen, 70, 70, 70, 110); $g.DrawLine($pen, 70, 70, 110, 70) + $g.DrawLine($pen, 186, 70, 146, 70); $g.DrawLine($pen, 186, 70, 186, 110) + $g.DrawLine($pen, 70, 186, 70, 146); $g.DrawLine($pen, 70, 186, 110, 186) + $g.DrawLine($pen, 186, 186, 186, 146); $g.DrawLine($pen, 186, 186, 146, 186) + $pen.Dispose(); $g.Dispose() + + # Encode bitmap as PNG, then wrap in a real PNG-embedded .ICO container. + $ms = New-Object System.IO.MemoryStream + $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) + $png = $ms.ToArray() + $ms.Dispose(); $bmp.Dispose() + + $fs = [System.IO.File]::Open($Path, 'Create') + $bw = New-Object System.IO.BinaryWriter $fs + try { + # ICONDIR (6 bytes) + $bw.Write([uint16]0) # reserved + $bw.Write([uint16]1) # type = icon + $bw.Write([uint16]1) # count + # ICONDIRENTRY (16 bytes) + $bw.Write([byte]0) # width (0 = 256) + $bw.Write([byte]0) # height (0 = 256) + $bw.Write([byte]0) # color count + $bw.Write([byte]0) # reserved + $bw.Write([uint16]1) # planes + $bw.Write([uint16]32) # bit count + $bw.Write([uint32]$png.Length) # bytes in resource + $bw.Write([uint32]22) # offset = 6 + 16 + # PNG payload + $bw.Write($png) + $bw.Flush() + } finally { + $bw.Close(); $fs.Close() + } + return $Path +} + +function Get-SnipITIconPath { + $p = Join-Path $script:AppHomeDir 'SnipIT.ico' + # Always regenerate so upgrades pick up icon changes + New-SnipITIcon -Path $p | Out-Null + return $p +} + +function Write-SnipITShortcuts { + [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 + ) + + $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 { + 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 the per-user app directory unless it is + # already the executing copy. + $runningFull = [System.IO.Path]::GetFullPath($script:SnipInstallSourcePath) + $targetFull = [System.IO.Path]::GetFullPath($target) + if ($runningFull -ne $targetFull) { + Copy-Item -LiteralPath $script:SnipInstallSourcePath -Destination $target -Force + } + + $iconPath = Get-SnipITIconPath + Write-SnipITShortcuts -Paths $Paths -IconPath $iconPath + + if ($fresh) { Set-Content -LiteralPath $marker -Value (Get-Date -Format o) } + return $fresh +} + +function Uninstall-SnipIT { + Remove-Item -Force -ErrorAction SilentlyContinue ` + $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 + } +} + +function New-SnipThemeResources { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [bool]$HighContrast, + [Nullable[bool]]$AnimationsEnabled = $null + ) + + $tokens = Get-SnipThemeTokens + $resources = [System.Windows.ResourceDictionary]::new() + $newBrush = { + param([Parameter(Mandatory)] [string]$Color) + $converted = [System.Windows.Media.ColorConverter]::ConvertFromString($Color) + $brush = [System.Windows.Media.SolidColorBrush]::new($converted) + $brush.Freeze() + $brush + } + $motionEnabled = if ($null -eq $AnimationsEnabled) { + [bool][System.Windows.SystemParameters]::ClientAreaAnimation + } else { + [bool]$AnimationsEnabled + } + + $resources['SnipIslandRadius'] = [double]$tokens.IslandRadius + $resources['SnipControlRadius'] = [double]$tokens.ControlRadius + $resources['SnipCornerHeight'] = [double]$tokens.CornerIslandHeight + $resources['SnipToolTarget'] = [double]$tokens.ToolTarget + $resources['SnipDockHeight'] = [double]$tokens.MainDockHeight + $resources['SnipPropertyHeight'] = [double]$tokens.PropertyIslandHeight + $resources['SnipHairlineThickness'] = [double]1 + $resources['SnipFocusThickness'] = [double]2 + $resources['SnipHighContrast'] = [bool]$HighContrast + $resources['SnipAnimationsEnabled'] = [bool]$motionEnabled + $resources['SnipPopupAnimation'] = if ($motionEnabled) { + [System.Windows.Controls.Primitives.PopupAnimation]::Fade + } else { + [System.Windows.Controls.Primitives.PopupAnimation]::None + } + $resources['SnipMotionDuration'] = if ($motionEnabled) { + [timespan]::FromMilliseconds(140) + } else { + [timespan]::Zero + } + $resources['SnipDisplayFont'] = [System.Windows.Media.FontFamily]::new('Segoe UI Variable Display') + $resources['SnipTextFont'] = [System.Windows.Media.FontFamily]::new('Segoe UI Variable Text') + $resources['SnipCodeFont'] = [System.Windows.Media.FontFamily]::new('Cascadia Code') + + if ($HighContrast) { + $resources['SnipPageBrush'] = [System.Windows.SystemColors]::WindowBrush + $resources['SnipCanvasBrush'] = [System.Windows.SystemColors]::WindowBrush + $resources['SnipGlassScrimBrush'] = [System.Windows.SystemColors]::WindowBrush + $resources['SnipGlassGradientBrush'] = [System.Windows.SystemColors]::WindowBrush + $resources['SnipPrimaryTextBrush'] = [System.Windows.SystemColors]::WindowTextBrush + $resources['SnipSecondaryTextBrush'] = [System.Windows.SystemColors]::WindowTextBrush + $resources['SnipMutedTextBrush'] = [System.Windows.SystemColors]::GrayTextBrush + $resources['SnipHairlineBrush'] = [System.Windows.SystemColors]::WindowTextBrush + $resources['SnipInnerHighlightBrush'] = [System.Windows.SystemColors]::WindowTextBrush + $resources['SnipHoverBrush'] = [System.Windows.SystemColors]::ControlBrush + $resources['SnipAccentBrush'] = [System.Windows.SystemColors]::HighlightBrush + $resources['SnipAccentTintBrush'] = [System.Windows.SystemColors]::HighlightBrush + $resources['SnipAccentTextBrush'] = [System.Windows.SystemColors]::HighlightTextBrush + $resources['SnipAccentInkBrush'] = [System.Windows.SystemColors]::HighlightTextBrush + $resources['SnipFocusBrush'] = [System.Windows.SystemColors]::WindowTextBrush + $resources['SnipCoralBrush'] = [System.Windows.SystemColors]::HighlightBrush + $resources['SnipCoralTintBrush'] = [System.Windows.SystemColors]::HighlightBrush + $resources['SnipMintBrush'] = [System.Windows.SystemColors]::HighlightBrush + $resources['SnipAmberBrush'] = [System.Windows.SystemColors]::HighlightBrush + $resources['SnipVioletBrush'] = [System.Windows.SystemColors]::HighlightBrush + $resources['SnipGlassLayers'] = [string[]]@('SystemBackground', 'SystemBorder') + } else { + $resources['SnipPageBrush'] = & $newBrush $tokens.PageBlack + $resources['SnipCanvasBrush'] = & $newBrush $tokens.CanvasBlack + $resources['SnipGlassScrimBrush'] = & $newBrush $tokens.GlassScrim + + $glass = [System.Windows.Media.LinearGradientBrush]::new() + $glass.StartPoint = [System.Windows.Point]::new(0, 0) + $glass.EndPoint = [System.Windows.Point]::new(0, 1) + $glass.GradientStops.Add([System.Windows.Media.GradientStop]::new( + [System.Windows.Media.ColorConverter]::ConvertFromString($tokens.GlassTop), 0)) + $glass.GradientStops.Add([System.Windows.Media.GradientStop]::new( + [System.Windows.Media.ColorConverter]::ConvertFromString($tokens.GlassBottom), 1)) + $glass.Freeze() + $resources['SnipGlassGradientBrush'] = $glass + + $resources['SnipPrimaryTextBrush'] = & $newBrush $tokens.PrimaryText + $resources['SnipSecondaryTextBrush'] = & $newBrush $tokens.SecondaryText + $resources['SnipMutedTextBrush'] = & $newBrush $tokens.MutedText + $resources['SnipHairlineBrush'] = & $newBrush $tokens.Hairline + $resources['SnipInnerHighlightBrush'] = & $newBrush $tokens.Hairline + $resources['SnipHoverBrush'] = & $newBrush $tokens.HoverFill + $resources['SnipAccentBrush'] = & $newBrush $tokens.Accent + $accentTintColor = [System.Windows.Media.ColorConverter]::ConvertFromString($tokens.Accent) + $accentTintColor.A = 0x2E + $accentTintBrush = [System.Windows.Media.SolidColorBrush]::new($accentTintColor) + $accentTintBrush.Freeze() + $resources['SnipAccentTintBrush'] = $accentTintBrush + $resources['SnipAccentTextBrush'] = & $newBrush $tokens.AccentText + $resources['SnipAccentInkBrush'] = & $newBrush $tokens.PageBlack + $resources['SnipFocusBrush'] = $resources['SnipAccentBrush'] + $resources['SnipCoralBrush'] = & $newBrush $tokens.Coral + $coralTintColor = [System.Windows.Media.ColorConverter]::ConvertFromString($tokens.Coral) + $coralTintColor.A = 0x2E + $coralTintBrush = [System.Windows.Media.SolidColorBrush]::new($coralTintColor) + $coralTintBrush.Freeze() + $resources['SnipCoralTintBrush'] = $coralTintBrush + $resources['SnipMintBrush'] = & $newBrush $tokens.Mint + $resources['SnipAmberBrush'] = & $newBrush $tokens.Amber + $resources['SnipVioletBrush'] = & $newBrush $tokens.Violet + + $shadow = [System.Windows.Media.Effects.DropShadowEffect]::new() + $shadow.Color = [System.Windows.Media.Colors]::Black + $shadow.BlurRadius = 18 + $shadow.ShadowDepth = 5 + $shadow.Direction = 270 + $shadow.Opacity = 0.58 + $shadow.Freeze() + $resources['SnipIslandShadow'] = $shadow + $resources['SnipGlassLayers'] = [string[]]@( + 'ContrastScrim', 'GlassGradient', 'Hairline', 'InnerHighlight', 'Shadow') + } + + $styleXaml = Get-SnipXamlText -Name 'ThemeResources' + $styleReader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($styleXaml)) + try { + $styles = [System.Windows.Markup.XamlReader]::Load($styleReader) + } finally { + $styleReader.Dispose() + } + $resources.MergedDictionaries.Add($styles) + $resources +} + +function Add-SnipThemeResources { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Windows.FrameworkElement]$Root, + [Parameter(Mandatory)] [System.Windows.ResourceDictionary]$Resources + ) + + if (-not $Root.Resources.MergedDictionaries.Contains($Resources)) { + $Root.Resources.MergedDictionaries.Add($Resources) + } + $Root +} + +function Connect-SnipWindowLifecycle { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Windows.Window]$Window, + [scriptblock]$Register = { param($hwnd) Register-SelfWindowHandle -Hwnd $hwnd }, + [scriptblock]$Unregister = { param($hwnd) Unregister-SelfWindowHandle -Hwnd $hwnd } + ) + + $helper = [System.Windows.Interop.WindowInteropHelper]::new($Window) + $state = [pscustomobject]@{ + Window = $Window + Handle = [IntPtr]::Zero + Connected = $false + SourceInitializedHandler = $null + ClosedHandler = $null + } + $registerWindow = $Register + $unregisterWindow = $Unregister + $sourceInitialized = [EventHandler]{ + param($sender, $eventArgs) + $handle = $helper.Handle + if ($handle -eq [IntPtr]::Zero -or $state.Connected) { return } + & $registerWindow $handle + $state.Handle = $handle + $state.Connected = $true + }.GetNewClosure() + $closed = [EventHandler]{ + param($sender, $eventArgs) + try { + if ($state.Connected -and $state.Handle -ne [IntPtr]::Zero) { + & $unregisterWindow $state.Handle + } + } finally { + $state.Connected = $false + $Window.Remove_SourceInitialized($sourceInitialized) + $Window.Remove_Closed($state.ClosedHandler) + } + }.GetNewClosure() + $state.SourceInitializedHandler = $sourceInitialized + $state.ClosedHandler = $closed + $Window.Add_SourceInitialized($sourceInitialized) + $Window.Add_Closed($closed) + $state +} + +function Convert-BitmapToBitmapSource { + param([System.Drawing.Bitmap]$Bitmap) + # DeleteObject lives on the main [Native] class defined at startup — no per-call JIT. + $hbmp = [IntPtr]::Zero + try { + $hbmp = $Bitmap.GetHbitmap() + $src = [System.Windows.Interop.Imaging]::CreateBitmapSourceFromHBitmap( + $hbmp, [IntPtr]::Zero, + [System.Windows.Int32Rect]::Empty, + [System.Windows.Media.Imaging.BitmapSizeOptions]::FromEmptyOptions()) + $src.Freeze() + return $src + } finally { + if ($hbmp -ne [IntPtr]::Zero) { [Native]::DeleteObject($hbmp) | Out-Null } + } +} + +function Save-CaptureToFile { + param([System.Drawing.Bitmap]$Bitmap) + $defaultDir = Join-Path ([Environment]::GetFolderPath('MyPictures')) 'Snips' + New-Item -ItemType Directory -Force -Path $defaultDir | Out-Null + $dlg = New-Object Microsoft.Win32.SaveFileDialog + $dlg.Filter = 'PNG image (*.png)|*.png|JPEG (*.jpg)|*.jpg|Bitmap (*.bmp)|*.bmp' + $dlg.FileName = Get-DefaultSnipFilename + $dlg.InitialDirectory = $defaultDir + if ($dlg.ShowDialog()) { + $filterFormat = switch ($dlg.FilterIndex) { + 2 { 'Jpeg' } + 3 { 'Bmp' } + default { 'Png' } + } + $savePath = Resolve-SaveImagePath -Path $dlg.FileName -FilterFormat $filterFormat + $fmt = switch (Get-ImageFormatNameFromPath $savePath) { + 'Jpeg' { [System.Drawing.Imaging.ImageFormat]::Jpeg } + 'Bmp' { [System.Drawing.Imaging.ImageFormat]::Bmp } + default { [System.Drawing.Imaging.ImageFormat]::Png } + } + $Bitmap.Save($savePath, $fmt) + return $savePath + } + return $null +} + +# source: src/20-Native.ps1 +#requires -Version 7.5 +# Generated deterministically from the reviewed SnipIT.ps1 AST by scripts/Export-SnipITModules.ps1. + +$script:SnipNativeInitializer = { + +$pinvoke = @' +using System; +using System.Runtime.InteropServices; +using System.Drawing; + +public static class Native { + // DPI awareness + [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); + public static readonly IntPtr DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = new IntPtr(-4); + + // Hotkey + [DllImport("user32.dll")] public static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); + [DllImport("user32.dll")] public static extern bool UnregisterHotKey(IntPtr hWnd, int id); + + // Window discovery + [StructLayout(LayoutKind.Sequential)] + public struct POINT { public int X; public int Y; } + [StructLayout(LayoutKind.Sequential)] + public struct RECT { public int Left, Top, Right, Bottom; } + + [DllImport("user32.dll")] public static extern IntPtr WindowFromPoint(POINT p); + [DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr hWnd, uint flags); + [DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr hWnd, uint command); + [DllImport("user32.dll")] public static extern IntPtr MonitorFromPoint(POINT point, uint flags); + public const uint GA_ROOT = 2; + public const uint GW_HWNDNEXT = 2; + public const uint MONITOR_DEFAULTTONEAREST = 2; + + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); + + // Visibility + show/hide for SnipIT-owned windows. We hide our chrome + // around CopyFromScreen so widget / preview UI doesn't get baked into + // captures, then SW_SHOWNA back without stealing focus. + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll", SetLastError = true)] + public static extern bool SetWindowPos(IntPtr hWnd, IntPtr insertAfter, + int x, int y, int width, int height, uint flags); + public const int SW_HIDE = 0; + public const int SW_SHOWNA = 8; // show without activating (don't steal focus) + public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1); + public const uint SWP_NOZORDER = 0x0004; + public const uint SWP_NOACTIVATE = 0x0010; + public const uint SWP_SHOWWINDOW = 0x0040; + + [DllImport("shcore.dll")] + public static extern int GetDpiForMonitor(IntPtr monitor, int dpiType, + out uint dpiX, out uint dpiY); + public const int MDT_EFFECTIVE_DPI = 0; + + // DWM extended frame bounds (no drop shadow) + [DllImport("dwmapi.dll")] + public static extern int DwmGetWindowAttribute(IntPtr hWnd, int attr, out RECT rect, int size); + public const int DWMWA_EXTENDED_FRAME_BOUNDS = 9; + + // Mica backdrop (Win11) + [DllImport("dwmapi.dll")] + public static extern int DwmSetWindowAttribute(IntPtr hWnd, int attr, ref int value, int size); + public const int DWMWA_SYSTEMBACKDROP_TYPE = 38; + public const int DWMSBT_MAINWINDOW = 2; // Mica + public const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; + + // Cursor pos in screen pixels + [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p); + + // GDI cleanup for handles returned by Bitmap.GetHbitmap() + [DllImport("gdi32.dll")] public static extern bool DeleteObject(IntPtr hObject); +} +'@ + +if (-not ('Native' -as [type])) { + Add-Type -TypeDefinition $pinvoke -ReferencedAssemblies ([System.Drawing.Bitmap].Assembly.Location) +} + +[Native]::SetProcessDpiAwarenessContext([Native]::DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) | Out-Null + +$script:SelfWindowHandles = New-Object System.Collections.Generic.List[IntPtr] + +$script:ActivePreviewContext = $null + +} + +function Get-VirtualScreenBounds { + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + [pscustomobject]@{ X=$vs.X; Y=$vs.Y; Width=$vs.Width; Height=$vs.Height } +} + +function Get-SnipMonitorDescriptors { + [CmdletBinding()] + param() + + foreach ($screen in [System.Windows.Forms.Screen]::AllScreens) { + $bounds = $screen.Bounds + $workingArea = $screen.WorkingArea + $point = [Native+POINT]::new() + $point.X = [int]($bounds.Left + [math]::Floor($bounds.Width / 2)) + $point.Y = [int]($bounds.Top + [math]::Floor($bounds.Height / 2)) + $monitor = [Native]::MonitorFromPoint( + $point, [Native]::MONITOR_DEFAULTTONEAREST) + [uint32]$dpiX = 96 + [uint32]$dpiY = 96 + if ($monitor -ne [IntPtr]::Zero) { + try { + if ([Native]::GetDpiForMonitor( + $monitor, [Native]::MDT_EFFECTIVE_DPI, + [ref]$dpiX, [ref]$dpiY) -ne 0) { + $dpiX = 96 + $dpiY = 96 + } + } catch { + $dpiX = 96 + $dpiY = 96 + } + } + [pscustomobject][ordered]@{ + Id = [string]$screen.DeviceName + X = [int]$bounds.X + Y = [int]$bounds.Y + Width = [int]$bounds.Width + Height = [int]$bounds.Height + WorkX = [int]$workingArea.X + WorkY = [int]$workingArea.Y + WorkWidth = [int]$workingArea.Width + WorkHeight = [int]$workingArea.Height + DpiX = [double]$dpiX + DpiY = [double]$dpiY + IsPrimary = [bool]$screen.Primary + } + } +} + +function Get-SnipWindowAtPhysicalPoint { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Point, + [object[]]$OwnHandles = @() + ) + + $nativePoint = [Native+POINT]::new() + $nativePoint.X = [int]$Point.X + $nativePoint.Y = [int]$Point.Y + $candidate = [Native]::WindowFromPoint($nativePoint) + if ($candidate -eq [IntPtr]::Zero) { return [IntPtr]::Zero } + $candidate = [Native]::GetAncestor($candidate, [Native]::GA_ROOT) + $own = [System.Collections.Generic.HashSet[Int64]]::new() + foreach ($handle in @($OwnHandles)) { + if ($null -ne $handle -and [IntPtr]$handle -ne [IntPtr]::Zero) { + [void]$own.Add(([IntPtr]$handle).ToInt64()) + } + } + + for ($attempt = 0; $attempt -lt 256 -and + $candidate -ne [IntPtr]::Zero; $attempt++) { + if (-not $own.Contains($candidate.ToInt64())) { return $candidate } + $candidate = [Native]::GetWindow($candidate, [Native]::GW_HWNDNEXT) + while ($candidate -ne [IntPtr]::Zero) { + if ([Native]::IsWindowVisible($candidate)) { + $bounds = [Native+RECT]::new() + if ([Native]::GetWindowRect($candidate, [ref]$bounds) -and + $nativePoint.X -ge $bounds.Left -and $nativePoint.X -lt $bounds.Right -and + $nativePoint.Y -ge $bounds.Top -and $nativePoint.Y -lt $bounds.Bottom) { + break + } + } + $candidate = [Native]::GetWindow($candidate, [Native]::GW_HWNDNEXT) + } + } + [IntPtr]::Zero +} + +function Get-SnipWindowBounds { + [CmdletBinding()] + param([Parameter(Mandatory)] [IntPtr]$Hwnd) + + if ($Hwnd -eq [IntPtr]::Zero) { return $null } + $bounds = [Native+RECT]::new() + $ok = ([Native]::DwmGetWindowAttribute( + $Hwnd, [Native]::DWMWA_EXTENDED_FRAME_BOUNDS, [ref]$bounds, 16) -eq 0) + if (-not $ok) { $ok = [Native]::GetWindowRect($Hwnd, [ref]$bounds) } + $width = $bounds.Right - $bounds.Left + $height = $bounds.Bottom - $bounds.Top + if (-not $ok -or $width -le 0 -or $height -le 0) { return $null } + [pscustomobject][ordered]@{ + X = [int]$bounds.Left + Y = [int]$bounds.Top + Width = [int]$width + Height = [int]$height + } +} + +function Register-SelfWindowHandle { + # Tracks an HWND we own (widget, preview, hotkey form, console) so the + # capture path can exclude it via Resolve-WindowCaptureTarget and the + # snapshot path can hide it via Hide-OwnSnipITWindowsForCapture. + param([IntPtr]$Hwnd) + if ($Hwnd -eq [IntPtr]::Zero) { return } + if (-not $script:SelfWindowHandles.Contains($Hwnd)) { + [void]$script:SelfWindowHandles.Add($Hwnd) + } +} + +function Unregister-SelfWindowHandle { + param([IntPtr]$Hwnd) + [void]$script:SelfWindowHandles.Remove($Hwnd) +} + +function Hide-OwnSnipITWindowsForCapture { + # Hides every registered SnipIT-owned hwnd that is currently visible so + # our widget / preview / etc. don't get baked into a desktop snapshot. + # Returns the list of hidden hwnds; pass it to + # Show-OwnSnipITWindowsForCapture to restore them without stealing focus. + [OutputType([System.Collections.Generic.List[IntPtr]])] + [CmdletBinding()] + param() + + if ($null -ne $script:ActivePreviewContext -and + $null -ne $script:ActivePreviewContext.CommandRouter) { + try { & $script:ActivePreviewContext.CommandRouter.CloseTransientMenus } + catch { Write-SnipDiag -Message 'Preview transient-menu cleanup failed' -ErrorRecord $_ } + } + $hidden = New-Object System.Collections.Generic.List[IntPtr] + foreach ($h in @($script:SelfWindowHandles)) { + if ($h -eq [IntPtr]::Zero) { continue } + if (-not [Native]::IsWindowVisible($h)) { continue } + if ([Native]::ShowWindow($h, [Native]::SW_HIDE)) { $hidden.Add($h) } + } + if ($hidden.Count -gt 0) { + # Pump pending UI work and yield briefly so DWM composes a frame + # without our chrome before CopyFromScreen samples the desktop. + try { [System.Windows.Forms.Application]::DoEvents() } catch {} + Start-Sleep -Milliseconds 80 + } + # Wrap in a single-element array so PowerShell does not unroll the List + # across the output stream. + ,$hidden +} + +function Show-OwnSnipITWindowsForCapture { + param($Hidden) + if (-not $Hidden -or $Hidden.Count -eq 0) { return } + foreach ($h in $Hidden) { + # SW_SHOWNA = show without activating, so we don't yank focus from + # whatever window the user was on while the snapshot ran. + [void][Native]::ShowWindow([IntPtr]$h, [Native]::SW_SHOWNA) + } +} + +function Set-MicaBackdrop { + param([System.Windows.Window]$Window) + try { + $helper = New-Object System.Windows.Interop.WindowInteropHelper $Window + $hwnd = $helper.Handle + if ($hwnd -eq [IntPtr]::Zero) { return } + $val = [Native]::DWMSBT_MAINWINDOW + [Native]::DwmSetWindowAttribute($hwnd, [Native]::DWMWA_SYSTEMBACKDROP_TYPE, [ref]$val, 4) | Out-Null + $dark = 1 + [Native]::DwmSetWindowAttribute($hwnd, [Native]::DWMWA_USE_IMMERSIVE_DARK_MODE, [ref]$dark, 4) | Out-Null + } catch {} +} + +# source: src/30-Capture.ps1 +#requires -Version 7.5 +# Generated deterministically from the reviewed SnipIT.ps1 AST by scripts/Export-SnipITModules.ps1. + +$script:CaptureCoordinator = $null + +function New-ScreenBitmap { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [int]$X, + [Parameter(Mandatory)] [int]$Y, + [Parameter(Mandatory)] [int]$Width, + [Parameter(Mandatory)] [int]$Height, + [scriptblock]$BitmapFactory = { + param($width,$height,$pixelFormat) + [System.Drawing.Bitmap]::new($width, $height, $pixelFormat) + }, + [scriptblock]$GraphicsFactory = { + param($bitmap) + [System.Drawing.Graphics]::FromImage($bitmap) + }, + [scriptblock]$CopyPixels = { + param($graphics,$x,$y,$width,$height) + $graphics.CopyFromScreen( + $x, $y, 0, 0, + [System.Drawing.Size]::new($width, $height), + [System.Drawing.CopyPixelOperation]::SourceCopy) + } + ) + + $bmp = $null + $graphics = $null + $captureSucceeded = $false + $graphicsCleanupFailed = $false + try { + $bmp = & $BitmapFactory $Width $Height ` + ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + $graphics = & $GraphicsFactory $bmp + & $CopyPixels $graphics $X $Y $Width $Height | Out-Null + $captureSucceeded = $true + } finally { + try { + if ($null -ne $graphics) { + Invoke-SnipResourceDispose -Resource $graphics + } + } catch { + # A bitmap cannot be transferred when its Graphics cleanup failed. + $graphicsCleanupFailed = $true + throw + } finally { + if ((-not $captureSucceeded -or $graphicsCleanupFailed) -and + $null -ne $bmp) { + Invoke-SnipResourceDispose -Resource $bmp + } + } + } + + return $bmp +} + +function Get-SnipOverlayService { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Services, + [Parameter(Mandatory)] [string]$Name + ) + + $property = $Services.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { + throw [ArgumentException]::new( + "Smart overlay services are missing '$Name'.", 'Services') + } + $property.Value +} + +function New-SnipOverlayContext { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Snapshot, + $SnapshotSource, + [Parameter(Mandatory)] $VirtualBounds, + [Parameter(Mandatory)] [object[]]$MonitorLayouts, + [Parameter(Mandatory)] $Services, + [Parameter(Mandatory)] [System.Windows.ResourceDictionary]$Resources + ) + + $context = [pscustomobject][ordered]@{ + Snapshot = $Snapshot + SnapshotSource = $SnapshotSource + VirtualBounds = $VirtualBounds + MonitorLayouts = @($MonitorLayouts) + Services = $Services + Resources = $Resources + Overlays = [System.Collections.ArrayList]::new() + Dragging = $false + AnchorPhysical = $null + PointerPhysical = $null + Selection = $null + HoverHwnd = [IntPtr]::Zero + HoverBounds = $null + ClickCandidateBounds = $null + PendingOverlay = $null + PointerDirty = $false + RenderCount = 0 + CaptureOverlay = $null + SurfaceResult = 'UserCancelled' + ErrorRecord = $null + Closing = $false + RenderingHandler = $null + RenderingAttached = $false + Actions = $null + } + + $readCursor = { + $getCursor = Get-SnipOverlayService -Services $context.Services ` + -Name GetCursorPosition + $output = @(& $getCursor) + $point = if ($output.Count -gt 0) { $output[-1] } else { $null } + if ($null -eq $point -or $null -eq $point.PSObject.Properties['X'] -or + $null -eq $point.PSObject.Properties['Y']) { + throw [InvalidOperationException]::new( + 'GetCursorPosition returned no physical point.') + } + [pscustomobject][ordered]@{ X=[int]$point.X; Y=[int]$point.Y } + }.GetNewClosure() + $close = { + param( + [ValidateSet('Completed','UserCancelled','Preempted','Failed','Shutdown')] + [string]$Result = 'UserCancelled' + ) + if ($context.Closing) { return } + $context.Closing = $true + $context.SurfaceResult = $Result + if ($Result -ne 'Completed') { $context.Selection = $null } + if ($null -ne $context.CaptureOverlay) { + try { $context.CaptureOverlay.Window.ReleaseMouseCapture() } catch {} + } + $context.Dragging = $false + foreach ($overlay in @($context.Overlays)) { + if ($null -ne $overlay.Window) { try { $overlay.Window.Close() } catch {} } + } + }.GetNewClosure() + $queuePointer = { + param($Overlay) + if ($context.Closing) { return } + $context.PendingOverlay = $Overlay + $context.PointerDirty = $true + }.GetNewClosure() + $beginDrag = { + param($Overlay) + if ($context.Closing) { return } + $point = & $readCursor + # Flush the newest queued hover against this exact physical point so + # a fast move/down/up cannot commit the previous frame's target. + $context.PendingOverlay = $Overlay + $context.PointerDirty = $true + Invoke-SnipOverlayRenderTick -Context $context -PhysicalPoint $point + $context.PointerPhysical = $point + $context.AnchorPhysical = $point + $context.Selection = [pscustomobject][ordered]@{ + X=$point.X; Y=$point.Y; Width=0; Height=0 + } + $context.ClickCandidateBounds = $context.HoverBounds + $context.Dragging = $true + $context.HoverHwnd = [IntPtr]::Zero + $context.HoverBounds = $null + $context.CaptureOverlay = $Overlay + $context.PointerDirty = $true + foreach ($item in @($context.Overlays)) { + $item.HintBorder.Visibility = 'Collapsed' + } + try { $Overlay.Window.CaptureMouse() | Out-Null } catch {} + }.GetNewClosure() + $completeDrag = { + if (-not $context.Dragging -or $context.Closing) { return } + $point = & $readCursor + $context.PointerPhysical = $point + $kind = Test-IsClickVsDrag ` + -AnchorX $context.AnchorPhysical.X -AnchorY $context.AnchorPhysical.Y ` + -CurrentX $point.X -CurrentY $point.Y + if ($kind -eq 'click') { + $context.Selection = $context.ClickCandidateBounds + } else { + $context.Selection = Get-DragRectangle ` + -AnchorX $context.AnchorPhysical.X -AnchorY $context.AnchorPhysical.Y ` + -CurrentX $point.X -CurrentY $point.Y + } + try { $context.CaptureOverlay.Window.ReleaseMouseCapture() } catch {} + $context.Dragging = $false + $valid = $null -ne $context.Selection -and + (Test-CaptureRectValid -Width ([int]$context.Selection.Width) ` + -Height ([int]$context.Selection.Height)) + & $close $(if ($valid) { 'Completed' } else { 'UserCancelled' }) + }.GetNewClosure() + $cancel = { & $close 'UserCancelled' }.GetNewClosure() + $context.Actions = [pscustomobject][ordered]@{ + ReadCursor = $readCursor + Close = $close + QueuePointer = $queuePointer + BeginDrag = $beginDrag + CompleteDrag = $completeDrag + Cancel = $cancel + } + $context +} + +function Remove-SnipOverlayWindowEvents { + [CmdletBinding()] + param([Parameter(Mandatory)] $Overlay) + + if (-not $Overlay.EventsAttached) { return } + $Overlay.EventsAttached = $false + try { $Overlay.Window.Remove_MouseMove($Overlay.MouseMoveHandler) } catch {} + try { $Overlay.Window.Remove_MouseLeftButtonDown($Overlay.MouseDownHandler) } catch {} + try { $Overlay.Window.Remove_MouseLeftButtonUp($Overlay.MouseUpHandler) } catch {} + try { $Overlay.Window.Remove_MouseRightButtonDown($Overlay.RightDownHandler) } catch {} + try { $Overlay.Window.Remove_KeyDown($Overlay.KeyDownHandler) } catch {} + try { $Overlay.Window.Remove_SourceInitialized($Overlay.PositionHandler) } catch {} + try { $Overlay.Window.Remove_Closed($Overlay.ClosedHandler) } catch {} +} + +function New-SnipOverlayWindow { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Context, + [Parameter(Mandatory)] $MonitorLayout + ) + + [xml]$xaml = [xml](Get-SnipXamlText -Name 'SmartOverlay') + $reader = [System.Xml.XmlNodeReader]::new($xaml) + $window = [System.Windows.Markup.XamlReader]::Load($reader) + Add-SnipThemeResources -Root $window -Resources $Context.Resources | Out-Null + $window.Left = [double]$MonitorLayout.DipX + $window.Top = [double]$MonitorLayout.DipY + $window.Width = [double]$MonitorLayout.DipWidth + $window.Height = [double]$MonitorLayout.DipHeight + + $bgImage = $window.FindName('BgImage') + $bgImage.Width = [double]$MonitorLayout.DipWidth + $bgImage.Height = [double]$MonitorLayout.DipHeight + if ($Context.SnapshotSource -is [System.Windows.Media.Imaging.BitmapSource]) { + $sourceX = [int][math]::Round( + [double]$MonitorLayout.PhysicalX - [double]$Context.VirtualBounds.X, + 0, [MidpointRounding]::AwayFromZero) + $sourceY = [int][math]::Round( + [double]$MonitorLayout.PhysicalY - [double]$Context.VirtualBounds.Y, + 0, [MidpointRounding]::AwayFromZero) + $sourceWidth = [int][math]::Round( + [double]$MonitorLayout.PhysicalWidth, + 0, [MidpointRounding]::AwayFromZero) + $sourceHeight = [int][math]::Round( + [double]$MonitorLayout.PhysicalHeight, + 0, [MidpointRounding]::AwayFromZero) + $monitorSource = [System.Windows.Media.Imaging.CroppedBitmap]::new( + $Context.SnapshotSource, + [System.Windows.Int32Rect]::new( + $sourceX, $sourceY, $sourceWidth, $sourceHeight)) + $monitorSource.Freeze() + $bgImage.Source = $monitorSource + } + + $overlay = [pscustomobject][ordered]@{ + Context = $Context + Layout = $MonitorLayout + Window = $window + Lifecycle = $null + BackgroundImage = $bgImage + Dimmer = $window.FindName('Dimmer') + HoverRect = $window.FindName('HoverRect') + DragRect = $window.FindName('DragRect') + HintBorder = $window.FindName('HintBorder') + HintText = $window.FindName('HintText') + LoupeBorder = $window.FindName('LoupeBorder') + LoupeImage = $window.FindName('LoupeImage') + LoupeText = $window.FindName('LoupeText') + LoupeBounds = $null + RenderedSelection = $null + RenderedHover = $null + Closed = $false + EventsAttached = $false + MouseMoveHandler = $null + MouseDownHandler = $null + MouseUpHandler = $null + RightDownHandler = $null + KeyDownHandler = $null + PositionHandler = $null + ClosedHandler = $null + } + $overlay.HoverRect.Stroke = $Context.Resources['SnipAccentBrush'] + $overlay.DragRect.Stroke = $Context.Resources['SnipMintBrush'] + if ([bool]$Context.Resources['SnipHighContrast']) { + $overlay.Dimmer.Fill = [System.Windows.SystemColors]::WindowBrush + $overlay.DragRect.Fill = [System.Windows.SystemColors]::HighlightBrush + } + if ($null -ne $Context.Resources['SnipIslandShadow']) { + $overlay.HintBorder.Effect = $Context.Resources['SnipIslandShadow'] + $overlay.LoupeBorder.Effect = $Context.Resources['SnipIslandShadow'] + } + [System.Windows.Automation.AutomationProperties]::SetName( + $window, "Smart capture overlay on $($MonitorLayout.Id)") + + $register = Get-SnipOverlayService -Services $Context.Services -Name RegisterWindow + $unregister = Get-SnipOverlayService -Services $Context.Services -Name UnregisterWindow + $overlay.Lifecycle = Connect-SnipWindowLifecycle -Window $window ` + -Register $register -Unregister $unregister + $positionState = [pscustomobject]@{ Overlay=$overlay } + $position = [EventHandler]{ + param($sender,$eventArgs) + $item = $positionState.Overlay + $handle = [System.Windows.Interop.WindowInteropHelper]::new($item.Window).Handle + if ($handle -eq [IntPtr]::Zero) { return } + try { + $positionWindow = Get-SnipOverlayService ` + -Services $item.Context.Services -Name PositionWindow + $positionOutput = @(& $positionWindow $handle $item.Layout) + $positioned = $positionOutput.Count -gt 0 -and + $positionOutput[-1] -is [bool] -and [bool]$positionOutput[-1] + if (-not $positioned) { + throw [ComponentModel.Win32Exception]::new( + "SetWindowPos returned false for monitor '$($item.Layout.Id)'.") + } + } catch { + throw [InvalidOperationException]::new( + "SetWindowPos failed for monitor '$($item.Layout.Id)'.", + $_.Exception) + } + }.GetNewClosure() + $mouseMove = [System.Windows.Input.MouseEventHandler]{ + param($sender,$eventArgs) + & $overlay.Context.Actions.QueuePointer $overlay | Out-Null + }.GetNewClosure() + $mouseDown = [System.Windows.Input.MouseButtonEventHandler]{ + param($sender,$eventArgs) + if ($eventArgs.ChangedButton -eq [System.Windows.Input.MouseButton]::Left) { + & $overlay.Context.Actions.BeginDrag $overlay | Out-Null + $eventArgs.Handled = $true + } + }.GetNewClosure() + $mouseUp = [System.Windows.Input.MouseButtonEventHandler]{ + param($sender,$eventArgs) + if ($eventArgs.ChangedButton -eq [System.Windows.Input.MouseButton]::Left) { + & $overlay.Context.Actions.CompleteDrag | Out-Null + $eventArgs.Handled = $true + } + }.GetNewClosure() + $rightDown = [System.Windows.Input.MouseButtonEventHandler]{ + param($sender,$eventArgs) + & $overlay.Context.Actions.Cancel | Out-Null + $eventArgs.Handled = $true + }.GetNewClosure() + $keyDown = [System.Windows.Input.KeyEventHandler]{ + param($sender,$eventArgs) + if ($eventArgs.Key -eq [System.Windows.Input.Key]::Escape) { + & $overlay.Context.Actions.Cancel | Out-Null + $eventArgs.Handled = $true + } + }.GetNewClosure() + $closedState = [pscustomobject]@{ Overlay=$overlay } + $closed = [EventHandler]{ + param($sender,$eventArgs) + $closedState.Overlay.Closed = $true + Remove-SnipOverlayWindowEvents -Overlay $closedState.Overlay + }.GetNewClosure() + $overlay.PositionHandler = $position + $overlay.MouseMoveHandler = $mouseMove + $overlay.MouseDownHandler = $mouseDown + $overlay.MouseUpHandler = $mouseUp + $overlay.RightDownHandler = $rightDown + $overlay.KeyDownHandler = $keyDown + $overlay.ClosedHandler = $closed + $window.Add_SourceInitialized($position) + $window.Add_MouseMove($mouseMove) + $window.Add_MouseLeftButtonDown($mouseDown) + $window.Add_MouseLeftButtonUp($mouseUp) + $window.Add_MouseRightButtonDown($rightDown) + $window.Add_KeyDown($keyDown) + $window.Add_Closed($closed) + $overlay.EventsAttached = $true + [void]$Context.Overlays.Add($overlay) + $overlay +} + +function Set-SnipOverlayRectangleVisual { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $RectangleControl, + $Intersection + ) + + if ($null -eq $Intersection) { + $RectangleControl.Visibility = 'Collapsed' + return + } + [System.Windows.Controls.Canvas]::SetLeft( + $RectangleControl, [double]$Intersection.DipX) + [System.Windows.Controls.Canvas]::SetTop( + $RectangleControl, [double]$Intersection.DipY) + $RectangleControl.Width = [double]$Intersection.DipWidth + $RectangleControl.Height = [double]$Intersection.DipHeight + $RectangleControl.Visibility = 'Visible' +} + +function Invoke-SnipOverlayRenderTick { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Context, + $PhysicalPoint + ) + + if ($Context.Closing -or -not $Context.PointerDirty) { return } + $Context.PointerDirty = $false + $point = if ($PSBoundParameters.ContainsKey('PhysicalPoint')) { + [pscustomobject][ordered]@{ + X = [int]$PhysicalPoint.X + Y = [int]$PhysicalPoint.Y + } + } else { + & $Context.Actions.ReadCursor + } + $Context.PointerPhysical = $point + + if ($Context.Dragging) { + $Context.Selection = Get-DragRectangle ` + -AnchorX $Context.AnchorPhysical.X -AnchorY $Context.AnchorPhysical.Y ` + -CurrentX $point.X -CurrentY $point.Y + $Context.HoverHwnd = [IntPtr]::Zero + $Context.HoverBounds = $null + } else { + $ownHandles = [System.Collections.Generic.List[IntPtr]]::new() + $ownValues = [System.Collections.Generic.HashSet[Int64]]::new() + foreach ($handle in @( + $Context.Overlays | ForEach-Object { $_.Lifecycle.Handle })) { + $nativeHandle = [IntPtr]$handle + if ($nativeHandle -ne [IntPtr]::Zero -and + $ownValues.Add($nativeHandle.ToInt64())) { + $ownHandles.Add($nativeHandle) + } + } + $getOwnHandles = Get-SnipOverlayService -Services $Context.Services ` + -Name GetOwnWindowHandles + $registeredOutput = @(& $getOwnHandles) + foreach ($handle in $registeredOutput) { + if ($null -eq $handle) { continue } + $nativeHandle = [IntPtr]$handle + if ($nativeHandle -ne [IntPtr]::Zero -and + $ownValues.Add($nativeHandle.ToInt64())) { + $ownHandles.Add($nativeHandle) + } + } + $getWindow = Get-SnipOverlayService -Services $Context.Services -Name GetWindowAtPoint + $windowOutput = @(& $getWindow $point ([IntPtr[]]$ownHandles.ToArray())) + $hwnd = if ($windowOutput.Count -gt 0) { + [IntPtr]$windowOutput[-1] + } else { + [IntPtr]::Zero + } + $Context.HoverHwnd = $hwnd + $Context.HoverBounds = $null + if ($hwnd -ne [IntPtr]::Zero) { + $getBounds = Get-SnipOverlayService -Services $Context.Services -Name GetWindowBounds + $boundsOutput = @(& $getBounds $hwnd) + $bounds = if ($boundsOutput.Count -gt 0) { $boundsOutput[-1] } else { $null } + if ($null -ne $bounds -and + $null -ne $bounds.PSObject.Properties['X'] -and + $null -ne $bounds.PSObject.Properties['Y'] -and + $null -ne $bounds.PSObject.Properties['Width'] -and + $null -ne $bounds.PSObject.Properties['Height'] -and + [double]$bounds.Width -gt 0 -and [double]$bounds.Height -gt 0) { + $Context.HoverBounds = [pscustomobject][ordered]@{ + X=[double]$bounds.X; Y=[double]$bounds.Y + Width=[double]$bounds.Width; Height=[double]$bounds.Height + } + } + } + } + + $selectionParts = if ($Context.Dragging -and $null -ne $Context.Selection) { + @(Get-SnipOverlayIntersections -Rectangle $Context.Selection ` + -MonitorLayouts $Context.MonitorLayouts) + } else { + @() + } + $hoverParts = if (-not $Context.Dragging -and $null -ne $Context.HoverBounds) { + @(Get-SnipOverlayIntersections -Rectangle $Context.HoverBounds ` + -MonitorLayouts $Context.MonitorLayouts) + } else { + @() + } + + foreach ($overlay in @($Context.Overlays)) { + $selectionPart = @($selectionParts | Where-Object { + $_.MonitorIndex -eq $overlay.Layout.Index + } | Select-Object -First 1) + $selectionPart = if ($selectionPart.Count) { $selectionPart[0] } else { $null } + $overlay.RenderedSelection = $selectionPart + Set-SnipOverlayRectangleVisual -RectangleControl $overlay.DragRect ` + -Intersection $selectionPart + if ($null -ne $selectionPart) { + $overlay.HintText.Text = ('{0} × {1} px' -f + [int]$Context.Selection.Width, [int]$Context.Selection.Height) + } + + $hoverPart = @($hoverParts | Where-Object { + $_.MonitorIndex -eq $overlay.Layout.Index + } | Select-Object -First 1) + $hoverPart = if ($hoverPart.Count) { $hoverPart[0] } else { $null } + $overlay.RenderedHover = $hoverPart + Set-SnipOverlayRectangleVisual -RectangleControl $overlay.HoverRect ` + -Intersection $hoverPart + } + + $pointerOverlay = @($Context.Overlays | Where-Object { + $point.X -ge $_.Layout.PhysicalX -and + $point.X -lt ($_.Layout.PhysicalX + $_.Layout.PhysicalWidth) -and + $point.Y -ge $_.Layout.PhysicalY -and + $point.Y -lt ($_.Layout.PhysicalY + $_.Layout.PhysicalHeight) + } | Select-Object -First 1) + foreach ($overlay in @($Context.Overlays)) { + if ($pointerOverlay.Count -eq 0 -or + -not [object]::ReferenceEquals($overlay, $pointerOverlay[0])) { + $overlay.LoupeBorder.Visibility = 'Collapsed' + $overlay.LoupeBounds = $null + continue + } + + $loupeWidth = [int][math]::Round( + $overlay.LoupeBorder.Width * $overlay.Layout.ScaleX, + 0, [MidpointRounding]::AwayFromZero) + $loupeHeight = [int][math]::Round( + $overlay.LoupeBorder.Height * $overlay.Layout.ScaleY, + 0, [MidpointRounding]::AwayFromZero) + $position = Get-LoupePosition -MouseX $point.X -MouseY $point.Y ` + -VsX ([int]$overlay.Layout.PhysicalX) ` + -VsY ([int]$overlay.Layout.PhysicalY) ` + -VsWidth ([int]$overlay.Layout.PhysicalWidth) ` + -VsHeight ([int]$overlay.Layout.PhysicalHeight) ` + -LoupeWidth $loupeWidth -LoupeHeight $loupeHeight + $pointerLocalX = $point.X - [double]$overlay.Layout.PhysicalX + $pointerLocalY = $point.Y - [double]$overlay.Layout.PhysicalY + $candidates = @( + [pscustomobject]@{ X=$position.X; Y=$position.Y }, + [pscustomobject]@{ + X=$pointerLocalX - $loupeWidth - 14 + Y=$pointerLocalY - $loupeHeight - 10 + }, + [pscustomobject]@{ + X=$pointerLocalX + 24 + Y=$pointerLocalY - $loupeHeight - 10 + }, + [pscustomobject]@{ + X=$pointerLocalX - $loupeWidth - 14 + Y=$pointerLocalY + 24 + }, + [pscustomobject]@{ X=$pointerLocalX + 24; Y=$pointerLocalY + 24 } + ) + $localX = 0 + $localY = 0 + $bestOverlap = [double]::PositiveInfinity + foreach ($candidate in $candidates) { + $candidateX = [math]::Max(0, [math]::Min( + [double]$overlay.Layout.PhysicalWidth - $loupeWidth, + [double]$candidate.X)) + $candidateY = [math]::Max(0, [math]::Min( + [double]$overlay.Layout.PhysicalHeight - $loupeHeight, + [double]$candidate.Y)) + $overlap = 0.0 + if ($Context.Dragging -and $null -ne $Context.Selection) { + $candidateGlobalX = [double]$overlay.Layout.PhysicalX + $candidateX + $candidateGlobalY = [double]$overlay.Layout.PhysicalY + $candidateY + $overlapWidth = [math]::Min( + $candidateGlobalX + $loupeWidth, + [double]$Context.Selection.X + [double]$Context.Selection.Width) - + [math]::Max($candidateGlobalX, [double]$Context.Selection.X) + $overlapHeight = [math]::Min( + $candidateGlobalY + $loupeHeight, + [double]$Context.Selection.Y + [double]$Context.Selection.Height) - + [math]::Max($candidateGlobalY, [double]$Context.Selection.Y) + if ($overlapWidth -gt 0 -and $overlapHeight -gt 0) { + $overlap = $overlapWidth * $overlapHeight + } + } + if ($overlap -lt $bestOverlap) { + $bestOverlap = $overlap + $localX = $candidateX + $localY = $candidateY + if ($bestOverlap -eq 0) { break } + } + } + [System.Windows.Controls.Canvas]::SetLeft( + $overlay.LoupeBorder, $localX / $overlay.Layout.ScaleX) + [System.Windows.Controls.Canvas]::SetTop( + $overlay.LoupeBorder, $localY / $overlay.Layout.ScaleY) + $overlay.LoupeBounds = [pscustomobject][ordered]@{ + X = [double]$overlay.Layout.PhysicalX + $localX + Y = [double]$overlay.Layout.PhysicalY + $localY + Width = $loupeWidth + Height = $loupeHeight + } + if ($Context.SnapshotSource -is [System.Windows.Media.Imaging.BitmapSource]) { + $source = Get-LoupeSourceRect -MouseX $point.X -MouseY $point.Y ` + -VsX ([int]$Context.VirtualBounds.X) -VsY ([int]$Context.VirtualBounds.Y) ` + -VsWidth ([int]$Context.VirtualBounds.Width) ` + -VsHeight ([int]$Context.VirtualBounds.Height) -Size 18 + $loupeSource = [System.Windows.Media.Imaging.CroppedBitmap]::new( + $Context.SnapshotSource, + [System.Windows.Int32Rect]::new($source.X, $source.Y, $source.Size, $source.Size)) + $loupeSource.Freeze() + $overlay.LoupeImage.Width = 144 + $overlay.LoupeImage.Height = 144 + $overlay.LoupeImage.Source = $loupeSource + } + $overlay.LoupeText.Text = ('{0} , {1}' -f $point.X, $point.Y) + $overlay.LoupeBorder.Visibility = 'Visible' + } + $Context.RenderCount++ +} + +function Show-SmartOverlaySet { + [CmdletBinding()] + param( + [scriptblock]$OnSurfaceReady, + $Services, + [scriptblock]$TestAction, + [scriptblock]$HideWindows, + [scriptblock]$RestoreWindows, + [scriptblock]$CaptureFactory, + [scriptblock]$BitmapSourceFactory + ) + + $snapshot = $null + $context = $null + $overlayServices = $Services + try { + if ($null -eq $overlayServices) { + $overlayServices = [pscustomobject][ordered]@{ + GetMonitorDescriptors = { @(Get-SnipMonitorDescriptors) } + GetVirtualBounds = { Get-VirtualScreenBounds } + HideWindows = $HideWindows + RestoreWindows = $RestoreWindows + CaptureSnapshot = $CaptureFactory + ConvertSnapshotSource = $BitmapSourceFactory + GetCursorPosition = { + $point = [Native+POINT]::new() + if (-not [Native]::GetCursorPos([ref]$point)) { + throw [InvalidOperationException]::new('GetCursorPos failed.') + } + [pscustomobject][ordered]@{ X=$point.X; Y=$point.Y } + } + GetWindowAtPoint = { + param($point,$ownHandles) + Get-SnipWindowAtPhysicalPoint -Point $point -OwnHandles $ownHandles + } + GetWindowBounds = { param($hwnd) Get-SnipWindowBounds -Hwnd $hwnd } + GetOwnWindowHandles = { @($script:SelfWindowHandles) } + PositionWindow = { + param($hwnd,$layout) + if (-not [Native]::SetWindowPos( + $hwnd, [Native]::HWND_TOPMOST, + [int]$layout.PhysicalX, [int]$layout.PhysicalY, + [int]$layout.PhysicalWidth, [int]$layout.PhysicalHeight, + [Native]::SWP_NOACTIVATE -bor [Native]::SWP_SHOWWINDOW)) { + $nativeError = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + throw [ComponentModel.Win32Exception]::new( + $nativeError, + "SetWindowPos failed for monitor '$($layout.Id)'.") + } + $true + } + AddRenderingHandler = { + param($handler) + [System.Windows.Media.CompositionTarget]::add_Rendering($handler) + } + RemoveRenderingHandler = { + param($handler) + [System.Windows.Media.CompositionTarget]::remove_Rendering($handler) + } + RegisterWindow = { param($hwnd) Register-SelfWindowHandle -Hwnd $hwnd } + UnregisterWindow = { param($hwnd) Unregister-SelfWindowHandle -Hwnd $hwnd } + Resources = New-SnipThemeResources ` + -HighContrast ([System.Windows.SystemParameters]::HighContrast) + } + } + + foreach ($name in 'GetMonitorDescriptors','GetVirtualBounds','HideWindows', + 'RestoreWindows','CaptureSnapshot','ConvertSnapshotSource','GetCursorPosition', + 'GetWindowAtPoint','GetWindowBounds','GetOwnWindowHandles','PositionWindow', + 'AddRenderingHandler', + 'RemoveRenderingHandler','RegisterWindow','UnregisterWindow','Resources') { + [void](Get-SnipOverlayService -Services $overlayServices -Name $name) + } + $getDescriptors = Get-SnipOverlayService -Services $overlayServices ` + -Name GetMonitorDescriptors + $descriptors = @(& $getDescriptors) + $layouts = @(Get-SnipMonitorLayouts -MonitorDescriptors $descriptors) + $getVirtualBounds = Get-SnipOverlayService -Services $overlayServices ` + -Name GetVirtualBounds + $virtualOutput = @(& $getVirtualBounds) + $virtualBounds = if ($virtualOutput.Count) { $virtualOutput[-1] } else { $null } + if ($null -eq $virtualBounds) { + throw [InvalidOperationException]::new('Virtual screen bounds are unavailable.') + } + + $hide = Get-SnipOverlayService -Services $overlayServices -Name HideWindows + $restore = Get-SnipOverlayService -Services $overlayServices -Name RestoreWindows + $capture = Get-SnipOverlayService -Services $overlayServices -Name CaptureSnapshot + $convert = Get-SnipOverlayService -Services $overlayServices -Name ConvertSnapshotSource + $hidden = & $hide + try { + $captureOutput = @(& $capture $virtualBounds) + $snapshot = if ($captureOutput.Count) { $captureOutput[-1] } else { $null } + } finally { + & $restore $hidden | Out-Null + } + if ($null -eq $snapshot) { + throw [InvalidOperationException]::new('Virtual snapshot creation returned null.') + } + $sourceOutput = @(& $convert $snapshot) + $snapshotSource = if ($sourceOutput.Count) { $sourceOutput[-1] } else { $null } + $resources = Get-SnipOverlayService -Services $overlayServices -Name Resources + $context = New-SnipOverlayContext -Snapshot $snapshot ` + -SnapshotSource $snapshotSource -VirtualBounds $virtualBounds ` + -MonitorLayouts $layouts -Services $overlayServices -Resources $resources + foreach ($layout in $layouts) { + New-SnipOverlayWindow -Context $context -MonitorLayout $layout | Out-Null + } + + $renderState = [pscustomobject]@{ Context=$context } + $renderHandler = [EventHandler]{ + param($sender,$eventArgs) + try { + Invoke-SnipOverlayRenderTick -Context $renderState.Context + } catch { + $renderState.Context.ErrorRecord = $_ + & $renderState.Context.Actions.Close 'Failed' | Out-Null + } + }.GetNewClosure() + $context.RenderingHandler = $renderHandler + $surface = [pscustomobject][ordered]@{ + Kind = 'Selecting' + Window = $context.Overlays[0].Window + Windows = @($context.Overlays | ForEach-Object Window) + Context = $context + Close = { + param([string]$result) + & $context.Actions.Close $result + }.GetNewClosure() + } + $showSurface = $true + if ($OnSurfaceReady) { + $readyOutput = @(& $OnSurfaceReady $surface) + $readyValue = if ($readyOutput.Count) { $readyOutput[-1] } else { $null } + if ($readyValue -is [bool] -and -not $readyValue) { $showSurface = $false } + } + + if ($showSurface) { + $addRendering = Get-SnipOverlayService -Services $overlayServices ` + -Name AddRenderingHandler + # Treat subscription as owned before invoking the injected seam so + # a service that attaches and then throws is still detached. + $context.RenderingAttached = $true + & $addRendering $renderHandler | Out-Null + if ($TestAction) { + foreach ($overlay in @($context.Overlays)) { + $overlay.Window.Opacity = 0 + $overlay.Window.ShowActivated = $false + $overlay.Window.Show() + $overlay.Window.UpdateLayout() + } + $kit = [pscustomobject][ordered]@{ + Context = $context + Overlays = @($context.Overlays) + QueuePointer = $context.Actions.QueuePointer + BeginDrag = $context.Actions.BeginDrag + CompleteDrag = $context.Actions.CompleteDrag + Cancel = $context.Actions.Cancel + Close = $context.Actions.Close + RenderTick = { Invoke-SnipOverlayRenderTick -Context $context }.GetNewClosure() + } + & $TestAction $kit | Out-Null + if (-not $context.Closing) { & $context.Actions.Cancel | Out-Null } + } else { + for ($index = 1; $index -lt $context.Overlays.Count; $index++) { + $context.Overlays[$index].Window.Show() + } + $context.Overlays[0].Window.ShowDialog() | Out-Null + } + } + + if ($context.SurfaceResult -eq 'Completed') { + $validSelection = $null -ne $context.Selection + if ($validSelection) { + foreach ($propertyName in 'X','Y','Width','Height') { + if ($null -eq $context.Selection.PSObject.Properties[$propertyName]) { + $validSelection = $false + break + } + } + } + if ($validSelection) { + try { + $selection = [pscustomobject][ordered]@{ + X = [int][math]::Round([double]$context.Selection.X, 0, + [MidpointRounding]::AwayFromZero) + Y = [int][math]::Round([double]$context.Selection.Y, 0, + [MidpointRounding]::AwayFromZero) + Width = [int][math]::Round([double]$context.Selection.Width, 0, + [MidpointRounding]::AwayFromZero) + Height = [int][math]::Round([double]$context.Selection.Height, 0, + [MidpointRounding]::AwayFromZero) + } + $validSelection = Test-CaptureRectValid ` + -Width $selection.Width -Height $selection.Height + } catch { + $validSelection = $false + } + } + if (-not $validSelection) { + $context.SurfaceResult = 'UserCancelled' + $selection = $null + } + } else { + $selection = $null + } + [pscustomobject][ordered]@{ + Result = $context.SurfaceResult + Selection = $selection + Bitmap = $null + ErrorRecord = $context.ErrorRecord + } + } catch { + [pscustomobject][ordered]@{ + Result = 'Failed' + Selection = $null + Bitmap = $null + ErrorRecord = $_ + } + } finally { + if ($null -ne $context) { + if ($context.RenderingAttached) { + try { + $removeRendering = Get-SnipOverlayService ` + -Services $context.Services -Name RemoveRenderingHandler + & $removeRendering $context.RenderingHandler | Out-Null + } catch {} + $context.RenderingAttached = $false + } + foreach ($overlay in @($context.Overlays)) { + try { $overlay.Window.Close() } catch {} + Remove-SnipOverlayWindowEvents -Overlay $overlay + } + } + if ($null -ne $snapshot) { + try { Invoke-SnipResourceDispose -Resource $snapshot } catch {} + } + } +} + +function Show-SmartOverlay { + [CmdletBinding()] + param( + [scriptblock]$OnSurfaceReady, + $Services, + [scriptblock]$TestAction, + [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 + } + ) + + Show-SmartOverlaySet -OnSurfaceReady $OnSurfaceReady ` + -Services $Services -TestAction $TestAction ` + -HideWindows $HideWindows -RestoreWindows $RestoreWindows ` + -CaptureFactory $CaptureFactory -BitmapSourceFactory $BitmapSourceFactory +} + +function New-SnipRuntimeCaptureServices { + [CmdletBinding()] + param( + [scriptblock]$SmartOverlay = { + param($onSurfaceReady,$coordinator,$request) + Show-SmartOverlay -OnSurfaceReady $onSurfaceReady + }, + [scriptblock]$GetVirtualBounds = { Get-VirtualScreenBounds }, + [scriptblock]$ResolveWindow = { + $foreground = [Native]::GetForegroundWindow() + $target = Resolve-WindowCaptureTarget -ForegroundHwnd $foreground ` + -SelfWindowHandles $script:SelfWindowHandles + if ($null -eq $target) { return $null } + + $bounds = New-Object Native+RECT + $gotBounds = ([Native]::DwmGetWindowAttribute( + $target, [Native]::DWMWA_EXTENDED_FRAME_BOUNDS, [ref]$bounds, 16) -eq 0) + if (-not $gotBounds) { + $gotBounds = [Native]::GetWindowRect($target, [ref]$bounds) + } + $width = $bounds.Right - $bounds.Left + $height = $bounds.Bottom - $bounds.Top + if (-not $gotBounds -or $width -le 0 -or $height -le 0) { + return $null + } + [pscustomobject][ordered]@{ + Hwnd = $target + X = $bounds.Left + Y = $bounds.Top + Width = $width + Height = $height + } + }, + [scriptblock]$HideWindows = { Hide-OwnSnipITWindowsForCapture }, + [scriptblock]$RestoreWindows = { + param($hidden) + Show-OwnSnipITWindowsForCapture -Hidden $hidden + }, + [scriptblock]$CaptureRectangle = { + param($bounds) + New-ScreenBitmap -X $bounds.X -Y $bounds.Y ` + -Width $bounds.Width -Height $bounds.Height + } + ) + + $smartOverlayForCapture = $SmartOverlay + $getVirtualBoundsForCapture = $GetVirtualBounds + $resolveWindowForCapture = $ResolveWindow + $hideWindowsForCapture = $HideWindows + $restoreWindowsForCapture = $RestoreWindows + $captureRectangleForCapture = $CaptureRectangle + $captureBounds = { + param($bounds) + $hidden = & $hideWindowsForCapture + try { + $captured = & $captureRectangleForCapture $bounds + if ($captured -is [System.Drawing.Image]) { + $captured.Tag = [pscustomobject][ordered]@{ + X=[int]$bounds.X; Y=[int]$bounds.Y + Width=[int]$bounds.Width; Height=[int]$bounds.Height + } + } + $captured + } finally { + & $restoreWindowsForCapture $hidden | Out-Null + } + }.GetNewClosure() + + $fullCapture = { + param($coordinator,$request) + # Resolve virtual bounds inside the transaction so monitor changes and + # negative-origin layouts are never served by stale coordinates. + $vs = & $getVirtualBoundsForCapture + & $captureBounds $vs + }.GetNewClosure() + + $windowCapture = { + param($coordinator,$request) + # Foreground HWND and bounds are deliberately resolved for every fresh + # Window request, immediately before that request's snapshot. + $descriptor = & $resolveWindowForCapture + $validDescriptor = $null -ne $descriptor + $captureDescriptor = $null + if ($validDescriptor) { + foreach ($propertyName in 'X','Y','Width','Height') { + if ($null -eq $descriptor.PSObject.Properties[$propertyName]) { + $validDescriptor = $false + break + } + } + } + if ($validDescriptor) { + try { + $x = [int]$descriptor.X + $y = [int]$descriptor.Y + $width = [int]$descriptor.Width + $height = [int]$descriptor.Height + $validDescriptor = ($width -gt 0 -and $height -gt 0) + if ($validDescriptor) { + $hwndProperty = $descriptor.PSObject.Properties['Hwnd'] + $captureDescriptor = [pscustomobject][ordered]@{ + Hwnd = if ($null -ne $hwndProperty) { $hwndProperty.Value } else { $null } + X = $x + Y = $y + Width = $width + Height = $height + } + } + } catch { + $validDescriptor = $false + } + } + if (-not $validDescriptor) { + return (& $fullCapture $coordinator $request) + } + & $captureBounds $captureDescriptor + }.GetNewClosure() + + $smartCapture = { + param($coordinator,$request) + $surfaceContext = [pscustomobject]@{ + Coordinator = $coordinator + Request = $request + } + $surfaceReady = { + param($surface) + $owner = $surfaceContext.Coordinator + + if ($owner.ShutdownRequested -or $owner.Phase -eq 'ShuttingDown') { + $owner.ActiveSurface = $surface + $owner.SurfaceCloseRequested = $false + $owner.Phase = 'ShuttingDown' + Close-SnipActiveSurface -Coordinator $owner -Result Shutdown | Out-Null + return $false + } + + if ($null -ne $owner.PendingRequest -or + $null -eq $owner.ActiveRequest -or + $owner.ActiveRequest.Id -ne $surfaceContext.Request.Id) { + # Register only long enough to close the stale surface. Its + # transaction never exposes Selecting. + $owner.ActiveSurface = $surface + $owner.SurfaceCloseRequested = $false + Close-SnipActiveSurface -Coordinator $owner -Result Preempted | Out-Null + return $false + } + + # The current request publishes its closable surface and Selecting + # atomically on this UI-thread callback. + $owner.ActiveSurface = $surface + $owner.SurfaceCloseRequested = $false + $owner.Phase = 'Selecting' + return $true + }.GetNewClosure() + $overlayOutput = @(& $smartOverlayForCapture $surfaceReady $coordinator $request) + $overlayResult = if ($overlayOutput.Count) { $overlayOutput[-1] } else { $null } + if ($null -eq $overlayResult) { + return [pscustomobject][ordered]@{ + Result='UserCancelled'; Bitmap=$null; ErrorRecord=$null + } + } + $resultProperty = $overlayResult.PSObject.Properties['Result'] + $resultName = if ($null -ne $resultProperty) { + [string]$resultProperty.Value + } else { + 'UserCancelled' + } + if ($resultName -ne 'Completed') { return $overlayResult } + + $selectionProperty = $overlayResult.PSObject.Properties['Selection'] + $selection = if ($null -ne $selectionProperty) { + $selectionProperty.Value + } else { + $null + } + $validSelection = $null -ne $selection + if ($validSelection) { + foreach ($propertyName in 'X','Y','Width','Height') { + if ($null -eq $selection.PSObject.Properties[$propertyName]) { + $validSelection = $false + break + } + } + } + if ($validSelection) { + try { + $selectionBounds = [pscustomobject][ordered]@{ + X = [int]$selection.X + Y = [int]$selection.Y + Width = [int]$selection.Width + Height = [int]$selection.Height + } + $validSelection = Test-CaptureRectValid ` + -Width $selectionBounds.Width -Height $selectionBounds.Height + } catch { + $validSelection = $false + } + } + if (-not $validSelection) { + return [pscustomobject][ordered]@{ + Result='UserCancelled'; Bitmap=$null; ErrorRecord=$null + } + } + + # Overlay returns only physical geometry. The coordinator-owned + # capture service creates the fresh crop after all overlay HWNDs close. + & $captureBounds $selectionBounds + }.GetNewClosure() + + $preview = { + param($bitmap,$accept,$coordinator,$request) + $coordinatorForPreview = $coordinator + $surfaceReady = { + param($surface) + $coordinatorForPreview.ActiveSurface = $surface + $coordinatorForPreview.PreviewWindow = $surface.Window + $coordinatorForPreview.SurfaceCloseRequested = $false + }.GetNewClosure() + $newSnip = { + Request-SnipCapture -Coordinator $coordinatorForPreview ` + -Mode Smart -Source PreviewNew | Out-Null + }.GetNewClosure() + $outputStarting = { + param($kind) + if ($coordinatorForPreview.Phase -eq 'Previewing') { + $coordinatorForPreview.Phase = 'Completing' + } + }.GetNewClosure() + $outputCompleted = { + param($kind,$success) + $result = if ($success) { 'Completed' } else { 'UserCancelled' } + $operation = switch ($kind) { + 'CopyAndClose' { 'Copy' } + 'CopyKeepOpen' { 'CopyKeepOpen' } + default { 'Save' } + } + Complete-SnipSurface -Coordinator $coordinatorForPreview ` + -Result $result -Operation $operation | Out-Null + }.GetNewClosure() + Show-PreviewWindow -Bitmap $bitmap ` + -OnOwnershipAccepted $accept ` + -OnSurfaceReady $surfaceReady ` + -OnNewSnip $newSnip ` + -OnOutputStarting $outputStarting ` + -OnOutputCompleted $outputCompleted + }.GetNewClosure() + + $startDelay = { + param($delay,$callback,$request,$coordinator) + $timer = New-Object System.Windows.Forms.Timer + $timer.Interval = [math]::Max(1, [int][math]::Ceiling($delay.TotalMilliseconds)) + $timer.Add_Tick({ + $timer.Stop() + # The timer continuation re-enters the one public request API; + # Request-SnipCapture owns cancellation and disposal of this timer. + & $callback + }.GetNewClosure()) + $timer.Start() + $timer + }.GetNewClosure() + + $cancelDelay = { + param($timer) + try { $timer.Stop() } finally { $timer.Dispose() } + } + + [pscustomobject]@{ + SmartCapture = $smartCapture + FullCapture = $fullCapture + WindowCapture = $windowCapture + Preview = $preview + StartDelay = $startDelay + CancelDelay = $cancelDelay + } +} + +function Invoke-SmartCapture { + Request-SnipCapture -Coordinator $script:CaptureCoordinator ` + -Mode Smart -Source Legacy | Out-Null +} + +function Invoke-FullScreenCapture { + Request-SnipCapture -Coordinator $script:CaptureCoordinator ` + -Mode Full -Source Legacy | Out-Null +} + +function Invoke-WindowCapture { + Request-SnipCapture -Coordinator $script:CaptureCoordinator ` + -Mode Window -Source Legacy | Out-Null +} + +function Start-DelayedCapture { + [CmdletBinding()] + param([int]$Seconds, [ValidateSet('smart','full','window')] [string]$Type) + $plural = if ($Seconds -ne 1) { 's' } else { '' } + try { + $script:tray.BalloonTipTitle = 'SnipIT' + $script:tray.BalloonTipText = "Capturing ($Type) in $Seconds second$plural..." + $script:tray.ShowBalloonTip(1500) + } catch {} + Request-SnipCapture -Coordinator $script:CaptureCoordinator ` + -Mode $Type -Delay ([timespan]::FromSeconds($Seconds)) -Source TrayDelay | Out-Null +} + +# source: src/40-Preview.ps1 +#requires -Version 7.5 +# Generated deterministically from the reviewed SnipIT.ps1 AST by scripts/Export-SnipITModules.ps1. + +$script:CurrentPreviewWindow = $null + +function New-SnipPreviewContext { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Drawing.Bitmap]$Bitmap, + [System.Windows.Media.Imaging.BitmapSource]$BitmapSource, + $CaptureBounds, + [object[]]$MonitorDescriptors, + [System.Windows.ResourceDictionary]$Resources, + [scriptblock]$RegisterWindow = { param($hwnd) Register-SelfWindowHandle -Hwnd $hwnd }, + [scriptblock]$UnregisterWindow = { param($hwnd) Unregister-SelfWindowHandle -Hwnd $hwnd }, + [scriptblock]$SetWindowPosition = { + param($hwnd, $bounds) + [Native]::SetWindowPos( + $hwnd, [IntPtr]::Zero, + [int]$bounds.X, [int]$bounds.Y, + [int]$bounds.Width, [int]$bounds.Height, + [Native]::SWP_NOZORDER -bor [Native]::SWP_NOACTIVATE) + } + ) + + if ($null -eq $Resources) { + $Resources = New-SnipThemeResources ` + -HighContrast ([bool][System.Windows.SystemParameters]::HighContrast) + } + if ($null -eq $MonitorDescriptors -or $MonitorDescriptors.Count -eq 0) { + $MonitorDescriptors = @(Get-SnipMonitorDescriptors) + } + if ($null -eq $BitmapSource) { + $BitmapSource = Convert-BitmapToBitmapSource $Bitmap + } + if (-not $BitmapSource.IsFrozen -and $BitmapSource.CanFreeze) { + $BitmapSource.Freeze() + } + if ($null -eq $CaptureBounds) { + $tag = $Bitmap.Tag + if ($null -ne $tag -and + $null -ne $tag.PSObject.Properties['X'] -and + $null -ne $tag.PSObject.Properties['Y'] -and + $null -ne $tag.PSObject.Properties['Width'] -and + $null -ne $tag.PSObject.Properties['Height']) { + $CaptureBounds = $tag + } elseif ($MonitorDescriptors.Count -gt 0) { + $fallback = @($MonitorDescriptors | Where-Object IsPrimary | Select-Object -First 1) + if ($fallback.Count -eq 0) { $fallback = @($MonitorDescriptors[0]) } + $CaptureBounds = [pscustomobject]@{ + X = [double]$fallback[0].X + Y = [double]$fallback[0].Y + Width = [double]$fallback[0].Width + Height = [double]$fallback[0].Height + } + } else { + $CaptureBounds = [pscustomobject]@{ X=0; Y=0; Width=$Bitmap.Width; Height=$Bitmap.Height } + } + } + + $captureLeft = [double]$CaptureBounds.X + $captureTop = [double]$CaptureBounds.Y + $captureRight = $captureLeft + [double]$CaptureBounds.Width + $captureBottom = $captureTop + [double]$CaptureBounds.Height + $captureCenterX = $captureLeft + ([double]$CaptureBounds.Width / 2) + $captureCenterY = $captureTop + ([double]$CaptureBounds.Height / 2) + $captureMonitor = $null + $largestIntersection = -1.0 + foreach ($monitor in @($MonitorDescriptors)) { + $left = [double]$monitor.X + $top = [double]$monitor.Y + $right = $left + [double]$monitor.Width + $bottom = $top + [double]$monitor.Height + $intersectionWidth = [math]::Max(0.0, [math]::Min($captureRight, $right) - [math]::Max($captureLeft, $left)) + $intersectionHeight = [math]::Max(0.0, [math]::Min($captureBottom, $bottom) - [math]::Max($captureTop, $top)) + $intersection = $intersectionWidth * $intersectionHeight + if ($intersection -gt $largestIntersection) { + $captureMonitor = $monitor + $largestIntersection = $intersection + } + } + if ($null -eq $captureMonitor -and $MonitorDescriptors.Count -gt 0) { + $captureMonitor = $MonitorDescriptors[0] + } + + if ($null -ne $captureMonitor) { + $workX = if ($null -ne $captureMonitor.PSObject.Properties['WorkX']) { + [double]$captureMonitor.WorkX + } else { [double]$captureMonitor.X } + $workY = if ($null -ne $captureMonitor.PSObject.Properties['WorkY']) { + [double]$captureMonitor.WorkY + } else { [double]$captureMonitor.Y } + $workWidth = if ($null -ne $captureMonitor.PSObject.Properties['WorkWidth']) { + [double]$captureMonitor.WorkWidth + } else { [double]$captureMonitor.Width } + $workHeight = if ($null -ne $captureMonitor.PSObject.Properties['WorkHeight']) { + [double]$captureMonitor.WorkHeight + } else { [double]$captureMonitor.Height } + } else { + $workX = 0.0; $workY = 0.0; $workWidth = 1200.0; $workHeight = 700.0 + } + $dpiX = if ($null -ne $captureMonitor -and [double]$captureMonitor.DpiX -gt 0) { + [double]$captureMonitor.DpiX + } else { 96.0 } + $dpiY = if ($null -ne $captureMonitor -and [double]$captureMonitor.DpiY -gt 0) { + [double]$captureMonitor.DpiY + } else { 96.0 } + $scaleX = $dpiX / 96.0 + $scaleY = $dpiY / 96.0 + $initialPhysicalWidth = [math]::Min( + $workWidth, [math]::Round(1180.0 * $scaleX)) + $initialPhysicalHeight = [math]::Min( + $workHeight, [math]::Round(760.0 * $scaleY)) + $initialPhysicalWidth = [math]::Max( + [math]::Min($workWidth, [math]::Round(760.0 * $scaleX)), + $initialPhysicalWidth) + $initialPhysicalHeight = [math]::Max( + [math]::Min($workHeight, [math]::Round(540.0 * $scaleY)), + $initialPhysicalHeight) + $initialX = [math]::Max($workX, [math]::Min( + $captureCenterX - ($initialPhysicalWidth / 2), + $workX + $workWidth - $initialPhysicalWidth)) + $initialY = [math]::Max($workY, [math]::Min( + $captureCenterY - ($initialPhysicalHeight / 2), + $workY + $workHeight - $initialPhysicalHeight)) + $initialWidth = $initialPhysicalWidth / $scaleX + $initialHeight = $initialPhysicalHeight / $scaleY + + [pscustomobject][ordered]@{ + Bitmap = $Bitmap + BitmapSource = $BitmapSource + Annotations = [System.Collections.ArrayList]::new() + SelectedAnnotationId = $null + CropRectangle = $null + Draft = $null + Interaction = $null + AnnotationDraftClearCount = 0 + UndoStack = [System.Collections.Stack]::new() + RedoStack = [System.Collections.Stack]::new() + ActiveTool = 'Select' + ToolProperties = [ordered]@{ + Crop = [pscustomobject]@{ Preset='Free' } + } + CaptureBounds = $CaptureBounds + CaptureMonitor = $captureMonitor + InitialBounds = [pscustomobject]@{ + X=($initialX / $scaleX); Y=($initialY / $scaleY) + Width=$initialWidth; Height=$initialHeight + } + InitialPhysicalBounds = [pscustomobject]@{ + X=[int][math]::Round($initialX) + Y=[int][math]::Round($initialY) + Width=[int][math]::Round($initialPhysicalWidth) + Height=[int][math]::Round($initialPhysicalHeight) + } + Resources = $Resources + RegisterWindow = $RegisterWindow + UnregisterWindow = $UnregisterWindow + SetWindowPosition = $SetWindowPosition + GetKeyboardModifiers = { [System.Windows.Input.Keyboard]::Modifiers } + ClipboardSetter = { param($image) [System.Windows.Clipboard]::SetImage($image) } + SaveBitmap = { param($image) Save-CaptureToFile -Bitmap $image } + RetryDelay = { param($milliseconds) [System.Threading.Thread]::Sleep($milliseconds) } + PlacementState = [pscustomobject]@{ + HandlersAttached=$false + IsApplied=$false + } + Window = $null + Chrome = $null + Shell = $null + EditorState = $null + PropertyControls = [ordered]@{} + PropertyBindings = [System.Collections.ArrayList]::new() + CropAspectMenuControl = $null + SelectCropPreset = $null + ApplyCrop = $null + ResetCrop = $null + CancelDraft = $null + DeleteSelection = $null + DuplicateSelection = $null + LastHitRoute = $null + EditingProperty = $false + ApplySelectionProperty = $null + ModeState = [pscustomobject]@{ Value = 'Wide' } + ActivePropertyTool = 'Select' + RecentTools = [System.Collections.ArrayList]@('Highlight','ArrowLine') + ToolMetadata = [ordered]@{ + Select=[pscustomobject]@{ Icon='↖'; DisplayName='Select' } + Crop=[pscustomobject]@{ Icon='⌗'; DisplayName='Crop' } + Pen=[pscustomobject]@{ Icon='✎'; DisplayName='Pen' } + Highlight=[pscustomobject]@{ Icon='▰'; DisplayName='Highlight' } + ArrowLine=[pscustomobject]@{ Icon='↗'; DisplayName='Arrow/Line' } + RectangleEllipse=[pscustomobject]@{ Icon='□'; DisplayName='Rectangle/Ellipse' } + Text=[pscustomobject]@{ Icon='T'; DisplayName='Text' } + Steps=[pscustomobject]@{ Icon='①'; DisplayName='Steps' } + BlurPixelate=[pscustomobject]@{ Icon='▒'; DisplayName='Blur/Pixelate' } + Color=[pscustomobject]@{ Icon='●'; DisplayName='Color' } + } + ToolControls = [ordered]@{} + ToolOrder = [System.Collections.ArrayList]::new() + MoreState = [pscustomobject]@{ + IsActive=$false; Tool=$null; Name='More'; Icon='…' + } + StatusState = [pscustomobject]@{ Kind='Idle'; Text='' } + SetStatus = $null + ClearStatus = $null + PropertyState = [pscustomobject]@{ + Tool='Select'; Visible=[string[]]@(); Overflow=[string[]]@() + HorizontalScrollBarVisibility=[System.Windows.Controls.ScrollBarVisibility]::Disabled + } + SplitControls = [ordered]@{} + TransientMenus = [System.Collections.ArrayList]::new() + PropertyMenuControl = $null + AnnotationMenuControl = $null + CommandRouter = $null + RoutePreviewKeyDown = $null + PopupRouteMenu = $null + } +} + +function Set-SnipPreviewMenuStyle { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Windows.Controls.ContextMenu]$Menu, + [Parameter(Mandatory)] $Context + ) + + Add-SnipThemeResources -Root $Menu -Resources $Context.Resources | Out-Null + $Menu.Style = $Context.Resources['SnipContextMenuStyle'] + $Menu.Background = $Context.Resources['SnipCanvasBrush'] + $Menu.Foreground = $Context.Resources['SnipPrimaryTextBrush'] + $Menu.BorderBrush = $Context.Resources['SnipHairlineBrush'] + $Menu.BorderThickness = [System.Windows.Thickness]::new(1) + $Menu.Padding = [System.Windows.Thickness]::new(2) + $implicitScrollStyle = [System.Windows.Style]::new( + [System.Windows.Controls.Primitives.ScrollBar]) + $implicitScrollStyle.BasedOn = $Context.Resources['SnipMenuScrollBarStyle'] + $Menu.Resources[[System.Windows.Controls.Primitives.ScrollBar]] = $implicitScrollStyle + $style = $Context.Resources['SnipMenuItemStyle'] + $pending = [System.Collections.Generic.Queue[System.Windows.Controls.MenuItem]]::new() + $menuItems = [System.Collections.ArrayList]::new() + foreach ($rootItem in @($Menu.Items)) { + if ($rootItem -is [System.Windows.Controls.MenuItem]) { + $pending.Enqueue($rootItem) + } + } + while ($pending.Count -gt 0) { + $item = $pending.Dequeue() + $menuItems.Add($item) | Out-Null + Add-SnipThemeResources -Root $item -Resources $Context.Resources | Out-Null + $item.ClearValue([System.Windows.Controls.Control]::BackgroundProperty) + $item.ClearValue([System.Windows.Controls.Control]::ForegroundProperty) + $item.ClearValue([System.Windows.Controls.Control]::BorderBrushProperty) + $item.Style = $style + foreach ($childItem in @($item.Items)) { + if ($childItem -is [System.Windows.Controls.MenuItem]) { + $pending.Enqueue($childItem) + } + } + } + + $existingLifecycle = $Menu.Tag + if ($null -ne $existingLifecycle -and + $null -ne $existingLifecycle.PSObject.Properties['Kind'] -and + $existingLifecycle.Kind -eq 'SnipPreviewMenuLifecycle' -and + $existingLifecycle.HandlersAttached) { + return $Menu + } + + $nestedStates = [System.Collections.ArrayList]::new() + $lifecycle = [pscustomobject][ordered]@{ + Kind = 'SnipPreviewMenuLifecycle' + Menu = $Menu + NestedStates = $nestedStates + CloseNestedPopups = $null + Disconnect = $null + WindowClosedHandler = $null + PopupKeyRouteHandler = $null + PopupKeyRouteAttached = $false + PopupKeyRouteAttachCount = 0 + PopupKeyRouteInvocationCount = 0 + LastPopupKeyOrigin = $null + LastPopupRouteCommand = $null + LastPopupRouteHandled = $false + HandlersAttached = $true + } + $popupKeyRouteHandler = [System.Windows.Input.KeyEventHandler]({ + param($sender,$eventArgs) + $lifecycle.PopupKeyRouteInvocationCount++ + $lifecycle.LastPopupKeyOrigin = $eventArgs.OriginalSource + $previousRouteMenu = $Context.PopupRouteMenu + $Context.PopupRouteMenu = $Menu + try { + if ($null -ne $Context.RoutePreviewKeyDown) { + & $Context.RoutePreviewKeyDown $eventArgs + } + $lifecycle.LastPopupRouteCommand = if ($null -ne $Context.CommandRouter) { + $Context.CommandRouter.LastCommand + } else { $null } + } finally { + $lifecycle.LastPopupRouteHandled = [bool]$eventArgs.Handled + $Context.PopupRouteMenu = $previousRouteMenu + } + }.GetNewClosure()) + $lifecycle.PopupKeyRouteHandler = $popupKeyRouteHandler + $Menu.AddHandler( + [System.Windows.Input.Keyboard]::PreviewKeyDownEvent, + $popupKeyRouteHandler, + $true) + $lifecycle.PopupKeyRouteAttached = $true + $lifecycle.PopupKeyRouteAttachCount++ + $unregisterNested = { + param($NestedState) + if ($NestedState.IsRegistered -and $NestedState.Handle -ne [IntPtr]::Zero) { + & $Context.UnregisterWindow $NestedState.Handle + } + $NestedState.Handle = [IntPtr]::Zero + $NestedState.IsRegistered = $false + }.GetNewClosure() + foreach ($menuItem in @($menuItems)) { + $menuItem.ApplyTemplate() | Out-Null + $popup = $menuItem.Template.FindName('PART_Popup',$menuItem) + if ($popup -isnot [System.Windows.Controls.Primitives.Popup]) { continue } + $popup.PopupAnimation = [System.Windows.Controls.Primitives.PopupAnimation]( + $Context.Resources['SnipPopupAnimation']) + $nestedState = [pscustomobject][ordered]@{ + Item = $menuItem + Popup = $popup + Handle = [IntPtr]::Zero + IsRegistered = $false + OpenedHandler = $null + ClosedHandler = $null + HandlersAttached = $true + } + $popupForHandler = $popup + $stateForHandler = $nestedState + $openedHandler = { + $source = if ($null -ne $popupForHandler.Child) { + [System.Windows.PresentationSource]::FromVisual($popupForHandler.Child) + } else { $null } + if ($source -is [System.Windows.Interop.HwndSource] -and + $source.Handle -ne [IntPtr]::Zero -and + -not $stateForHandler.IsRegistered) { + & $Context.RegisterWindow $source.Handle + $stateForHandler.Handle = $source.Handle + $stateForHandler.IsRegistered = $true + } + }.GetNewClosure() + $closedHandler = { + & $unregisterNested $stateForHandler + }.GetNewClosure() + $nestedState.OpenedHandler = $openedHandler + $nestedState.ClosedHandler = $closedHandler + $popup.Tag = $nestedState + $popup.Add_Opened($openedHandler) + $popup.Add_Closed($closedHandler) + $nestedStates.Add($nestedState) | Out-Null + } + $closeNestedPopups = { + for ($index = $nestedStates.Count - 1; $index -ge 0; $index--) { + $nestedState = $nestedStates[$index] + if ($nestedState.Item.IsSubmenuOpen) { + $nestedState.Item.IsSubmenuOpen = $false + } + & $unregisterNested $nestedState + } + }.GetNewClosure() + $disconnect = { + & $closeNestedPopups + if ($lifecycle.PopupKeyRouteAttached) { + $Menu.RemoveHandler( + [System.Windows.Input.Keyboard]::PreviewKeyDownEvent, + $lifecycle.PopupKeyRouteHandler) + $lifecycle.PopupKeyRouteAttached = $false + } + foreach ($nestedState in @($nestedStates)) { + if (-not $nestedState.HandlersAttached) { continue } + $nestedState.Popup.Remove_Opened($nestedState.OpenedHandler) + $nestedState.Popup.Remove_Closed($nestedState.ClosedHandler) + $nestedState.HandlersAttached = $false + } + if ($null -ne $Context.Window -and + $null -ne $lifecycle.WindowClosedHandler) { + $Context.Window.Remove_Closed($lifecycle.WindowClosedHandler) + } + $lifecycle.HandlersAttached = $false + }.GetNewClosure() + $lifecycle.CloseNestedPopups = $closeNestedPopups + $lifecycle.Disconnect = $disconnect + if ($null -ne $Context.Window) { + $windowClosedHandler = { & $lifecycle.Disconnect }.GetNewClosure() + $lifecycle.WindowClosedHandler = $windowClosedHandler + $Context.Window.Add_Closed($windowClosedHandler) + } + $Menu.Tag = $lifecycle + $Menu +} + +function Connect-SnipPreviewMenuButton { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Windows.Controls.Button]$Button, + [Parameter(Mandatory)] [System.Windows.Controls.ContextMenu]$Menu, + [Parameter(Mandatory)] $Context, + [Parameter(Mandatory)] [string]$Name + ) + + Set-SnipPreviewMenuStyle -Menu $Menu -Context $Context | Out-Null + $Menu.PlacementTarget = $Button + $Button.ContextMenu = $Menu + [System.Windows.Automation.AutomationProperties]::SetItemStatus($Button, 'Collapsed') + $state = [pscustomobject]@{ + IsExpanded=$false; MenuHandle=[IntPtr]::Zero; IsMenuWindowRegistered=$false + HandlersAttached=$false + } + $open = { + $Menu.PlacementTarget = $Button + $Menu.IsOpen = $true + }.GetNewClosure() + $close = { + if ($null -ne $Menu.Tag -and $null -ne $Menu.Tag.CloseNestedPopups) { + & $Menu.Tag.CloseNestedPopups + } + if ($Menu.IsOpen) { $Menu.IsOpen = $false } + if ($state.IsMenuWindowRegistered -and $state.MenuHandle -ne [IntPtr]::Zero) { + & $Context.UnregisterWindow $state.MenuHandle + } + $state.IsExpanded = $false + $state.MenuHandle = [IntPtr]::Zero + $state.IsMenuWindowRegistered = $false + [System.Windows.Automation.AutomationProperties]::SetItemStatus($Button, 'Collapsed') + try { $Button.Focus() | Out-Null } catch {} + }.GetNewClosure() + $openedHandler = { + $state.IsExpanded = $true + [System.Windows.Automation.AutomationProperties]::SetItemStatus($Button, 'Expanded') + $source = [System.Windows.PresentationSource]::FromVisual($Menu) + if ($source -is [System.Windows.Interop.HwndSource] -and + $source.Handle -ne [IntPtr]::Zero -and -not $state.IsMenuWindowRegistered) { + & $Context.RegisterWindow $source.Handle + $state.MenuHandle = $source.Handle + $state.IsMenuWindowRegistered = $true + } + }.GetNewClosure() + $closedHandler = { & $close }.GetNewClosure() + $buttonClickHandler = { & $open }.GetNewClosure() + $disconnect = $null + $windowClosedHandler = $null + $disconnect = { + & $close + if ($state.HandlersAttached) { + $Menu.Remove_Opened($openedHandler) + $Menu.Remove_Closed($closedHandler) + $Button.Remove_Click($buttonClickHandler) + if ($null -ne $Context.Window -and $null -ne $windowClosedHandler) { + $Context.Window.Remove_Closed($windowClosedHandler) + } + $state.HandlersAttached = $false + } + if ($null -ne $Menu.Tag -and $null -ne $Menu.Tag.Disconnect) { + & $Menu.Tag.Disconnect + } + }.GetNewClosure() + $windowClosedHandler = { & $disconnect }.GetNewClosure() + $Menu.Add_Opened($openedHandler) + $Menu.Add_Closed($closedHandler) + $Button.Add_Click($buttonClickHandler) + if ($null -ne $Context.Window) { $Context.Window.Add_Closed($windowClosedHandler) } + $state.HandlersAttached = $true + $control = [pscustomobject]@{ + Name=$Name; State=$state; Menu=$Menu; Button=$Button + OpenOptions=$open; CloseOptions=$close; Disconnect=$disconnect + } + $Context.TransientMenus.Add($control) | Out-Null + $control +} + +function Connect-SnipPreviewTransientContextMenu { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Windows.Controls.ContextMenu]$Menu, + [Parameter(Mandatory)] [System.Windows.FrameworkElement]$PlacementTarget, + [Parameter(Mandatory)] $Context, + [Parameter(Mandatory)] [string]$Name, + [scriptblock]$Cleanup = {} + ) + + Set-SnipPreviewMenuStyle -Menu $Menu -Context $Context | Out-Null + $Menu.PlacementTarget = $PlacementTarget + $state = [pscustomobject][ordered]@{ + IsExpanded = $false + MenuHandle = [IntPtr]::Zero + IsMenuWindowRegistered = $false + HandlersAttached = $false + CleanupRan = $false + } + $open = { + $Menu.PlacementTarget = $PlacementTarget + $Menu.IsOpen = $true + }.GetNewClosure() + $close = { + if ($null -ne $Menu.Tag -and $null -ne $Menu.Tag.CloseNestedPopups) { + & $Menu.Tag.CloseNestedPopups + } + if ($Menu.IsOpen) { $Menu.IsOpen = $false } + if ($state.IsMenuWindowRegistered -and $state.MenuHandle -ne [IntPtr]::Zero) { + & $Context.UnregisterWindow $state.MenuHandle + } + $state.IsExpanded = $false + $state.MenuHandle = [IntPtr]::Zero + $state.IsMenuWindowRegistered = $false + try { $PlacementTarget.Focus() | Out-Null } catch {} + }.GetNewClosure() + $registerMenuWindow = { + if (-not $Menu.IsOpen) { return } + $source = [System.Windows.PresentationSource]::FromVisual($Menu) + if ($source -is [System.Windows.Interop.HwndSource] -and + $source.Handle -ne [IntPtr]::Zero -and + -not $state.IsMenuWindowRegistered) { + & $Context.RegisterWindow $source.Handle + $state.MenuHandle = $source.Handle + $state.IsMenuWindowRegistered = $true + } + }.GetNewClosure() + $openedHandler = { + $state.IsExpanded = $true + & $registerMenuWindow + if (-not $state.IsMenuWindowRegistered) { + $retryRegistration = [Action]{ & $registerMenuWindow }.GetNewClosure() + [void]$Menu.Dispatcher.BeginInvoke( + [System.Windows.Threading.DispatcherPriority]::Render, + $retryRegistration) + } + }.GetNewClosure() + $closedHandler = { & $close }.GetNewClosure() + $disconnect = $null + $windowClosedHandler = $null + $disconnect = { + & $close + if ($state.HandlersAttached) { + $Menu.Remove_Opened($openedHandler) + $Menu.Remove_Closed($closedHandler) + if ($null -ne $Context.Window -and $null -ne $windowClosedHandler) { + $Context.Window.Remove_Closed($windowClosedHandler) + } + $state.HandlersAttached = $false + } + if ($null -ne $Menu.Tag -and $null -ne $Menu.Tag.Disconnect) { + & $Menu.Tag.Disconnect + } + if (-not $state.CleanupRan) { + & $Cleanup + $state.CleanupRan = $true + } + }.GetNewClosure() + $windowClosedHandler = { & $disconnect }.GetNewClosure() + $Menu.Add_Opened($openedHandler) + $Menu.Add_Closed($closedHandler) + if ($null -ne $Context.Window) { $Context.Window.Add_Closed($windowClosedHandler) } + $state.HandlersAttached = $true + $control = [pscustomobject][ordered]@{ + Name = $Name + State = $state + Menu = $Menu + PlacementTarget = $PlacementTarget + OpenOptions = $open + CloseOptions = $close + Disconnect = $disconnect + } + $Context.TransientMenus.Add($control) | Out-Null + $control +} + +function New-SnipSplitControl { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$Name, + [Parameter(Mandatory)] [string]$DefaultCommand, + [Parameter(Mandatory)] [string]$PrimaryText, + [Parameter(Mandatory)] [string]$PrimaryAutomationName, + [Parameter(Mandatory)] [string]$OptionsAutomationName, + [Parameter(Mandatory)] [System.Collections.IDictionary]$Options, + [Parameter(Mandatory)] $Context, + [string]$Glyph = '', + [double]$PrimaryWidth = 38, + [double]$OptionsWidth = 22, + [scriptblock]$PrimaryAction = {} + ) + + $root = [System.Windows.Controls.Border]::new() + $root.CornerRadius = [System.Windows.CornerRadius]::new(10) + $root.BorderThickness = [System.Windows.Thickness]::new(1) + $root.Margin = [System.Windows.Thickness]::new(2,0,2,0) + $root.SetResourceReference( + [System.Windows.Controls.Border]::BorderBrushProperty, 'SnipHairlineBrush') + $root.SetResourceReference( + [System.Windows.Controls.Border]::BackgroundProperty, 'SnipGlassGradientBrush') + + $grid = [System.Windows.Controls.Grid]::new() + $grid.ColumnDefinitions.Add([System.Windows.Controls.ColumnDefinition]::new()) + $grid.ColumnDefinitions.Add([System.Windows.Controls.ColumnDefinition]::new()) + $grid.ColumnDefinitions[0].Width = [System.Windows.GridLength]::new($PrimaryWidth) + $grid.ColumnDefinitions[1].Width = [System.Windows.GridLength]::new($OptionsWidth) + $root.Child = $grid + + $primaryButton = [System.Windows.Controls.Button]::new() + $primaryButton.Width = $PrimaryWidth + $primaryButton.Height = 38 + $primaryButton.Padding = [System.Windows.Thickness]::new(4,0,4,0) + $primaryButton.IsTabStop = $true + $primaryButton.SetResourceReference( + [System.Windows.FrameworkElement]::StyleProperty, 'SnipButtonStyle') + [System.Windows.Automation.AutomationProperties]::SetName( + $primaryButton, $PrimaryAutomationName) + $primaryButton.ToolTip = if ($Name -eq 'CopyAndClose') { + 'Copy and close (Ctrl+Enter)' + } else { "$PrimaryAutomationName (Enter)" } + [System.Windows.Controls.Grid]::SetColumn($primaryButton, 0) + + $primaryContent = [System.Windows.Controls.StackPanel]::new() + $primaryContent.Orientation = [System.Windows.Controls.Orientation]::Horizontal + $primaryContent.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center + if (-not [string]::IsNullOrEmpty($Glyph)) { + $icon = [System.Windows.Controls.TextBlock]::new() + $icon.Text = $Glyph + $icon.FontFamily = [System.Windows.Media.FontFamily]::new('Segoe Fluent Icons') + $icon.Margin = [System.Windows.Thickness]::new(0,0,5,0) + $primaryContent.Children.Add($icon) | Out-Null + } + $label = [System.Windows.Controls.TextBlock]::new() + $label.Text = $PrimaryText + $label.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + $primaryContent.Children.Add($label) | Out-Null + $primaryButton.Content = $primaryContent + + $optionsButton = [System.Windows.Controls.Button]::new() + $optionsButton.Width = $OptionsWidth + $optionsButton.Height = 38 + $optionsButton.Padding = [System.Windows.Thickness]::new(0) + $optionsButton.Content = [char]0xE70D + $optionsButton.FontFamily = [System.Windows.Media.FontFamily]::new('Segoe Fluent Icons') + $optionsButton.IsTabStop = $true + $optionsButton.SetResourceReference( + [System.Windows.FrameworkElement]::StyleProperty, 'SnipButtonStyle') + [System.Windows.Automation.AutomationProperties]::SetName( + $optionsButton, $OptionsAutomationName) + $optionsButton.ToolTip = switch ($Name) { + 'CopyAndClose' { 'Copy options (Alt+Down)' } + 'ArrowLine' { 'Arrow/Line options (Alt+Down)' } + 'RectangleEllipse' { 'Rectangle/Ellipse options (Alt+Down)' } + 'BlurPixelate' { 'Blur/Pixelate options (Alt+Down)' } + default { "$OptionsAutomationName (Alt+Down)" } + } + [System.Windows.Automation.AutomationProperties]::SetItemStatus( + $optionsButton, 'Collapsed') + [System.Windows.Controls.Grid]::SetColumn($optionsButton, 1) + $grid.Children.Add($primaryButton) | Out-Null + $grid.Children.Add($optionsButton) | Out-Null + + $menu = [System.Windows.Controls.ContextMenu]::new() + $menuItems = [ordered]@{} + foreach ($optionName in @($Options.Keys)) { + $item = [System.Windows.Controls.MenuItem]::new() + $item.Header = switch ([string]$optionName) { + 'CopyKeepOpen' { 'Copy and keep open' } + 'Arrow' { 'Arrow' } + 'Line' { 'Line' } + 'Rectangle' { 'Rectangle' } + 'Ellipse' { 'Ellipse' } + 'Blur' { 'Blur' } + 'Pixelate' { 'Pixelate' } + default { [string]$optionName } + } + [System.Windows.Automation.AutomationProperties]::SetName($item, [string]$item.Header) + $callback = $Options[$optionName] + $item.Add_Click({ & $callback }.GetNewClosure()) + $menu.Items.Add($item) | Out-Null + $menuItems[[string]$optionName] = $item + } + Set-SnipPreviewMenuStyle -Menu $menu -Context $Context | Out-Null + $menu.PlacementTarget = $optionsButton + + $state = [pscustomobject]@{ + IsExpanded = $false + MenuHandle = [IntPtr]::Zero + IsMenuWindowRegistered = $false + LastOpener = $optionsButton + HandlersAttached = $false + } + $registerWindow = $Context.RegisterWindow + $unregisterWindow = $Context.UnregisterWindow + $openOptions = { + param($opener) + if ($null -eq $opener) { $opener = $optionsButton } + $state.LastOpener = $opener + $menu.PlacementTarget = $opener + $menu.IsOpen = $true + }.GetNewClosure() + $closeOptions = { + if ($null -ne $menu.Tag -and $null -ne $menu.Tag.CloseNestedPopups) { + & $menu.Tag.CloseNestedPopups + } + if ($menu.IsOpen) { $menu.IsOpen = $false } + # ContextMenu.Closed is dispatcher-delayed on some WPF paths. Keep the + # registry and accessibility state synchronous for capture preflight. + if ($state.IsMenuWindowRegistered -and $state.MenuHandle -ne [IntPtr]::Zero) { + & $unregisterWindow $state.MenuHandle + } + $state.IsExpanded = $false + $state.MenuHandle = [IntPtr]::Zero + $state.IsMenuWindowRegistered = $false + [System.Windows.Automation.AutomationProperties]::SetItemStatus( + $optionsButton, 'Collapsed') + try { $state.LastOpener.Focus() | Out-Null } catch {} + }.GetNewClosure() + $handleKey = { + param([string]$Key, [bool]$Alt, $Opener) + if ($Alt -and $Key -eq 'Down') { + & $openOptions $Opener + return $true + } + if ($Key -eq 'Escape' -and $state.IsExpanded) { & $closeOptions; return $true } + if (-not $state.IsExpanded) { return $false } + $items = @($menu.Items | Where-Object { $_ -is [System.Windows.Controls.MenuItem] }) + if ($items.Count -eq 0) { return $false } + switch ($Key) { + 'Home' { $items[0].Focus() | Out-Null; return $true } + 'End' { $items[-1].Focus() | Out-Null; return $true } + 'Down' { $items[0].Focus() | Out-Null; return $true } + 'Up' { $items[-1].Focus() | Out-Null; return $true } + 'Enter' { + $focused = [System.Windows.Input.Keyboard]::FocusedElement + if ($focused -is [System.Windows.Controls.MenuItem]) { + $focused.RaiseEvent([System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.MenuItem]::ClickEvent)) + return $true + } + } + } + return $false + }.GetNewClosure() + + $menuOpenedHandler = { + $state.IsExpanded = $true + [System.Windows.Automation.AutomationProperties]::SetItemStatus( + $optionsButton, 'Expanded') + $source = [System.Windows.PresentationSource]::FromVisual($menu) + if ($source -is [System.Windows.Interop.HwndSource]) { + $handle = $source.Handle + if ($handle -ne [IntPtr]::Zero -and -not $state.IsMenuWindowRegistered) { + & $registerWindow $handle + $state.MenuHandle = $handle + $state.IsMenuWindowRegistered = $true + } + } + }.GetNewClosure() + $menuClosedHandler = { + try { + if ($state.IsMenuWindowRegistered -and $state.MenuHandle -ne [IntPtr]::Zero) { + & $unregisterWindow $state.MenuHandle + } + } finally { + $state.IsExpanded = $false + $state.MenuHandle = [IntPtr]::Zero + $state.IsMenuWindowRegistered = $false + [System.Windows.Automation.AutomationProperties]::SetItemStatus( + $optionsButton, 'Collapsed') + try { $state.LastOpener.Focus() | Out-Null } catch {} + } + }.GetNewClosure() + $menu.Add_Opened($menuOpenedHandler) + $menu.Add_Closed($menuClosedHandler) + $primaryClickHandler = { & $PrimaryAction }.GetNewClosure() + $primaryButton.Add_Click($primaryClickHandler) + $optionsClickHandler = { & $openOptions $optionsButton }.GetNewClosure() + $optionsButton.Add_Click($optionsClickHandler) + $splitKeyDownHandler = { + $modifiers = & $Context.GetKeyboardModifiers + $alt = ($modifiers -band + [System.Windows.Input.ModifierKeys]::Alt) -ne 0 + if (& $handleKey ([string]$_.Key) $alt $_.Source) { + $_.Handled = $true + } + }.GetNewClosure() + $primaryButton.Add_PreviewKeyDown($splitKeyDownHandler) + $optionsButton.Add_PreviewKeyDown($splitKeyDownHandler) + $splitClosedHandler = { + & $closeOptions + $menu.Remove_Opened($menuOpenedHandler) + $menu.Remove_Closed($menuClosedHandler) + $primaryButton.Remove_Click($primaryClickHandler) + $primaryButton.Remove_PreviewKeyDown($splitKeyDownHandler) + $optionsButton.Remove_PreviewKeyDown($splitKeyDownHandler) + $optionsButton.Remove_Click($optionsClickHandler) + if ($null -ne $menu.Tag -and $null -ne $menu.Tag.Disconnect) { + & $menu.Tag.Disconnect + } + $Context.Window.Remove_Closed($splitClosedHandler) + $state.HandlersAttached = $false + }.GetNewClosure() + $Context.Window.Add_Closed($splitClosedHandler) + $state.HandlersAttached = $true + + $split = [pscustomobject][ordered]@{ + Name = $Name + DefaultCommand = $DefaultCommand + OptionOrder = [string[]]@($Options.Keys) + Root = $root + PrimaryButton = $primaryButton + OptionsButton = $optionsButton + Menu = $menu + MenuItems = $menuItems + Label = $label + State = $state + OpenOptions = $openOptions + CloseOptions = $closeOptions + HandleKey = $handleKey + } + $root.Tag = $split + $Context.TransientMenus.Add($split) | Out-Null + $split +} + +function Set-SnipPropertyIsland { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Context, + [Parameter(Mandatory)] + [ValidateSet('Select','Crop','Pen','Highlight','ArrowLine','RectangleEllipse','Text','Steps','BlurPixelate')] + [string]$Tool + ) + + $definitions = [ordered]@{ + Select = @('Position','Size','Duplicate','Delete') + Crop = @('Aspect','Reset','Apply') + Pen = @('Color','Width','Opacity') + Highlight = @('Color','Width','Opacity') + ArrowLine = @('Color','Width','Opacity','StartStyle','EndStyle') + RectangleEllipse = @('StrokeColor','Fill','Width','Opacity') + Text = @('Color','FontSize','Weight','Background') + Steps = @('Color','Size','ResetSequence') + BlurPixelate = @('Mode','Strength','Feathering') + } + $Context.ActivePropertyTool = $Tool + foreach ($binding in @($Context.PropertyBindings)) { + $bindingEvent = if ($null -ne $binding.PSObject.Properties['Event']) { + [string]$binding.Event + } else { 'Click' } + switch ($bindingEvent) { + 'GotKeyboardFocus' { $binding.Target.Remove_GotKeyboardFocus($binding.Handler) } + 'LostKeyboardFocus' { $binding.Target.Remove_LostKeyboardFocus($binding.Handler) } + 'KeyDown' { $binding.Target.Remove_KeyDown($binding.Handler) } + default { $binding.Target.Remove_Click($binding.Handler) } + } + } + $Context.PropertyBindings.Clear() + foreach ($controlName in @('PropertyMenuControl','CropAspectMenuControl')) { + $control = $Context.$controlName + if ($null -eq $control) { continue } + if ($null -ne $control.Disconnect) { & $control.Disconnect } + try { & $control.CloseOptions } catch {} + [void]$Context.TransientMenus.Remove($control) + $Context.$controlName = $null + } + $Context.PropertyControls.Clear() + $hasSelection = $null -ne $Context.SelectedAnnotationId + if ($Tool -eq 'Select' -and -not $hasSelection) { + $Context.PropertyState.Tool = 'Select' + $Context.PropertyState.Visible = [string[]]@() + $Context.PropertyState.Overflow = [string[]]@() + if ($null -ne $Context.Shell -and $null -ne $Context.Shell.PropertyIsland) { + $Context.Shell.PropertyPanel.Children.Clear() + $Context.Shell.PropertyIsland.Visibility = [System.Windows.Visibility]::Collapsed + } + return $Context.PropertyState + } + if ($null -ne $Context.Shell -and $null -ne $Context.Shell.PropertyIsland) { + $Context.Shell.PropertyIsland.Visibility = [System.Windows.Visibility]::Visible + } + $mode = [string]$Context.ModeState.Value + $all = [string[]]@($definitions[$Tool]) + [string[]]$visible = @() + [string[]]$overflow = @() + if ($mode -eq 'Wide') { + $visible = $all + $overflow = @() + } else { + $primaryCount = [math]::Min(2, $all.Count) + $visible = @($all[0..($primaryCount - 1)]) + $overflow = if ($all.Count -gt $primaryCount) { + @($all[$primaryCount..($all.Count - 1)]) + } else { @() } + if ($overflow.Count -gt 0) { $visible = @($visible + 'MoreProperties') } + } + $Context.PropertyState.Tool = $Tool + $Context.PropertyState.Visible = $visible + $Context.PropertyState.Overflow = $overflow + $panel = $Context.Shell.PropertyPanel + if ($null -ne $panel) { + $panel.Children.Clear() + foreach ($propertyName in $visible) { + if ($Tool -eq 'Select' -and $propertyName -in @('Position','Size')) { + $selectedRecords = @($Context.Annotations | Where-Object { + [string]$_.Id -eq [string]$Context.SelectedAnnotationId + } | Select-Object -First 1) + $selectedRecord = if($selectedRecords.Count -gt 0){ + $selectedRecords[0] + }else{$null} + $geometry = if ($null -ne $selectedRecord) { + $sourceGeometry=$selectedRecord.Geometry + if($sourceGeometry.Type -in @('Bounds','TextBounds','StepBounds')){ + $sourceGeometry + }elseif($sourceGeometry.Type -eq 'Line'){ + [pscustomobject]@{ + X=[math]::Min($sourceGeometry.Start.X,$sourceGeometry.End.X) + Y=[math]::Min($sourceGeometry.Start.Y,$sourceGeometry.End.Y) + Width=[math]::Max(1,[math]::Abs($sourceGeometry.End.X-$sourceGeometry.Start.X)) + Height=[math]::Max(1,[math]::Abs($sourceGeometry.End.Y-$sourceGeometry.Start.Y)) + } + }elseif($sourceGeometry.Type -eq 'Points' -and @($sourceGeometry.Points).Count -gt 0){ + $xs=@($sourceGeometry.Points|ForEach-Object X) + $ys=@($sourceGeometry.Points|ForEach-Object Y) + $minimumX=($xs|Measure-Object -Minimum).Minimum + $minimumY=($ys|Measure-Object -Minimum).Minimum + [pscustomobject]@{ + X=$minimumX;Y=$minimumY + Width=[math]::Max(1,($xs|Measure-Object -Maximum).Maximum-$minimumX) + Height=[math]::Max(1,($ys|Measure-Object -Maximum).Maximum-$minimumY) + } + }else{$null} + } else { $null } + $editor = [System.Windows.Controls.TextBox]::new() + $editor.Height=36; $editor.MinWidth=96 + $editor.Margin=[System.Windows.Thickness]::new(2,0,2,0) + $editor.Padding=[System.Windows.Thickness]::new(8,6,8,4) + $editor.Background=$Context.Resources['SnipCanvasBrush'] + $editor.Foreground=$Context.Resources['SnipPrimaryTextBrush'] + $editor.BorderBrush=$Context.Resources['SnipHairlineBrush'] + $editor.BorderThickness=[System.Windows.Thickness]::new(1) + $editor.Text = if ($null -eq $geometry) { '' } elseif ($propertyName -eq 'Position') { + "$([int]$geometry.X), $([int]$geometry.Y)" + } else { "$([int]$geometry.Width) × $([int]$geometry.Height)" } + $editor.Tag=[pscustomobject]@{ + Role='PropertyEditor';Name=$propertyName;OriginalText=$editor.Text + } + [System.Windows.Automation.AutomationProperties]::SetName( + $editor,"Selected annotation $propertyName") + $commitProperty={ + if($Context.ApplySelectionProperty){ + & $Context.ApplySelectionProperty $propertyName $editor.Text + } + $Context.EditingProperty=$false + $editor.Tag.OriginalText=$editor.Text + }.GetNewClosure() + $focusHandler={ + $Context.EditingProperty=$true + $editor.Tag.OriginalText=$editor.Text + }.GetNewClosure() + $lostFocusHandler={ + if($Context.EditingProperty){& $commitProperty} + }.GetNewClosure() + $editorKeyHandler={ + if($_.Key -eq [System.Windows.Input.Key]::Enter){ + & $commitProperty; $_.Handled=$true + }elseif($_.Key -eq [System.Windows.Input.Key]::Escape){ + $editor.Text=[string]$editor.Tag.OriginalText + $Context.EditingProperty=$false + $_.Handled=$true + } + }.GetNewClosure() + $editor.Add_GotKeyboardFocus($focusHandler) + $editor.Add_LostKeyboardFocus($lostFocusHandler) + $editor.Add_KeyDown($editorKeyHandler) + foreach($bindingSpec in @( + [pscustomobject]@{Event='GotKeyboardFocus';Handler=$focusHandler}, + [pscustomobject]@{Event='LostKeyboardFocus';Handler=$lostFocusHandler}, + [pscustomobject]@{Event='KeyDown';Handler=$editorKeyHandler})){ + $Context.PropertyBindings.Add([pscustomobject]@{ + Target=$editor;Event=$bindingSpec.Event;Handler=$bindingSpec.Handler + })|Out-Null + } + $Context.PropertyControls[$propertyName]=[pscustomobject][ordered]@{ + Name=$propertyName;Element=$editor;Button=$null + Menu=$null;MenuItems=$null;Controller=$null + } + $panel.Children.Add($editor)|Out-Null + continue + } + $button = [System.Windows.Controls.Button]::new() + $button.Height = 36 + $button.MinWidth = if ($mode -eq 'Narrow') { 68 } else { 76 } + $button.Margin = [System.Windows.Thickness]::new(2,0,2,0) + $button.Padding = [System.Windows.Thickness]::new(8,4,8,4) + $button.Content = switch ($propertyName) { + 'MoreProperties' { 'More properties' } + 'StartStyle' { 'Start' } + 'EndStyle' { 'End' } + 'StrokeColor' { 'Stroke' } + 'ResetSequence' { 'Reset' } + default { $propertyName } + } + [System.Windows.Automation.AutomationProperties]::SetName( + $button, [string]$button.Content) + $button.SetResourceReference( + [System.Windows.FrameworkElement]::StyleProperty, 'SnipButtonStyle') + if ($propertyName -eq 'MoreProperties') { + $propertyMenu = [System.Windows.Controls.ContextMenu]::new() + foreach ($overflowName in $overflow) { + $propertyItem = [System.Windows.Controls.MenuItem]::new() + $propertyItem.Header = switch ($overflowName) { + 'StartStyle' { 'Start' } + 'EndStyle' { 'End' } + 'StrokeColor' { 'Stroke' } + 'ResetSequence' { 'Reset' } + default { $overflowName } + } + [System.Windows.Automation.AutomationProperties]::SetName( + $propertyItem, [string]$propertyItem.Header) + $propertyMenu.Items.Add($propertyItem) | Out-Null + $overflowEntry = [pscustomobject][ordered]@{ + Name=$overflowName; Element=$propertyItem; Button=$null + Menu=$null; MenuItems=$null; Controller=$null + } + $Context.PropertyControls[$overflowName] = $overflowEntry + if (($Tool -eq 'Crop' -and $overflowName -in @('Reset','Apply')) -or + ($Tool -eq 'Select' -and + $overflowName -in @('Duplicate','Delete'))) { + $semanticName = $overflowName + $semanticHandler = { + switch ("$Tool/$semanticName") { + 'Crop/Reset' { if ($Context.ResetCrop) { & $Context.ResetCrop } } + 'Crop/Apply' { if ($Context.ApplyCrop) { & $Context.ApplyCrop } } + 'Select/Duplicate' { if ($Context.DuplicateSelection) { & $Context.DuplicateSelection } } + 'Select/Delete' { if ($Context.DeleteSelection) { & $Context.DeleteSelection } } + } + if ($null -ne $Context.PropertyMenuControl) { + & $Context.PropertyMenuControl.CloseOptions + } + }.GetNewClosure() + $propertyItem.Add_Click($semanticHandler) + $Context.PropertyBindings.Add([pscustomobject]@{ + Target=$propertyItem; Handler=$semanticHandler + }) | Out-Null + } + } + $Context.PropertyMenuControl = Connect-SnipPreviewMenuButton ` + -Button $button -Menu $propertyMenu -Context $Context -Name MoreProperties + $Context.PropertyControls.MoreProperties = [pscustomobject][ordered]@{ + Name='MoreProperties'; Element=$button; Button=$button + Menu=$propertyMenu; MenuItems=$null + Controller=$Context.PropertyMenuControl + } + } elseif ($Tool -eq 'Crop' -and $propertyName -eq 'Aspect') { + $aspectMenu = [System.Windows.Controls.ContextMenu]::new() + $aspectItems = [ordered]@{} + foreach ($presetName in @('Free','Original','1:1','4:3','16:9')) { + $presetItem = [System.Windows.Controls.MenuItem]::new() + $presetItem.Header = $presetName + $presetItem.IsCheckable = $true + $presetItem.IsChecked = $Context.ToolProperties.Crop.Preset -eq $presetName + [System.Windows.Automation.AutomationProperties]::SetName( + $presetItem, "Crop aspect $presetName") + $selectedPreset = $presetName + $presetHandler = { + if ($Context.SelectCropPreset) { + & $Context.SelectCropPreset $selectedPreset + } + if ($null -ne $Context.CropAspectMenuControl) { + & $Context.CropAspectMenuControl.CloseOptions + } + }.GetNewClosure() + $presetItem.Add_Click($presetHandler) + $Context.PropertyBindings.Add([pscustomobject]@{ + Target=$presetItem; Handler=$presetHandler + }) | Out-Null + $aspectMenu.Items.Add($presetItem) | Out-Null + $aspectItems[$presetName] = $presetItem + } + $Context.CropAspectMenuControl = Connect-SnipPreviewMenuButton ` + -Button $button -Menu $aspectMenu -Context $Context -Name CropAspect + $Context.PropertyControls.Aspect = [pscustomobject][ordered]@{ + Name='Aspect'; Element=$button; Button=$button + Menu=$aspectMenu; MenuItems=$aspectItems + Controller=$Context.CropAspectMenuControl + } + } else { + $Context.PropertyControls[$propertyName] = [pscustomobject][ordered]@{ + Name=$propertyName; Element=$button; Button=$button + Menu=$null; MenuItems=$null; Controller=$null + } + if (($Tool -eq 'Crop' -and $propertyName -in @('Reset','Apply')) -or + ($Tool -eq 'Select' -and + $propertyName -in @('Duplicate','Delete'))) { + $semanticName = $propertyName + $semanticHandler = { + switch ("$Tool/$semanticName") { + 'Crop/Reset' { if ($Context.ResetCrop) { & $Context.ResetCrop } } + 'Crop/Apply' { if ($Context.ApplyCrop) { & $Context.ApplyCrop } } + 'Select/Duplicate' { if ($Context.DuplicateSelection) { & $Context.DuplicateSelection } } + 'Select/Delete' { if ($Context.DeleteSelection) { & $Context.DeleteSelection } } + } + }.GetNewClosure() + $button.Add_Click($semanticHandler) + $Context.PropertyBindings.Add([pscustomobject]@{ + Target=$button; Handler=$semanticHandler + }) | Out-Null + } + } + $panel.Children.Add($button) | Out-Null + } + $Context.Shell.PropertyIsland.Width = if ($mode -eq 'Wide') { 470 } elseif ($mode -eq 'Compact') { 350 } else { 286 } + } + $Context.PropertyState +} + +function Set-SnipPreviewStatusPresentation { + [CmdletBinding()] + param([Parameter(Mandatory)] $Context) + + if ($null -eq $Context.Shell -or $null -eq $Context.Shell.StatusText) { return } + $mode = [string]$Context.ModeState.Value + $statusText = $Context.Shell.StatusText + $statusIsland = $Context.Shell.StatusIsland + if ($Context.StatusState.Kind -eq 'Idle') { + $statusText.Text = if ($mode -eq 'Narrow') { '●' } else { 'Non-destructive edit' } + $statusText.TextTrimming = [System.Windows.TextTrimming]::None + $statusText.ToolTip = $null + [System.Windows.Automation.AutomationProperties]::SetHelpText($statusText, '') + $statusIsland.Width = if ($mode -eq 'Narrow') { 42 } else { 150 } + $statusIsland.Padding = if ($mode -eq 'Narrow') { + [System.Windows.Thickness]::new(0) + } else { [System.Windows.Thickness]::new(10,0,10,0) } + return + } + + $fullText = [string]$Context.StatusState.Text + $desiredWidth = [math]::Max(150.0, 34.0 + $fullText.Length * 7.0) + $statusText.Text = $fullText + $statusText.ToolTip = $fullText + [System.Windows.Automation.AutomationProperties]::SetHelpText($statusText, $fullText) + $statusIsland.Padding = [System.Windows.Thickness]::new(10,0,10,0) + switch ($mode) { + 'Compact' { + # The centered tool dock reaches into the lower-right lane at 900 DIPs. + # Keep the status capsule at its collision-safe compact width and expose + # the complete message through ToolTip and UI Automation HelpText. + $statusIsland.Width = 150 + $statusText.TextTrimming = [System.Windows.TextTrimming]::CharacterEllipsis + } + 'Narrow' { + $windowWidth = if ($null -ne $Context.Window -and + $Context.Window.ActualWidth -gt 0) { + [double]$Context.Window.ActualWidth + } else { 760.0 } + $statusIsland.Width = [math]::Min($desiredWidth, [math]::Max(150.0, $windowWidth - 60.0)) + $statusText.TextTrimming = if ($statusIsland.Width -lt $desiredWidth) { + [System.Windows.TextTrimming]::CharacterEllipsis + } else { [System.Windows.TextTrimming]::None } + } + default { + $statusIsland.Width = $desiredWidth + $statusText.TextTrimming = [System.Windows.TextTrimming]::None + } + } +} + +function Set-SnipPreviewResponsiveMode { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Context, + [Parameter(Mandatory)] [double]$Width, + [Parameter(Mandatory)] [double]$Height + ) + + $mode = Get-PreviewResponsiveMode -Width $Width -Height $Height + $Context.ModeState.Value = $mode + $recent = [string[]]@($Context.RecentTools | Select-Object -First 2) + if ($recent.Count -lt 2) { $recent = [string[]]@('Highlight','ArrowLine') } + $order = switch ($mode) { + 'Wide' { [string[]]@('Select','Crop','Pen','Highlight','ArrowLine','RectangleEllipse','Text','Steps','BlurPixelate','Color','Undo','Redo') } + 'Compact' { [string[]]@('Select','Crop','Pen',$recent[0],$recent[1],'Text','BlurPixelate','Color','More','Undo','Redo') } + default { [string[]]@('Select','Crop','Pen','Text','BlurPixelate','More','Undo','Redo') } + } + $Context.ToolOrder.Clear() + foreach ($name in $order) { $Context.ToolOrder.Add($name) | Out-Null } + $panel = $Context.Shell.ToolPanel + $panel.Children.Clear() + foreach ($name in $order) { + $control = $Context.ToolControls[$name] + if ($null -ne $control) { + $control.Margin = if ($mode -eq 'Wide') { + [System.Windows.Thickness]::new(2,0,2,0) + } else { [System.Windows.Thickness]::new(0) } + $panel.Children.Add($control) | Out-Null + } + } + + $activeTool = if (-not [string]::IsNullOrWhiteSpace([string]$Context.ActiveTool)) { + [string]$Context.ActiveTool + } else { 'Select' } + $visible = @($order) -contains $activeTool + $Context.MoreState.IsActive = -not $visible -and $activeTool -notin @('Select','') + $Context.MoreState.Tool = if ($Context.MoreState.IsActive) { $activeTool } else { $null } + $activeMetadata = if ($Context.MoreState.IsActive -and + $null -ne $Context.ToolMetadata[$activeTool]) { + $Context.ToolMetadata[$activeTool] + } else { $null } + $Context.MoreState.Name = if ($null -ne $activeMetadata) { + $activeMetadata.DisplayName + } else { 'More' } + $Context.MoreState.Icon = if ($null -ne $activeMetadata) { + $activeMetadata.Icon + } else { '…' } + if ($null -ne $Context.Shell.MoreButton) { + $Context.Shell.MoreName.Text = $Context.MoreState.Name + $Context.Shell.MoreIcon.Text = $Context.MoreState.Icon + $Context.Shell.MoreIndicator.Visibility = if ($Context.MoreState.IsActive) { + [System.Windows.Visibility]::Visible + } else { [System.Windows.Visibility]::Collapsed } + if ($Context.MoreState.IsActive) { + # Remove the previous fixed constraint for one canonical WPF measure; + # otherwise a substituted label inherits the stale 48-DIP slot. + $Context.Shell.MoreButton.Width = [double]::NaN + $Context.Shell.MoreButton.InvalidateMeasure() + $Context.Shell.MoreButton.Measure([System.Windows.Size]::new( + [double]::PositiveInfinity, [double]::PositiveInfinity)) + $Context.Shell.MoreButton.Width = [math]::Max( + 48.0, [math]::Ceiling($Context.Shell.MoreButton.DesiredSize.Width)) + } else { + $Context.Shell.MoreButton.Width = 48 + } + $Context.Shell.MoreButton.BorderBrush = if ($Context.MoreState.IsActive) { + $Context.Resources['SnipAccentBrush'] + } else { $Context.Resources['SnipHairlineBrush'] } + $Context.Shell.MoreButton.Background = if ($Context.MoreState.IsActive) { + $Context.Resources['SnipAccentTintBrush'] + } else { [System.Windows.Media.Brushes]::Transparent } + [System.Windows.Automation.AutomationProperties]::SetName( + $Context.Shell.MoreButton, $Context.MoreState.Name) + } + if ($null -ne $Context.Shell.PSObject.Properties['MoreMenuItems']) { + foreach ($secondaryTool in @('Highlight','ArrowLine','RectangleEllipse','Steps','Color')) { + $Context.Shell.MoreMenuItems[$secondaryTool].Visibility = if (@($order) -contains $secondaryTool) { + [System.Windows.Visibility]::Collapsed + } else { [System.Windows.Visibility]::Visible } + } + } + + $Context.Shell.ToolDock.Margin = if ($mode -eq 'Narrow') { + [System.Windows.Thickness]::new(0,0,0,96) + } else { [System.Windows.Thickness]::new(0,0,0,35) } + $Context.Shell.PropertyIsland.Margin = if ($mode -eq 'Narrow') { + [System.Windows.Thickness]::new(0,0,0,155) + } else { [System.Windows.Thickness]::new(0,0,0,94) } + $Context.Shell.CoordinateText.Visibility = if ($mode -eq 'Narrow') { + [System.Windows.Visibility]::Collapsed + } else { [System.Windows.Visibility]::Visible } + $Context.Shell.ZoomOutButton.Visibility = if ($mode -eq 'Narrow') { + [System.Windows.Visibility]::Collapsed + } else { [System.Windows.Visibility]::Visible } + $Context.Shell.ZoomInButton.Visibility = if ($mode -eq 'Narrow') { + [System.Windows.Visibility]::Collapsed + } else { [System.Windows.Visibility]::Visible } + Set-SnipPreviewStatusPresentation -Context $Context + Set-SnipPropertyIsland -Context $Context -Tool $Context.ActivePropertyTool | Out-Null + $mode +} + +function Initialize-SnipPreviewAnnotations { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Collections.IList]$Annotations + ) + + for ($index = 0; $index -lt $Annotations.Count; $index++) { + $annotation = $Annotations[$index] + $kind = Get-SnipRecordValue -InputObject $annotation -Name Kind + $geometry = Get-SnipRecordValue -InputObject $annotation -Name Geometry + $id = [string](Get-SnipRecordValue -InputObject $annotation -Name Id) + if (-not [string]::IsNullOrWhiteSpace([string]$kind) -and + $null -ne $geometry -and + -not [string]::IsNullOrWhiteSpace($id)) { + continue + } + $Annotations[$index] = Copy-SnipAnnotation -Annotation $annotation + } + $Annotations +} + +function New-SnipPreviewWindow { + [CmdletBinding()] + param([Parameter(Mandatory)] $Context) + + Initialize-SnipPreviewAnnotations -Annotations $Context.Annotations | Out-Null + + [xml]$xaml = [xml](Get-SnipXamlText -Name 'PreviewWindow') + $reader = [System.Xml.XmlNodeReader]::new($xaml) + $window = [System.Windows.Markup.XamlReader]::Load($reader) + Add-SnipThemeResources -Root $window -Resources $Context.Resources | Out-Null + $window.Left = [double]$Context.InitialBounds.X + $window.Top = [double]$Context.InitialBounds.Y + $window.Width = [double]$Context.InitialBounds.Width + $window.Height = [double]$Context.InitialBounds.Height + $placementState = $Context.PlacementState + $setWindowPosition = $Context.SetWindowPosition + $initialPhysicalBounds = $Context.InitialPhysicalBounds + $placementHelper = [System.Windows.Interop.WindowInteropHelper]::new($window) + $sourceInitializedHandler = { + if ($placementHelper.Handle -ne [IntPtr]::Zero) { + $placementState.IsApplied = [bool](& $setWindowPosition ` + $placementHelper.Handle $initialPhysicalBounds) + } + }.GetNewClosure() + $closedPlacementHandler = $null + $closedPlacementHandler = { + $window.Remove_SourceInitialized($sourceInitializedHandler) + $placementState.HandlersAttached = $false + }.GetNewClosure() + $window.Add_SourceInitialized($sourceInitializedHandler) + $window.Add_Closed($closedPlacementHandler) + $placementState.HandlersAttached = $true + $chrome = [System.Windows.Shell.WindowChrome]::new() + $chrome.CaptionHeight = 0 + $chrome.ResizeBorderThickness = [System.Windows.Thickness]::new(6) + $chrome.GlassFrameThickness = [System.Windows.Thickness]::new(0) + $chrome.CornerRadius = [System.Windows.CornerRadius]::new(0) + $chrome.UseAeroCaptionButtons = $false + [System.Windows.Shell.WindowChrome]::SetWindowChrome($window, $chrome) + $Context.Window = $window + $Context.Chrome = $chrome + + $actionsPanel = $window.FindName('ActionsPanel') + $toolPanel = $window.FindName('ToolPanel') + $newButton = { + param([string]$Name,[string]$Text,[string]$AutomationName,[double]$Width=38) + $button = [System.Windows.Controls.Button]::new() + $button.Name = $Name; $button.Content = $Text; $button.Width = $Width; $button.Height = 38 + $button.Margin = [System.Windows.Thickness]::new(2,0,2,0) + $button.Padding = [System.Windows.Thickness]::new(6,0,6,0) + $button.SetResourceReference([System.Windows.FrameworkElement]::StyleProperty, 'SnipButtonStyle') + [System.Windows.Automation.AutomationProperties]::SetName($button, $AutomationName) + $window.RegisterName($Name, $button) + $button + }.GetNewClosure() + $newToggle = { + param([string]$Name,[string]$Text,[string]$AutomationName) + $button = [System.Windows.Controls.Primitives.ToggleButton]::new() + $button.Name = $Name; $button.Content = $Text + $button.SetResourceReference([System.Windows.FrameworkElement]::StyleProperty, 'StudioToolToggleStyle') + [System.Windows.Automation.AutomationProperties]::SetName($button, $AutomationName) + $window.RegisterName($Name, $button) + $button + }.GetNewClosure() + + $pin = & $newToggle 'PinBtn' ([char]0xE718) 'Pin window on top' + $save = & $newButton 'SaveBtn' 'Save' 'Save snip' 62 + $pin.ToolTip = 'Keep preview on top' + $save.ToolTip = 'Save (Ctrl+S)' + $copyOptions = [ordered]@{ CopyKeepOpen = {} } + $copy = New-SnipSplitControl -Name CopyAndClose -DefaultCommand CopyAndClose ` + -PrimaryText 'Copy & close' -PrimaryAutomationName 'Copy and close' ` + -OptionsAutomationName 'Copy options' -Options $copyOptions -Context $Context ` + -Glyph ([char]0xE8C8) -PrimaryWidth 122 -OptionsWidth 28 + $copy.PrimaryButton.SetResourceReference( + [System.Windows.FrameworkElement]::StyleProperty, 'SnipPrimaryButtonStyle') + $copy.Root.Margin = [System.Windows.Thickness]::new(2,0,2,0) + $copy.PrimaryButton.Name = 'CopyBtn' + $window.RegisterName('CopyBtn', $copy.PrimaryButton) + $close = & $newButton 'CloseBtn' ([char]0xE711) 'Close preview' 38 + $closeStyle = [System.Windows.Style]::new([System.Windows.Controls.Button]) + $closeStyle.BasedOn = $Context.Resources['SnipButtonStyle'] + $closeStyle.Setters.Add([System.Windows.Setter]::new( + [System.Windows.Controls.Control]::BackgroundProperty, + [System.Windows.Media.Brushes]::Transparent)) + foreach ($triggerProperty in @( + [System.Windows.UIElement]::IsMouseOverProperty, + [System.Windows.UIElement]::IsKeyboardFocusedProperty)) { + $trigger = [System.Windows.Trigger]::new() + $trigger.Property = $triggerProperty + $trigger.Value = $true + $trigger.Setters.Add([System.Windows.Setter]::new( + [System.Windows.Controls.Control]::BackgroundProperty, + $Context.Resources['SnipCoralTintBrush'])) + $trigger.Setters.Add([System.Windows.Setter]::new( + [System.Windows.Controls.Control]::ForegroundProperty, + $Context.Resources['SnipAccentTextBrush'])) + $closeStyle.Triggers.Add($trigger) + } + $close.Style = $closeStyle + $close.ToolTip = 'Close preview (Alt+F4)' + $pin.FontFamily = [System.Windows.Media.FontFamily]::new('Segoe Fluent Icons') + $close.FontFamily = [System.Windows.Media.FontFamily]::new('Segoe Fluent Icons') + foreach ($control in @($pin,$save,$copy.Root,$close)) { $actionsPanel.Children.Add($control) | Out-Null } + + $select = & $newButton 'SelectToolBtn' '↖' 'Select tool' + $crop = & $newButton 'CropToolBtn' '⌗' 'Crop tool' + $pen = & $newButton 'PenToolBtn' '✎' 'Pen tool' + $highlight = & $newToggle 'HighlightBtn' '▰' 'Highlight tool' + $text = & $newToggle 'TextBtn' 'T' 'Text tool' + $steps = & $newButton 'StepsToolBtn' '①' 'Numbered steps tool' + $color = & $newButton 'ColorToolBtn' '●' 'Annotation color' + $colorMenu = [System.Windows.Controls.ContextMenu]::new() + $colorMenuItems = [ordered]@{} + foreach ($colorName in @('Yellow','Green','Pink','Blue','Orange','Red')) { + $colorItem = [System.Windows.Controls.MenuItem]::new() + $colorItem.Header = $colorName + [System.Windows.Automation.AutomationProperties]::SetName($colorItem, $colorName) + $colorMenu.Items.Add($colorItem) | Out-Null + $colorMenuItems[$colorName] = $colorItem + } + $colorMenuControl = Connect-SnipPreviewMenuButton ` + -Button $color -Menu $colorMenu -Context $Context -Name Color + $undo = & $newButton 'UndoBtn' '↶' 'Undo' + $redo = & $newButton 'RedoBtn' '↷' 'Redo' + $undo.ToolTip = 'Undo (Ctrl+Z)' + $redo.ToolTip = 'Redo (Ctrl+Shift+Z)' + $more = & $newButton 'MoreBtn' 'More' 'More tools' 48 + $more.Width = 48 + $moreContent = [System.Windows.Controls.StackPanel]::new() + $moreContent.Orientation = [System.Windows.Controls.Orientation]::Horizontal + $moreContent.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center + $moreIcon = [System.Windows.Controls.TextBlock]::new() + $moreIcon.Name = 'MoreIconText'; $moreIcon.Text = '…' + $moreIcon.Margin = [System.Windows.Thickness]::new(0,0,4,0) + $moreName = [System.Windows.Controls.TextBlock]::new() + $moreName.Name = 'MoreNameText'; $moreName.Text = 'More' + $moreIndicator = [System.Windows.Controls.Border]::new() + $moreIndicator.Name = 'MoreActiveIndicator' + $moreIndicator.Width = 12; $moreIndicator.Height = 2 + $moreIndicator.CornerRadius = [System.Windows.CornerRadius]::new(1) + $moreIndicator.Margin = [System.Windows.Thickness]::new(5,0,0,0) + $moreIndicator.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + $moreIndicator.Background = $Context.Resources['SnipAccentBrush'] + $moreIndicator.Visibility = [System.Windows.Visibility]::Collapsed + $moreContent.Children.Add($moreIcon) | Out-Null + $moreContent.Children.Add($moreName) | Out-Null + $moreContent.Children.Add($moreIndicator) | Out-Null + $more.Content = $moreContent + $window.RegisterName('MoreIconText', $moreIcon) + $window.RegisterName('MoreNameText', $moreName) + $window.RegisterName('MoreActiveIndicator', $moreIndicator) + + $arrow = New-SnipSplitControl -Name ArrowLine -DefaultCommand Arrow ` + -PrimaryText '↗' -PrimaryAutomationName 'Arrow tool, selected subtype Arrow' ` + -OptionsAutomationName 'Arrow or Line options' -Options ([ordered]@{ Arrow={}; Line={} }) ` + -Context $Context -PrimaryWidth 38 -OptionsWidth 22 + $rectangle = New-SnipSplitControl -Name RectangleEllipse -DefaultCommand Rectangle ` + -PrimaryText '□' -PrimaryAutomationName 'Rectangle tool, selected subtype Rectangle' ` + -OptionsAutomationName 'Rectangle or Ellipse options' -Options ([ordered]@{ Rectangle={}; Ellipse={} }) ` + -Context $Context -PrimaryWidth 38 -OptionsWidth 22 + $privacy = New-SnipSplitControl -Name BlurPixelate -DefaultCommand Blur ` + -PrimaryText '▒' -PrimaryAutomationName 'Blur tool, selected subtype Blur' ` + -OptionsAutomationName 'Blur or Pixelate options' -Options ([ordered]@{ Blur={}; Pixelate={} }) ` + -Context $Context -PrimaryWidth 38 -OptionsWidth 22 + $Context.SplitControls.Copy = $copy + $Context.SplitControls.ArrowLine = $arrow + $Context.SplitControls.RectangleEllipse = $rectangle + $Context.SplitControls.BlurPixelate = $privacy + $Context.ToolControls = [ordered]@{ + Select=$select; Crop=$crop; Pen=$pen; Highlight=$highlight; ArrowLine=$arrow.Root + RectangleEllipse=$rectangle.Root; Text=$text; Steps=$steps; BlurPixelate=$privacy.Root + Color=$color; More=$more; Undo=$undo; Redo=$redo + } + $select.Background = $Context.Resources['SnipAccentTintBrush'] + $select.BorderBrush = $Context.Resources['SnipAccentBrush'] + $moreMenu = [System.Windows.Controls.ContextMenu]::new() + $moreMenu.Background = $Context.Resources['SnipCanvasBrush'] + $moreMenu.Foreground = $Context.Resources['SnipPrimaryTextBrush'] + $moreMenu.BorderBrush = $Context.Resources['SnipHairlineBrush'] + $moreMenu.BorderThickness = [System.Windows.Thickness]::new(1) + $moreMenuItems = [ordered]@{} + $moreColorMenuItems = [ordered]@{} + foreach ($toolSpec in @( + @('Highlight','Highlight'), @('ArrowLine','Arrow/Line'), + @('RectangleEllipse','Rectangle/Ellipse'), @('Steps','Steps'), @('Color','Color'))) { + $item = [System.Windows.Controls.MenuItem]::new() + $item.Header = $toolSpec[1] + [System.Windows.Automation.AutomationProperties]::SetName($item, $toolSpec[1]) + if ($toolSpec[0] -eq 'Color') { + foreach ($colorName in @('Yellow','Green','Pink','Blue','Orange','Red')) { + $colorItem = [System.Windows.Controls.MenuItem]::new() + $colorItem.Header = $colorName + [System.Windows.Automation.AutomationProperties]::SetName( + $colorItem, $colorName) + $item.Items.Add($colorItem) | Out-Null + $moreColorMenuItems[$colorName] = $colorItem + } + } + $moreMenu.Items.Add($item) | Out-Null + $moreMenuItems[$toolSpec[0]] = $item + } + Set-SnipPreviewMenuStyle -Menu $moreMenu -Context $Context | Out-Null + $moreMenu.PlacementTarget = $more + $more.ContextMenu = $moreMenu + [System.Windows.Automation.AutomationProperties]::SetItemStatus($more, 'Collapsed') + $moreMenuState = [pscustomobject]@{ + IsExpanded=$false; MenuHandle=[IntPtr]::Zero; IsMenuWindowRegistered=$false + HandlersAttached=$false + } + $moreOpen = { + $moreMenu.PlacementTarget = $more + $moreMenu.IsOpen = $true + }.GetNewClosure() + $moreClose = { + if ($null -ne $moreMenu.Tag -and $null -ne $moreMenu.Tag.CloseNestedPopups) { + & $moreMenu.Tag.CloseNestedPopups + } + if ($moreMenu.IsOpen) { $moreMenu.IsOpen = $false } + if ($moreMenuState.IsMenuWindowRegistered -and + $moreMenuState.MenuHandle -ne [IntPtr]::Zero) { + & $Context.UnregisterWindow $moreMenuState.MenuHandle + } + $moreMenuState.IsExpanded = $false + $moreMenuState.MenuHandle = [IntPtr]::Zero + $moreMenuState.IsMenuWindowRegistered = $false + [System.Windows.Automation.AutomationProperties]::SetItemStatus($more, 'Collapsed') + try { $more.Focus() | Out-Null } catch {} + }.GetNewClosure() + $moreOpenedHandler = { + $moreMenuState.IsExpanded = $true + [System.Windows.Automation.AutomationProperties]::SetItemStatus($more, 'Expanded') + $source = [System.Windows.PresentationSource]::FromVisual($moreMenu) + if ($source -is [System.Windows.Interop.HwndSource] -and + $source.Handle -ne [IntPtr]::Zero -and + -not $moreMenuState.IsMenuWindowRegistered) { + & $Context.RegisterWindow $source.Handle + $moreMenuState.MenuHandle = $source.Handle + $moreMenuState.IsMenuWindowRegistered = $true + } + }.GetNewClosure() + $moreClosedHandler = { & $moreClose }.GetNewClosure() + $moreClickHandler = { & $moreOpen }.GetNewClosure() + $moreMenu.Add_Opened($moreOpenedHandler) + $moreMenu.Add_Closed($moreClosedHandler) + $more.Add_Click($moreClickHandler) + $moreKeyHandler = { + $modifiers = & $Context.GetKeyboardModifiers + if (($modifiers -band [System.Windows.Input.ModifierKeys]::Alt) -ne 0 -and + $_.Key -eq [System.Windows.Input.Key]::Down) { + & $moreOpen + $_.Handled = $true + } elseif ($_.Key -eq [System.Windows.Input.Key]::Escape -and + $moreMenuState.IsExpanded) { + & $moreClose + $_.Handled = $true + } + }.GetNewClosure() + $more.Add_PreviewKeyDown($moreKeyHandler) + $moreCleanupHandler = { + & $moreClose + $moreMenu.Remove_Opened($moreOpenedHandler) + $moreMenu.Remove_Closed($moreClosedHandler) + $more.Remove_Click($moreClickHandler) + $more.Remove_PreviewKeyDown($moreKeyHandler) + if ($null -ne $moreMenu.Tag -and $null -ne $moreMenu.Tag.Disconnect) { + & $moreMenu.Tag.Disconnect + } + $window.Remove_Closed($moreCleanupHandler) + $moreMenuState.HandlersAttached = $false + }.GetNewClosure() + $window.Add_Closed($moreCleanupHandler) + $moreMenuState.HandlersAttached = $true + $moreTransient = [pscustomobject]@{ + Name='More'; State=$moreMenuState; Menu=$moreMenu + OpenOptions=$moreOpen; CloseOptions=$moreClose + } + $Context.TransientMenus.Add($moreTransient) | Out-Null + $Context.Shell = [pscustomobject][ordered]@{ + Window=$window; StudioRoot=$window.FindName('StudioRoot'); Scroller=$window.FindName('Scroller') + ImageHost=$window.FindName('ImageHost'); PreviewImage=$window.FindName('PreviewImage') + AnnotationLayer=$window.FindName('AnnotationLayer') + InteractionLayer=$window.FindName('InteractionLayer') + SelectionLayer=$window.FindName('SelectionLayer') + HighlightLayer=$window.FindName('HighlightLayer'); BrandIsland=$window.FindName('BrandIsland') + ActionsIsland=$window.FindName('ActionsIsland'); PropertyIsland=$window.FindName('PropertyIsland') + ToolDock=$window.FindName('ToolDock'); ViewportIsland=$window.FindName('ViewportIsland') + StatusIsland=$window.FindName('StatusIsland'); ToolPanel=$toolPanel + PropertyPanel=$window.FindName('PropertyPanel'); MoreButton=$more + MoreMenu=$moreMenu; MoreMenuItems=$moreMenuItems; MoreMenuState=$moreMenuState + MoreColorMenuItems=$moreColorMenuItems; CloseMoreMenu=$moreClose + MoreIcon=$moreIcon; MoreName=$moreName; MoreIndicator=$moreIndicator + ColorMenu=$colorMenu; ColorMenuItems=$colorMenuItems; ColorMenuControl=$colorMenuControl + CoordinateText=$window.FindName('CoordinateText'); ZoomOutButton=$window.FindName('ZoomOutBtn') + ZoomInButton=$window.FindName('ZoomInBtn'); StatusText=$window.FindName('StatusText') + } + $closeMenus = { + foreach ($splitControl in @($Context.TransientMenus)) { + if ($null -ne $splitControl -and $null -ne $splitControl.CloseOptions) { + & $splitControl.CloseOptions + } + } + }.GetNewClosure() + $resolveCommand = { + param([string]$FocusedRole,[hashtable]$EditorState,[string]$Key,$Modifiers) + Resolve-PreviewKeyCommand -FocusedRole $FocusedRole -EditorState $EditorState ` + -Key $Key -Modifiers @($Modifiers) + }.GetNewClosure() + $clearStatus = { + $Context.StatusState.Kind = 'Idle' + $Context.StatusState.Text = '' + Set-SnipPreviewStatusPresentation -Context $Context + }.GetNewClosure() + $setStatus = { + param([string]$Text, [string]$Kind = 'Info') + if ([string]::IsNullOrWhiteSpace($Text)) { + & $clearStatus + return + } + $Context.StatusState.Kind = $Kind + $Context.StatusState.Text = $Text + Set-SnipPreviewStatusPresentation -Context $Context + }.GetNewClosure() + $Context.ClearStatus = $clearStatus + $Context.SetStatus = $setStatus + + $defaultSystemMenuAction = { + $point = $window.PointToScreen([System.Windows.Point]::new(0, 0)) + [System.Windows.SystemCommands]::ShowSystemMenu($window, $point) + }.GetNewClosure() + $closePreviewAction = { $window.Close() }.GetNewClosure() + $commandRouter = [pscustomobject][ordered]@{ + Resolve = $resolveCommand + CloseTransientMenus = $closeMenus + SupportsNativeSystemMenu = $true + LastCommand = $null + ResolveCount = 0 + SystemMenuAction = $defaultSystemMenuAction + CloseAction = $closePreviewAction + } + $Context.CommandRouter = $commandRouter + Set-SnipPreviewResponsiveMode -Context $Context -Width $window.Width -Height $window.Height | Out-Null + $Context.Shell +} + +function Show-PreviewWindow { + [CmdletBinding()] + param( + [System.Drawing.Bitmap]$Bitmap, + # Harness hook. When a scriptblock is provided, Show-PreviewWindow + # still calls ShowDialog() (so its local scope stays alive and the + # event handlers keep working), but on the Loaded event it invokes + # $TestAction with a hashtable of handles and then closes the window. + # The window is positioned off-screen for a headless feel. + [scriptblock]$TestAction, + [scriptblock]$OnOwnershipAccepted, + [scriptblock]$OnSurfaceReady, + [scriptblock]$OnNewSnip, + [scriptblock]$OnOutputStarting, + [scriptblock]$OnOutputCompleted + ) + + $previewLifecycle = [pscustomobject]@{ + Result = 'UserCancelled' + CleanupInstalled = $false + OwnershipAccepted = $false + BitmapDisposed = $false + } + $src = Convert-BitmapToBitmapSource $Bitmap + + # The legacy editor below remains the annotation engine; the Floating + # Studio owns only construction, layout, chrome, and command surfaces. + # Keeping this adapter at the old construction seam preserves every named + # closure and the public Show-PreviewWindow signature. + $resources = New-SnipThemeResources ` + -HighContrast ([bool][System.Windows.SystemParameters]::HighContrast) + $previewContext = New-SnipPreviewContext -Bitmap $Bitmap -BitmapSource $src ` + -Resources $resources + $studioShell = New-SnipPreviewWindow -Context $previewContext + $script:ActivePreviewContext = $previewContext + $win = $studioShell.Window + $previewImage = $win.FindName('PreviewImage') + $previewImage.Source = $previewContext.BitmapSource + $win.FindName('DimText').Text = "$($Bitmap.Width) × $($Bitmap.Height) px" + + # WindowChrome retains the native resize/snap contract while the one + # full-window StudioRoot paints the neutral-black client surface. + + # Surface ANY WPF dispatcher exception. Copy to clipboard AND write to + # %LOCALAPPDATA%\SnipIT\last-error.txt — a plain MessageBox doesn't always + # let you select text on Win11. + $win.Dispatcher.add_UnhandledException({ + param($sender, $e) + $ex = $e.Exception + $msg = "$($ex.GetType().FullName)`n$($ex.Message)`n`n$($ex.StackTrace)" + try { + $logFile = Join-Path $script:AppHomeDir 'last-error.txt' + Set-Content -LiteralPath $logFile -Value $msg -Encoding UTF8 + } catch {} + try { [System.Windows.Clipboard]::SetText($msg) } catch {} + try { + [System.Windows.Forms.MessageBox]::Show( + "$msg`n`n--- ALSO COPIED TO CLIPBOARD ---`nFile: $logFile", + 'SnipIT preview error (text on clipboard)', 'OK', 'Error') | Out-Null + } catch {} + $e.Handled = $true + }) + + $highlightLayer = $win.FindName('HighlightLayer') + $annotationLayer = $win.FindName('AnnotationLayer') + $interactionLayer = $win.FindName('InteractionLayer') + $selectionLayer = $win.FindName('SelectionLayer') + $highlightBtn = $win.FindName('HighlightBtn') + $rectBtn = $win.FindName('RectBtn') + $arrowBtn = $win.FindName('ArrowBtn') + $textBtn = $win.FindName('TextBtn') + $imageHost = $win.FindName('ImageHost') + $colorBar = $win.FindName('ColorBar') + $scroller = $win.FindName('Scroller') + $zoomText = $win.FindName('ZoomText') + # Create the shared transform before any gesture closure captures it. + # Selection/crop hit tolerances and handle sizes must observe the live + # object rather than a pre-initialization $null captured by GetNewClosure(). + $layoutScale = [System.Windows.Media.ScaleTransform]::new(1,1) + $imageHost.LayoutTransform = $layoutScale + + # Color palette: name → highlight (low alpha) and text (full alpha) variants + # Each entry: HiR/HiG/HiB used for highlights @ alpha 110, text @ alpha 255. + $palette = [ordered]@{ + Yellow = @{ R=255; G=222; B=0 } + Green = @{ R=70; G=210; B=110 } + Pink = @{ R=255; G=90; B=180 } + Blue = @{ R=80; G=170; B=255 } + Orange = @{ R=255; G=150; B=40 } + Red = @{ R=255; G=60; B=60 } + } + + $state = [pscustomobject]@{ + Annotations = $previewContext.Annotations + UndoStack = $previewContext.UndoStack + RedoStack = $previewContext.RedoStack + SelectionId = $previewContext.SelectedAnnotationId + CropRectangle = $previewContext.CropRectangle + Draft = $previewContext.Draft + ActiveColor = 'Yellow' + Drawing = $false + DrawingTool = $null + AnchorCanvas = $null + DraftRect = $null + EditingText = $false + Zoom = 1.0 + # Pan (Hand) mode: active when no annotation tool is checked. + Panning = $false + TemporaryPan = $false + PanStartSv = $null # mouse position at pan-begin, in Scroller-local coords + PanOrigX = 0.0 # Scroller.HorizontalOffset at pan-begin + PanOrigY = 0.0 # Scroller.VerticalOffset at pan-begin + ActiveStudioTool = $previewContext.ActiveTool + } + $previewContext.EditorState = $state + + function script:Get-DisplayedImageBounds { + # Canvas coordinates are in natural image-pixel space — the + # LayoutTransform only affects rendering, not local coords. Image + # and Canvas are both sized to Bitmap.Width x Bitmap.Height. + [pscustomobject]@{ + X = 0 + Y = 0 + W = $Bitmap.Width + H = $Bitmap.Height + Scale = 1.0 + } + } + + function script:To-WpfColor { + param([int]$A, [int]$R, [int]$G, [int]$B) + [System.Windows.Media.Color]::FromArgb($A, $R, $G, $B) + } + + function script:Get-PreviewAnnotationBounds { + param($Annotation) + if ($null -eq $Annotation -or $null -eq $Annotation.Geometry) { return $null } + $geometry = $Annotation.Geometry + switch ([string]$geometry.Type) { + { $_ -in @('Bounds','TextBounds','StepBounds') } { + return [pscustomobject]@{ + X=[double]$geometry.X; Y=[double]$geometry.Y + Width=[double]$geometry.Width; Height=[double]$geometry.Height + } + } + 'Line' { + $left = [math]::Min([double]$geometry.Start.X,[double]$geometry.End.X) + $top = [math]::Min([double]$geometry.Start.Y,[double]$geometry.End.Y) + return [pscustomobject]@{ + X=$left; Y=$top + Width=[math]::Max(1.0,[math]::Abs([double]$geometry.End.X-[double]$geometry.Start.X)) + Height=[math]::Max(1.0,[math]::Abs([double]$geometry.End.Y-[double]$geometry.Start.Y)) + } + } + 'Points' { + $points = @($geometry.Points) + if ($points.Count -eq 0) { return $null } + $xs = @($points | ForEach-Object { [double]$_.X }) + $ys = @($points | ForEach-Object { [double]$_.Y }) + $left = [double](($xs | Measure-Object -Minimum).Minimum) + $top = [double](($ys | Measure-Object -Minimum).Minimum) + return [pscustomobject]@{ + X=$left; Y=$top + Width=[math]::Max(1.0,[double](($xs | Measure-Object -Maximum).Maximum)-$left) + Height=[math]::Max(1.0,[double](($ys | Measure-Object -Maximum).Maximum)-$top) + } + } + } + $null + } + + function script:Get-PreviewAnnotationById { + param([AllowNull()][string]$Id) + if ([string]::IsNullOrWhiteSpace($Id)) { return $null } + $matches=@($previewContext.Annotations | Where-Object { + [string]$_.Id -eq $Id + } | Select-Object -First 1) + if($matches.Count -gt 0){return $matches[0]} + $null + } + + function script:Get-PreviewAnnotationIndexById { + param([AllowNull()][string]$Id) + if ([string]::IsNullOrWhiteSpace($Id)) { return -1 } + for ($index = 0; $index -lt $previewContext.Annotations.Count; $index++) { + if ([string]$previewContext.Annotations[$index].Id -eq $Id) { return $index } + } + -1 + } + + function script:Test-PreviewAnnotationEqual { + param($Left,$Right) + if ($null -eq $Left -or $null -eq $Right) { return $null -eq $Left -and $null -eq $Right } + (ConvertTo-Json $Left -Depth 32 -Compress) -ceq + (ConvertTo-Json $Right -Depth 32 -Compress) + } + + function script:Test-PreviewCropEqual { + param($Left,$Right) + if ($null -eq $Left -or $null -eq $Right) { return $null -eq $Left -and $null -eq $Right } + [int]$Left.X -eq [int]$Right.X -and [int]$Left.Y -eq [int]$Right.Y -and + [int]$Left.Width -eq [int]$Right.Width -and + [int]$Left.Height -eq [int]$Right.Height + } + + function script:Get-PreviewWpfBrush { + param([string]$Color,[int]$Alpha=255) + $rgb = $palette[$Color] + if ($null -eq $rgb) { return $resources['SnipPrimaryTextBrush'] } + [System.Windows.Media.SolidColorBrush]::new( + (To-WpfColor $Alpha $rgb.R $rgb.G $rgb.B)) + } + + function script:Add-PreviewHandleSet { + param( + [Parameter(Mandatory)] [System.Windows.Controls.Canvas]$Layer, + [Parameter(Mandatory)] $Bounds, + [Parameter(Mandatory)] [string]$Role, + [string[]]$Handles = @('TopLeft','Top','TopRight','Right','BottomRight','Bottom','BottomLeft','Left') + ) + $zoom = if ($null -ne $layoutScale -and $layoutScale.ScaleX -gt 0) { + [double]$layoutScale.ScaleX + } else { 1.0 } + $diameter = 10.0 / $zoom + $half = $diameter / 2.0 + $centers = @{ + TopLeft=@(([double]$Bounds.X),([double]$Bounds.Y)) + Top=@(([double]$Bounds.X + [double]$Bounds.Width/2.0),([double]$Bounds.Y)) + TopRight=@(([double]$Bounds.X + [double]$Bounds.Width),([double]$Bounds.Y)) + Right=@(([double]$Bounds.X + [double]$Bounds.Width),([double]$Bounds.Y + [double]$Bounds.Height/2.0)) + BottomRight=@(([double]$Bounds.X + [double]$Bounds.Width),([double]$Bounds.Y + [double]$Bounds.Height)) + Bottom=@(([double]$Bounds.X + [double]$Bounds.Width/2.0),([double]$Bounds.Y + [double]$Bounds.Height)) + BottomLeft=@(([double]$Bounds.X),([double]$Bounds.Y + [double]$Bounds.Height)) + Left=@(([double]$Bounds.X),([double]$Bounds.Y + [double]$Bounds.Height/2.0)) + } + foreach ($handleName in $Handles) { + $handle = [System.Windows.Shapes.Ellipse]::new() + $handle.Width = $diameter; $handle.Height = $diameter + $handle.Fill = $resources['SnipMintBrush'] + $handle.Stroke = $resources['SnipCanvasBrush'] + $handle.StrokeThickness = 1.0 / $zoom + $handle.IsHitTestVisible = $false + $handle.Tag = [pscustomobject]@{ Role=$Role; Handle=$handleName } + [System.Windows.Controls.Canvas]::SetLeft($handle,[double]$centers[$handleName][0]-$half) + [System.Windows.Controls.Canvas]::SetTop($handle,[double]$centers[$handleName][1]-$half) + $Layer.Children.Add($handle) | Out-Null + } + } + + function script:Render-PreviewInteraction { + $interactionLayer.Children.Clear() + $selectionLayer.Children.Clear() + $zoom = if ($null -ne $layoutScale -and $layoutScale.ScaleX -gt 0) { + [double]$layoutScale.ScaleX + } else { 1.0 } + + $crop = if ($null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Crop') { + $previewContext.Draft.Candidate + } else { $previewContext.CropRectangle } + if ($null -ne $crop) { + $cropOutline = [System.Windows.Shapes.Rectangle]::new() + $cropOutline.Width=[double]$crop.Width; $cropOutline.Height=[double]$crop.Height + $cropOutline.Stroke=$resources['SnipMintBrush'] + $cropOutline.StrokeThickness=2.0/$zoom + $cropOutline.Fill=[System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.Color]::FromArgb(20,168,239,215)) + $cropOutline.IsHitTestVisible=$false + $cropOutline.Tag=[pscustomobject]@{ Role='CropOverlay' } + [System.Windows.Controls.Canvas]::SetLeft($cropOutline,[double]$crop.X) + [System.Windows.Controls.Canvas]::SetTop($cropOutline,[double]$crop.Y) + $interactionLayer.Children.Add($cropOutline) | Out-Null + Add-PreviewHandleSet -Layer $interactionLayer -Bounds $crop ` + -Role CropHandle + } + + $selected = Get-PreviewAnnotationById $previewContext.SelectedAnnotationId + if ($null -eq $selected) { + if ($null -ne $previewContext.SelectedAnnotationId) { + $previewContext.SelectedAnnotationId = $null + $state.SelectionId = $null + } + return + } + if ($null -ne $previewContext.Interaction -and + $previewContext.Interaction.Kind -eq 'Select' -and + $null -ne $previewContext.Interaction.Candidate) { + $selected = $previewContext.Interaction.Candidate + } + $geometry = $selected.Geometry + if ($geometry.Type -eq 'Line') { + $outline = [System.Windows.Shapes.Line]::new() + $outline.X1=[double]$geometry.Start.X; $outline.Y1=[double]$geometry.Start.Y + $outline.X2=[double]$geometry.End.X; $outline.Y2=[double]$geometry.End.Y + $outline.Stroke=$resources['SnipMintBrush']; $outline.StrokeThickness=2.0/$zoom + $outline.IsHitTestVisible=$false + $outline.Tag=[pscustomobject]@{ Role='SelectionOutline' } + $selectionLayer.Children.Add($outline) | Out-Null + foreach ($endpoint in @( + [pscustomobject]@{ Name='Start'; X=$geometry.Start.X; Y=$geometry.Start.Y }, + [pscustomobject]@{ Name='End'; X=$geometry.End.X; Y=$geometry.End.Y })) { + $diameter=10.0/$zoom; $handle=[System.Windows.Shapes.Ellipse]::new() + $handle.Width=$diameter; $handle.Height=$diameter + $handle.Fill=$resources['SnipMintBrush']; $handle.IsHitTestVisible=$false + $handle.Tag=[pscustomobject]@{ Role='SelectionHandle'; Handle=$endpoint.Name } + [System.Windows.Controls.Canvas]::SetLeft($handle,[double]$endpoint.X-$diameter/2) + [System.Windows.Controls.Canvas]::SetTop($handle,[double]$endpoint.Y-$diameter/2) + $selectionLayer.Children.Add($handle) | Out-Null + } + return + } + $bounds = Get-PreviewAnnotationBounds $selected + if ($null -eq $bounds) { return } + $selectionOutline = [System.Windows.Shapes.Rectangle]::new() + $selectionOutline.Width=$bounds.Width; $selectionOutline.Height=$bounds.Height + $selectionOutline.Stroke=$resources['SnipMintBrush'] + $selectionOutline.StrokeThickness=2.0/$zoom + $dashPattern=[System.Windows.Media.DoubleCollection]::new() + $dashPattern.Add(3.0/$zoom) + $dashPattern.Add(2.0/$zoom) + $selectionOutline.StrokeDashArray=$dashPattern + $selectionOutline.IsHitTestVisible=$false + $selectionOutline.Tag=[pscustomobject]@{ Role='SelectionOutline' } + [System.Windows.Controls.Canvas]::SetLeft($selectionOutline,$bounds.X) + [System.Windows.Controls.Canvas]::SetTop($selectionOutline,$bounds.Y) + $selectionLayer.Children.Add($selectionOutline) | Out-Null + Add-PreviewHandleSet -Layer $selectionLayer -Bounds $bounds ` + -Role SelectionHandle + } + + # Both WPF composition and compatibility bitmap output consume this same + # stable projection. Sorting the wrapper records leaves the authoritative + # annotation list and its canonical record identities untouched. + $getOrderedAnnotations = { + Initialize-SnipPreviewAnnotations ` + -Annotations $previewContext.Annotations | Out-Null + $entries = for ($index = 0; + $index -lt $previewContext.Annotations.Count; $index++) { + [pscustomobject]@{ + Annotation = $previewContext.Annotations[$index] + Index = $index + } + } + @($entries | Sort-Object ` + @{Expression={ [double]$_.Annotation.Z };Ascending=$true}, ` + @{Expression={ $_.Index };Ascending=$true} | + ForEach-Object Annotation) + }.GetNewClosure() + + function script:Render-Annotations { + $annotationLayer.Children.Clear() + foreach ($a in @(& $getOrderedAnnotations)) { + $geometry = $a.Geometry + $alpha = [int][math]::Round(255.0 * [math]::Max(0.0,[math]::Min(1.0,[double]$a.Opacity))) + $brush = Get-PreviewWpfBrush -Color ([string]$a.Color) -Alpha $alpha + switch ([string]$a.Kind) { + { $_ -in @('Highlight','Rectangle','Ellipse') } { + $shape = if ($a.Kind -eq 'Ellipse') { + [System.Windows.Shapes.Ellipse]::new() + } else { [System.Windows.Shapes.Rectangle]::new() } + $shape.Width=[double]$geometry.Width; $shape.Height=[double]$geometry.Height + if ($a.Kind -eq 'Highlight') { $shape.Fill=$brush } else { $shape.Stroke=$brush } + $shape.Stroke=$brush + $shape.StrokeThickness=[double]$a.StrokeWidth + $shape.IsHitTestVisible=$false + $shape.Tag=[pscustomobject]@{ Role='Annotation'; Id=$a.Id } + [System.Windows.Controls.Canvas]::SetLeft($shape,[double]$geometry.X) + [System.Windows.Controls.Canvas]::SetTop($shape,[double]$geometry.Y) + $annotationLayer.Children.Add($shape) | Out-Null + } + { $_ -in @('Arrow','Line') } { + $line=[System.Windows.Shapes.Line]::new() + $line.X1=[double]$geometry.Start.X; $line.Y1=[double]$geometry.Start.Y + $line.X2=[double]$geometry.End.X; $line.Y2=[double]$geometry.End.Y + $line.Stroke=$brush; $line.StrokeThickness=[double]$a.StrokeWidth + $line.StrokeStartLineCap='Round' + $line.StrokeEndLineCap=if($a.Kind -eq 'Arrow'){'Triangle'}else{'Round'} + $line.IsHitTestVisible=$false + $line.Tag=[pscustomobject]@{ Role='Annotation'; Id=$a.Id } + $annotationLayer.Children.Add($line) | Out-Null + } + 'Text' { + $textBlock=[System.Windows.Controls.TextBlock]::new() + $textBlock.Text=[string]$a.Properties.Text + $textBlock.FontFamily=[System.Windows.Media.FontFamily]::new('Segoe UI') + $textBlock.FontWeight=[System.Windows.FontWeights]::SemiBold + $textBlock.FontSize=[double]$a.Properties.FontSize + $textBlock.Foreground=$brush; $textBlock.IsHitTestVisible=$false + $textBlock.Tag=[pscustomobject]@{ Role='Annotation'; Id=$a.Id } + [System.Windows.Controls.Canvas]::SetLeft($textBlock,[double]$geometry.X) + [System.Windows.Controls.Canvas]::SetTop($textBlock,[double]$geometry.Y) + $annotationLayer.Children.Add($textBlock) | Out-Null + } + default { + if ($geometry.Type -eq 'Points') { + $polyline=[System.Windows.Shapes.Polyline]::new() + foreach ($point in @($geometry.Points)) { + $polyline.Points.Add([System.Windows.Point]::new($point.X,$point.Y)) + } + $polyline.Stroke=$brush; $polyline.StrokeThickness=[double]$a.StrokeWidth + $polyline.IsHitTestVisible=$false + $polyline.Tag=[pscustomobject]@{ Role='Annotation'; Id=$a.Id } + $annotationLayer.Children.Add($polyline) | Out-Null + } + } + } + } + Render-PreviewInteraction + } + + function script:Trim-SnipStack { + # Cap a Stack to its $Max most recent entries (oldest drop off the bottom). + param($Stack, [int]$Max = $script:UndoStackMaxDepth) + if ($Stack.Count -le $Max) { return } + $keep = Get-TrimmedRecent -Items $Stack.ToArray() -MaxDepth $Max + $Stack.Clear() + for ($i = $keep.Count - 1; $i -ge 0; $i--) { [void]$Stack.Push($keep[$i]) } + } + + function script:Snapshot-State { + $previewContext.UndoStack.Push((New-SnipEditorSnapshot ` + -Annotations $previewContext.Annotations ` + -CropRectangle $previewContext.CropRectangle)) + $previewContext.RedoStack.Clear() + Trim-SnipStack $previewContext.UndoStack + } + + function script:Restore-State { + param($snapshot) + $restored = New-SnipEditorSnapshot -Annotations $snapshot.Annotations ` + -CropRectangle $snapshot.CropRectangle + $previewContext.Annotations.Clear() + foreach ($a in $restored.Annotations) { + [void]$previewContext.Annotations.Add($a) + } + $previewContext.CropRectangle = $restored.CropRectangle + $state.CropRectangle = $previewContext.CropRectangle + $previewContext.Draft = $null; $state.Draft = $null + $previewContext.Interaction = $null + if ($null -eq (Get-PreviewAnnotationById $previewContext.SelectedAnnotationId)) { + $previewContext.SelectedAnnotationId = $null + $state.SelectionId = $null + } + Render-Annotations + } + + function script:Do-Undo { + if ($previewContext.UndoStack.Count -eq 0) { return } + $previewContext.RedoStack.Push((New-SnipEditorSnapshot ` + -Annotations $previewContext.Annotations ` + -CropRectangle $previewContext.CropRectangle)) + Trim-SnipStack $previewContext.RedoStack + Restore-State $previewContext.UndoStack.Pop() + } + + function script:Do-Redo { + if ($previewContext.RedoStack.Count -eq 0) { return } + $previewContext.UndoStack.Push((New-SnipEditorSnapshot ` + -Annotations $previewContext.Annotations ` + -CropRectangle $previewContext.CropRectangle)) + Trim-SnipStack $previewContext.UndoStack + Restore-State $previewContext.RedoStack.Pop() + } + + # Named color picker. Tests and the real swatch click handler both call + # this. Also live-updates the foreground of any text box that is + # currently being edited, so the user sees the color change immediately. + $pickColor = { + param([string]$Name) + if (-not $palette.Contains($Name)) { return } + $state.ActiveColor = $Name + if ($state.EditingText) { + foreach ($child in $highlightLayer.Children) { + if ($child -is [System.Windows.Controls.TextBox]) { + $rgbL = $palette[$Name] + $child.Foreground = New-Object System.Windows.Media.SolidColorBrush( + (To-WpfColor 255 $rgbL.R $rgbL.G $rgbL.B)) + $child.BorderBrush = New-Object System.Windows.Media.SolidColorBrush( + (To-WpfColor 200 $rgbL.R $rgbL.G $rgbL.B)) + break + } + } + } + # Update the active swatch in-place instead of rebuilding the bar. + # Rebuilding would re-attach click handlers, and because $pickColor + # self-reference inside its own body resolves to $null (the closure + # captured it before assignment completed), the rebuilt handlers + # would carry a dead reference. Keep the original handlers alive. + $accent = [System.Windows.Media.Color]::FromArgb(255, 0x5B, 0x8D, 0xEF) + foreach ($ring in $colorBar.Children) { + if ($ring.Tag -eq $Name) { + $ring.BorderBrush = New-Object System.Windows.Media.SolidColorBrush($accent) + } else { + $ring.BorderBrush = [System.Windows.Media.Brushes]::Transparent + } + } + }.GetNewClosure() + + # Build color swatches. + # $pickColor is passed explicitly: `function script:` creates a new scope + # that does NOT inherit Show-PreviewWindow's locals for closure purposes, + # so a { & $pickColor ... }.GetNewClosure() inside this body would capture + # $null. Accept it as a parameter so the closure has a real reference. + function script:Build-ColorBar { + param([scriptblock]$pickColor) + $colorBar.Children.Clear() + # Circular swatches with a 2px accent outline ring on the active one. + # Fixed size (no jump) — the ring lives on an outer Border + padding. + $accentColor = [System.Windows.Media.Color]::FromArgb(255, 0x5B, 0x8D, 0xEF) + foreach ($name in $palette.Keys) { + $rgb = $palette[$name] + $isActive = ($state.ActiveColor -eq $name) + + $ring = New-Object System.Windows.Controls.Border + $ring.Width = 22 + $ring.Height = 22 + $ring.CornerRadius = New-Object System.Windows.CornerRadius 11 + $ring.Margin = New-Object System.Windows.Thickness 3, 0, 3, 0 + $ring.Padding = New-Object System.Windows.Thickness 2 + if ($isActive) { + $ring.BorderBrush = New-Object System.Windows.Media.SolidColorBrush($accentColor) + $ring.BorderThickness = New-Object System.Windows.Thickness 2 + } else { + $ring.BorderBrush = [System.Windows.Media.Brushes]::Transparent + $ring.BorderThickness = New-Object System.Windows.Thickness 2 + } + $ring.Cursor = [System.Windows.Input.Cursors]::Hand + # Non-focusable so clicking a swatch while a text box is open + # doesn't steal keyboard focus (which would fire LostFocus → + # commit the text in the OLD color before we can update it). + $ring.Focusable = $false + $ring.ToolTip = $name + $ring.Tag = $name + + $dot = New-Object System.Windows.Controls.Border + $dot.Width = 14 + $dot.Height = 14 + $dot.CornerRadius = New-Object System.Windows.CornerRadius 7 + $dot.Background = New-Object System.Windows.Media.SolidColorBrush( + (To-WpfColor 255 $rgb.R $rgb.G $rgb.B)) + $ring.Child = $dot + + $ring.Add_MouseLeftButtonDown({ & $pickColor $this.Tag }.GetNewClosure()) + [void]$colorBar.Children.Add($ring) + } + } + Build-ColorBar $pickColor + foreach ($colorName in @('Yellow','Green','Pink','Blue','Orange','Red')) { + $selectedColor = $colorName + $studioShell.ColorMenuItems[$colorName].Add_Click({ + & $pickColor $selectedColor + }.GetNewClosure()) + $studioShell.MoreColorMenuItems[$colorName].Add_Click({ + & $pickColor $selectedColor + & $studioShell.CloseMoreMenu + }.GetNewClosure()) + } + + # Tool toggle interlock — at most one tool active. No tool = pan (Hand) mode. + $tools = @($highlightBtn, $rectBtn, $arrowBtn, $textBtn) + foreach ($t in $tools) { + $t.Add_Checked({ + $me = $this + foreach ($other in $tools) { if ($other -ne $me) { $other.IsChecked = $false } } + }.GetNewClosure()) + } + # No tool checked by default → pan mode is active. + # Note: $scroller is resolved later (XAML lookup). We bind the cursor + # refresh to tool-button state changes so it stays in sync as the + # user toggles tools on/off. + $updateCursor = { + $anyTool = $highlightBtn.IsChecked -or $rectBtn.IsChecked -or + $arrowBtn.IsChecked -or $textBtn.IsChecked + $highlightLayer.Cursor = if ($anyTool) { + [System.Windows.Input.Cursors]::Cross + } else { + [System.Windows.Input.Cursors]::Hand + } + }.GetNewClosure() + foreach ($t in $tools) { + $t.Add_Checked($updateCursor) + $t.Add_Unchecked($updateCursor) + } + & $updateCursor # initial: Hand (no tool) + + $setStudioTool = { + param([string]$Tool) + if ($previewContext.ActiveTool -ne $Tool -and $previewContext.CancelDraft) { + & $previewContext.CancelDraft + } + foreach ($buttonName in @('Select','Crop','Pen','Steps')) { + $button = $previewContext.ToolControls[$buttonName] + $button.Background = [System.Windows.Media.Brushes]::Transparent + $button.BorderBrush = $resources['SnipHairlineBrush'] + } + foreach ($splitName in @('ArrowLine','RectangleEllipse','BlurPixelate')) { + $splitButton = $previewContext.SplitControls[$splitName].PrimaryButton + $splitButton.Background = [System.Windows.Media.Brushes]::Transparent + $splitButton.BorderBrush = $resources['SnipHairlineBrush'] + } + switch ($Tool) { + 'Highlight' { $highlightBtn.IsChecked = $true } + 'RectangleEllipse' { $rectBtn.IsChecked = $true } + 'ArrowLine' { $arrowBtn.IsChecked = $true } + 'Text' { $textBtn.IsChecked = $true } + default { + foreach ($legacyTool in $tools) { $legacyTool.IsChecked = $false } + } + } + $activeButton = switch ($Tool) { + 'Select' { $previewContext.ToolControls.Select } + 'Crop' { $previewContext.ToolControls.Crop } + 'Pen' { $previewContext.ToolControls.Pen } + 'Steps' { $previewContext.ToolControls.Steps } + 'ArrowLine' { $previewContext.SplitControls.ArrowLine.PrimaryButton } + 'RectangleEllipse' { $previewContext.SplitControls.RectangleEllipse.PrimaryButton } + 'BlurPixelate' { $previewContext.SplitControls.BlurPixelate.PrimaryButton } + default { $null } + } + if ($null -ne $activeButton) { + $activeButton.Background = $resources['SnipAccentTintBrush'] + $activeButton.BorderBrush = $resources['SnipAccentBrush'] + } + $previewContext.ActiveTool = $Tool + $state.ActiveStudioTool = $Tool + if ($Tool -in @('Highlight','ArrowLine','RectangleEllipse','Steps')) { + [void]$previewContext.RecentTools.Remove($Tool) + $previewContext.RecentTools.Insert(0, $Tool) + while ($previewContext.RecentTools.Count -gt 2) { + $previewContext.RecentTools.RemoveAt($previewContext.RecentTools.Count - 1) + } + } + if ($Tool -in @('Select','Crop','Pen','Highlight','ArrowLine','RectangleEllipse','Text','Steps','BlurPixelate')) { + Set-SnipPropertyIsland -Context $previewContext -Tool $Tool | Out-Null + } + Set-SnipPreviewResponsiveMode -Context $previewContext ` + -Width $win.ActualWidth -Height $win.ActualHeight | Out-Null + }.GetNewClosure() + $highlightBtn.Add_Checked({ & $setStudioTool 'Highlight' }.GetNewClosure()) + $textBtn.Add_Checked({ & $setStudioTool 'Text' }.GetNewClosure()) + $arrowBtn.Add_Checked({ & $setStudioTool 'ArrowLine' }.GetNewClosure()) + $rectBtn.Add_Checked({ & $setStudioTool 'RectangleEllipse' }.GetNewClosure()) + foreach ($legacySpec in @( + [pscustomobject]@{ Button=$highlightBtn; Tool='Highlight' }, + [pscustomobject]@{ Button=$textBtn; Tool='Text' }, + [pscustomobject]@{ Button=$arrowBtn; Tool='ArrowLine' }, + [pscustomobject]@{ Button=$rectBtn; Tool='RectangleEllipse' })) { + $legacyToolName=$legacySpec.Tool + $legacySpec.Button.Add_Unchecked({ + $anyLegacy=$highlightBtn.IsChecked -or $rectBtn.IsChecked -or + $arrowBtn.IsChecked -or $textBtn.IsChecked + if(-not $anyLegacy -and $previewContext.ActiveTool -eq $legacyToolName){ + $previewContext.ActiveTool=$null + $state.ActiveStudioTool=$null + } + }.GetNewClosure()) + } + + $previewContext.SplitControls.ArrowLine.PrimaryButton.Add_Click({ + & $setStudioTool 'ArrowLine' + }.GetNewClosure()) + $previewContext.SplitControls.RectangleEllipse.PrimaryButton.Add_Click({ + & $setStudioTool 'RectangleEllipse' + }.GetNewClosure()) + $previewContext.SplitControls.BlurPixelate.PrimaryButton.Add_Click({ + & $setStudioTool 'BlurPixelate' + }.GetNewClosure()) + foreach ($subtype in @('Arrow','Line')) { + $selectedSubtype = $subtype + $previewContext.SplitControls.ArrowLine.MenuItems[$subtype].Add_Click({ + $previewContext.SplitControls.ArrowLine.DefaultCommand = $selectedSubtype + $previewContext.SplitControls.ArrowLine.Label.Text = if ($selectedSubtype -eq 'Arrow') { '↗' } else { '╱' } + [System.Windows.Automation.AutomationProperties]::SetName( + $previewContext.SplitControls.ArrowLine.PrimaryButton, + "Arrow or Line tool, selected subtype $selectedSubtype") + & $setStudioTool 'ArrowLine' + }.GetNewClosure()) + } + foreach ($subtype in @('Rectangle','Ellipse')) { + $selectedSubtype = $subtype + $previewContext.SplitControls.RectangleEllipse.MenuItems[$subtype].Add_Click({ + $previewContext.SplitControls.RectangleEllipse.DefaultCommand = $selectedSubtype + $previewContext.SplitControls.RectangleEllipse.Label.Text = if ($selectedSubtype -eq 'Rectangle') { '□' } else { '○' } + [System.Windows.Automation.AutomationProperties]::SetName( + $previewContext.SplitControls.RectangleEllipse.PrimaryButton, + "Rectangle or Ellipse tool, selected subtype $selectedSubtype") + & $setStudioTool 'RectangleEllipse' + }.GetNewClosure()) + } + foreach ($subtype in @('Blur','Pixelate')) { + $selectedSubtype = $subtype + $previewContext.SplitControls.BlurPixelate.MenuItems[$subtype].Add_Click({ + $previewContext.SplitControls.BlurPixelate.DefaultCommand = $selectedSubtype + $previewContext.SplitControls.BlurPixelate.Label.Text = if ($selectedSubtype -eq 'Blur') { '▒' } else { '▦' } + [System.Windows.Automation.AutomationProperties]::SetName( + $previewContext.SplitControls.BlurPixelate.PrimaryButton, + "Blur or Pixelate tool, selected subtype $selectedSubtype") + & $setStudioTool 'BlurPixelate' + }.GetNewClosure()) + } + $previewContext.ToolControls.Select.Add_Click({ & $setStudioTool 'Select' }.GetNewClosure()) + $previewContext.ToolControls.Crop.Add_Click({ & $setStudioTool 'Crop' }.GetNewClosure()) + $previewContext.ToolControls.Pen.Add_Click({ & $setStudioTool 'Pen' }.GetNewClosure()) + $previewContext.ToolControls.Steps.Add_Click({ & $setStudioTool 'Steps' }.GetNewClosure()) + foreach ($toolName in @('Highlight','ArrowLine','RectangleEllipse','Steps')) { + $selectedTool = $toolName + $studioShell.MoreMenuItems[$toolName].Add_Click({ + & $setStudioTool $selectedTool + }.GetNewClosure()) + } + # ---- Named core helpers (closures so tests can drive them directly) ---- + + $beginPan = { + param([System.Windows.Point]$SvPoint) + $state.Panning = $true + $state.PanStartSv = $SvPoint + $state.PanOrigX = $scroller.HorizontalOffset + $state.PanOrigY = $scroller.VerticalOffset + $highlightLayer.Cursor = [System.Windows.Input.Cursors]::SizeAll + try { $highlightLayer.CaptureMouse() | Out-Null } catch {} + }.GetNewClosure() + + $updatePan = { + param([System.Windows.Point]$SvPoint) + if (-not $state.Panning) { return } + $dx = $SvPoint.X - $state.PanStartSv.X + $dy = $SvPoint.Y - $state.PanStartSv.Y + $scroller.ScrollToHorizontalOffset($state.PanOrigX - $dx) + $scroller.ScrollToVerticalOffset( $state.PanOrigY - $dy) + }.GetNewClosure() + + $endPan = { + $state.TemporaryPan = $false + if (-not $state.Panning) { + $highlightLayer.Cursor = [System.Windows.Input.Cursors]::Hand + return + } + $state.Panning = $false + try { $highlightLayer.ReleaseMouseCapture() } catch {} + $highlightLayer.Cursor = [System.Windows.Input.Cursors]::Hand + }.GetNewClosure() + + # Context owns annotation draft semantics and the active interaction. + # The legacy state fields are kept as reference mirrors for older preview + # helpers and the interactive harness; no caller writes them independently. + $setAnnotationDraft = { + param([AllowNull()]$Draft) + $clearedAnnotation = $null -eq $Draft -and + $null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Annotation' + $previewContext.Draft = $Draft + $state.Draft = $Draft + if ($null -eq $Draft) { + if ($null -ne $previewContext.Interaction -and + $previewContext.Interaction.Kind -eq 'Annotation') { + $previewContext.Interaction = $null + } + $state.Drawing = $false + $state.DrawingTool = $null + $state.AnchorCanvas = $null + $state.DraftRect = $null + if ($clearedAnnotation) { + $previewContext.AnnotationDraftClearCount++ + } + return + } + $previewContext.Interaction = [pscustomobject][ordered]@{ + Kind = 'Annotation' + Mode = 'Create' + Draft = $Draft + } + $state.Drawing = $true + $state.DrawingTool = $Draft.Tool + $state.AnchorCanvas = $Draft.Anchor + $state.DraftRect = $Draft.Visual + }.GetNewClosure() + + $beginDraw = { + param([string]$Tool, [System.Windows.Point]$P) + $rgb = $palette[$state.ActiveColor] + $visual = $null + $kind = if ($Tool -eq 'arrow') { + 'Arrow' + } elseif ($Tool -eq 'highlight') { + 'Highlight' + } else { 'Rectangle' } + $geometry = if ($Tool -eq 'arrow') { + [pscustomobject][ordered]@{ + Type='Line' + Start=[pscustomobject]@{ X=$P.X; Y=$P.Y } + End=[pscustomobject]@{ X=$P.X; Y=$P.Y } + } + } else { + [pscustomobject][ordered]@{ + Type='Bounds'; X=$P.X; Y=$P.Y; Width=0.0; Height=0.0 + } + } + if ($Tool -eq 'arrow') { + $line = New-Object System.Windows.Shapes.Line + $line.X1 = $P.X; $line.Y1 = $P.Y; $line.X2 = $P.X; $line.Y2 = $P.Y + $line.Stroke = New-Object System.Windows.Media.SolidColorBrush( + (To-WpfColor 255 $rgb.R $rgb.G $rgb.B)) + $line.StrokeThickness = 4 + $line.StrokeStartLineCap = 'Round' + $line.StrokeEndLineCap = 'Triangle' + $line.IsHitTestVisible = $false + [void]$interactionLayer.Children.Add($line) + $visual = $line + } else { $shape = New-Object System.Windows.Shapes.Rectangle if ($Tool -eq 'highlight') { $shape.Fill = New-Object System.Windows.Media.SolidColorBrush( (To-WpfColor 110 $rgb.R $rgb.G $rgb.B)) } - $shape.Stroke = New-Object System.Windows.Media.SolidColorBrush( - (To-WpfColor 220 $rgb.R $rgb.G $rgb.B)) - $shape.StrokeThickness = if ($Tool -eq 'rect') { 3 } else { 1.5 } - $shape.IsHitTestVisible = $false - [System.Windows.Controls.Canvas]::SetLeft($shape, $P.X) - [System.Windows.Controls.Canvas]::SetTop($shape, $P.Y) - $shape.Width = 0; $shape.Height = 0 - [void]$highlightLayer.Children.Add($shape) - $state.DraftRect = $shape + $shape.Stroke = New-Object System.Windows.Media.SolidColorBrush( + (To-WpfColor 220 $rgb.R $rgb.G $rgb.B)) + $shape.StrokeThickness = if ($Tool -eq 'rect') { 3 } else { 1.5 } + $shape.IsHitTestVisible = $false + [System.Windows.Controls.Canvas]::SetLeft($shape, $P.X) + [System.Windows.Controls.Canvas]::SetTop($shape, $P.Y) + $shape.Width = 0; $shape.Height = 0 + [void]$interactionLayer.Children.Add($shape) + $visual = $shape + } + $opacity = if ($Tool -eq 'highlight') { 110.0 / 255.0 } else { 1.0 } + $strokeWidth = if ($Tool -eq 'arrow') { + 4.0 + } elseif ($Tool -eq 'highlight') { 1.5 } else { 3.0 } + & $setAnnotationDraft ([pscustomobject][ordered]@{ + Kind='Annotation'; Tool=$Tool; RecordKind=$kind; Anchor=$P + Candidate=[pscustomobject][ordered]@{ + Kind=$kind; Geometry=$geometry; Color=$state.ActiveColor + StrokeWidth=$strokeWidth; Opacity=$opacity + Properties=[ordered]@{} + } + Visual=$visual + }) + try { $highlightLayer.CaptureMouse() | Out-Null } catch {} + }.GetNewClosure() + + $updateDraw = { + param([System.Windows.Point]$P) + $draft = $previewContext.Draft + if ($null -eq $draft -or $draft.Kind -ne 'Annotation' -or + $null -eq $draft.Visual) { return } + if ($draft.Tool -eq 'arrow') { + $draft.Visual.X2 = $P.X + $draft.Visual.Y2 = $P.Y + $draft.Candidate.Geometry.End = [pscustomobject]@{ X=$P.X; Y=$P.Y } + } else { + $r = Get-DragRectangle -AnchorX $draft.Anchor.X -AnchorY $draft.Anchor.Y ` + -CurrentX $P.X -CurrentY $P.Y + [System.Windows.Controls.Canvas]::SetLeft($draft.Visual, $r.X) + [System.Windows.Controls.Canvas]::SetTop($draft.Visual, $r.Y) + $draft.Visual.Width = $r.Width + $draft.Visual.Height = $r.Height + $draft.Candidate.Geometry = [pscustomobject][ordered]@{ + Type='Bounds'; X=$r.X; Y=$r.Y; Width=$r.Width; Height=$r.Height + } + } + }.GetNewClosure() + + $getNextAnnotationZ = { + if ($previewContext.Annotations.Count -eq 0) { return 0.0 } + $maximum = @($previewContext.Annotations | ForEach-Object { + if ($null -ne $_.PSObject.Properties['Z']) { [double]$_.Z } else { 0.0 } + } | Measure-Object -Maximum).Maximum + [double]$maximum + 1.0 + }.GetNewClosure() + + $openText = { + param([System.Windows.Point]$P) + $b = Get-DisplayedImageBounds + if (-not $b) { return $null } + + # Locals the inner $commit closure needs to capture. PS's chained + # GetNewClosure() does not propagate outer-closure captures into a + # nested .GetNewClosure(), so we must materialize them as real + # locals here before creating $commit. + $stateL = $state + $winL = $win + $hlLayerL = $highlightLayer + $textBtnL = $textBtn + $paletteL = $palette + $bL = $b + $getNextAnnotationZL = $getNextAnnotationZ + + $tb = New-Object System.Windows.Controls.TextBox + $tb.Background = New-Object System.Windows.Media.SolidColorBrush( + ([System.Windows.Media.Color]::FromArgb(180, 30, 30, 30))) + $rgb = $palette[$state.ActiveColor] + $tb.Foreground = New-Object System.Windows.Media.SolidColorBrush( + (To-WpfColor 255 $rgb.R $rgb.G $rgb.B)) + $tb.BorderBrush = New-Object System.Windows.Media.SolidColorBrush( + (To-WpfColor 200 $rgb.R $rgb.G $rgb.B)) + $tb.BorderThickness = New-Object System.Windows.Thickness 1 + $tb.FontFamily = New-Object System.Windows.Media.FontFamily 'Segoe UI' + $tb.FontWeight = [System.Windows.FontWeights]::SemiBold + $tb.FontSize = 18 + $tb.Padding = New-Object System.Windows.Thickness 4, 1, 4, 1 + $tb.MinWidth = 80 + [System.Windows.Controls.Canvas]::SetLeft($tb, $P.X) + [System.Windows.Controls.Canvas]::SetTop($tb, $P.Y) + [void]$highlightLayer.Children.Add($tb) + $state.EditingText = $true + + # Reentrance guard: ClearFocus / Children.Remove can synchronously + # fire LostFocus on the TextBox and recurse back into $commit. Using + # a hashtable field so the mutation propagates across invocations. + $commitGuard = @{ Done = $false } + $commit = { + if ($commitGuard.Done) { return } + $commitGuard.Done = $true + $stateL.EditingText = $false + $text = $tb.Text + try { [System.Windows.Input.Keyboard]::ClearFocus() } catch {} + try { [System.Windows.Input.Mouse]::Capture($null) } catch {} + try { $winL.Focus() | Out-Null } catch {} + [void]$hlLayerL.Children.Remove($tb) + if ([string]::IsNullOrWhiteSpace($text)) { return } + $imgX = [int][math]::Round(($P.X - $bL.X) / $bL.Scale) + $imgY = [int][math]::Round(($P.Y - $bL.Y) / $bL.Scale) + $fontSize = [int][math]::Round(18 / $bL.Scale) + Snapshot-State + $textWidth = [math]::Max(1,[int][math]::Ceiling( + $text.Length * $fontSize * 0.6)) + $textHeight = [math]::Max(1,[int][math]::Ceiling($fontSize * 1.3)) + [void]$stateL.Annotations.Add((New-SnipAnnotation -Kind Text ` + -Geometry ([pscustomobject]@{ + Type='TextBounds'; X=$imgX; Y=$imgY + Width=$textWidth; Height=$textHeight + }) -Color $stateL.ActiveColor -StrokeWidth 1 -Opacity 1 ` + -Properties ([ordered]@{ Text=$text; FontSize=$fontSize }) ` + -Z (& $getNextAnnotationZL))) + Render-Annotations + }.GetNewClosure() + + $tb.Add_KeyDown({ + if ($_.Key -eq 'Enter') { + & $commit + $textBtnL.IsChecked = $false + $_.Handled = $true + } + elseif ($_.Key -eq 'Escape') { + $commitGuard.Done = $true + $stateL.EditingText = $false + [void]$hlLayerL.Children.Remove($tb) + try { [System.Windows.Input.Keyboard]::ClearFocus() } catch {} + try { $hlLayerL.Focus() | Out-Null } catch {} + $_.Handled = $true + } + }.GetNewClosure()) + $tb.Add_LostFocus({ & $commit }.GetNewClosure()) + try { $tb.Focus() | Out-Null } catch {} + # Tests and external callers can drive commit via $tb.Tag + $tb.Tag = $commit + return $tb + }.GetNewClosure() + + $finishDraw = { + $draft = $previewContext.Draft + if ($null -eq $draft -or $draft.Kind -ne 'Annotation') { return } + $visual = $draft.Visual + if ($null -ne $visual) { + [void]$interactionLayer.Children.Remove($visual) + } + & $setAnnotationDraft $null + try { $highlightLayer.ReleaseMouseCapture() } catch {} + $b = Get-DisplayedImageBounds + if (-not $b) { + Render-PreviewInteraction + return + } + $candidate = $draft.Candidate + if ($draft.Tool -eq 'arrow') { + $start = $candidate.Geometry.Start + $end = $candidate.Geometry.End + $dx = $end.X - $start.X; $dy = $end.Y - $start.Y + if ([math]::Sqrt($dx * $dx + $dy * $dy) -lt 6) { + Render-PreviewInteraction + return + } + $x1 = [int][math]::Round(($start.X - $b.X) / $b.Scale) + $y1 = [int][math]::Round(($start.Y - $b.Y) / $b.Scale) + $x2 = [int][math]::Round(($end.X - $b.X) / $b.Scale) + $y2 = [int][math]::Round(($end.Y - $b.Y) / $b.Scale) + Snapshot-State + [void]$previewContext.Annotations.Add((New-SnipAnnotation -Kind Arrow ` + -Geometry ([pscustomobject]@{ + Type='Line' + Start=[pscustomobject]@{ X=$x1; Y=$y1 } + End=[pscustomobject]@{ X=$x2; Y=$y2 } + }) -Color $candidate.Color -StrokeWidth $candidate.StrokeWidth ` + -Opacity $candidate.Opacity ` + -Properties ([ordered]@{}) -Z (& $getNextAnnotationZ))) + Render-Annotations + return + } + if ($candidate.Geometry.Width -lt 3 -or $candidate.Geometry.Height -lt 3) { + Render-PreviewInteraction + return + } + $canvasX = [double]$candidate.Geometry.X + $canvasY = [double]$candidate.Geometry.Y + $rawX = [int][math]::Round(($canvasX - $b.X) / $b.Scale) + $rawY = [int][math]::Round(($canvasY - $b.Y) / $b.Scale) + $rawW = [int][math]::Round($candidate.Geometry.Width / $b.Scale) + $rawH = [int][math]::Round($candidate.Geometry.Height / $b.Scale) + $clamped = Get-ClampedAnnotationRect -X $rawX -Y $rawY -Width $rawW -Height $rawH ` + -BitmapWidth $Bitmap.Width -BitmapHeight $Bitmap.Height + Snapshot-State + [void]$previewContext.Annotations.Add((New-SnipAnnotation ` + -Kind $candidate.Kind ` + -Geometry ([pscustomobject]@{ + Type='Bounds'; X=$clamped.X; Y=$clamped.Y + Width=$clamped.Width; Height=$clamped.Height + }) -Color $candidate.Color -StrokeWidth $candidate.StrokeWidth ` + -Opacity $candidate.Opacity -Properties $candidate.Properties ` + -Z (& $getNextAnnotationZ))) + Render-Annotations + }.GetNewClosure() + + $setSelectedAnnotation = { + param([AllowNull()][string]$Id) + if (-not [string]::IsNullOrWhiteSpace($Id) -and + $null -eq (Get-PreviewAnnotationById $Id)) { + $Id = $null + } + $previewContext.SelectedAnnotationId = $Id + $state.SelectionId = $Id + if ($previewContext.ActiveTool -eq 'Select') { + Set-SnipPropertyIsland -Context $previewContext -Tool Select | Out-Null + } + Render-PreviewInteraction + $Id + }.GetNewClosure() + + $getResizeHandleAt = { + param([System.Windows.Point]$Point) + $annotation = Get-PreviewAnnotationById $previewContext.SelectedAnnotationId + if ($null -eq $annotation) { return $null } + $zoom = if ($layoutScale.ScaleX -gt 0) { [double]$layoutScale.ScaleX } else { 1.0 } + $tolerance = 7.0 / $zoom + if ($annotation.Geometry.Type -eq 'Line') { + foreach ($endpoint in @( + [pscustomobject]@{ Name='Start'; Point=$annotation.Geometry.Start }, + [pscustomobject]@{ Name='End'; Point=$annotation.Geometry.End })) { + $dx=$Point.X-[double]$endpoint.Point.X + $dy=$Point.Y-[double]$endpoint.Point.Y + if ([math]::Sqrt($dx*$dx+$dy*$dy) -le $tolerance) { + return $endpoint.Name + } + } + return $null + } + $bounds = Get-PreviewAnnotationBounds $annotation + if ($null -eq $bounds) { return $null } + $centers = [ordered]@{ + TopLeft=@(([double]$bounds.X),([double]$bounds.Y)) + Top=@(([double]$bounds.X+([double]$bounds.Width/2.0)),([double]$bounds.Y)) + TopRight=@(([double]$bounds.X+[double]$bounds.Width),([double]$bounds.Y)) + Right=@(([double]$bounds.X+[double]$bounds.Width), + ([double]$bounds.Y+([double]$bounds.Height/2.0))) + BottomRight=@(([double]$bounds.X+[double]$bounds.Width), + ([double]$bounds.Y+[double]$bounds.Height)) + Bottom=@(([double]$bounds.X+([double]$bounds.Width/2.0)), + ([double]$bounds.Y+[double]$bounds.Height)) + BottomLeft=@(([double]$bounds.X),([double]$bounds.Y+[double]$bounds.Height)) + Left=@(([double]$bounds.X), + ([double]$bounds.Y+([double]$bounds.Height/2.0))) + } + foreach ($entry in $centers.GetEnumerator()) { + $dx=$Point.X-[double]$entry.Value[0] + $dy=$Point.Y-[double]$entry.Value[1] + if ([math]::Sqrt($dx*$dx+$dy*$dy) -le $tolerance) { + return [string]$entry.Key + } + } + $null + }.GetNewClosure() + + $beginSelectGesture = { + param([System.Windows.Point]$Point) + $highlightLayer.Focus() | Out-Null + $handle = & $getResizeHandleAt $Point + $selectedId = $previewContext.SelectedAnnotationId + if ($null -eq $handle) { + $previewContext.LastHitRoute = 'Find-SnipAnnotation' + $selectedId = Select-SnipAnnotation -Annotations $previewContext.Annotations ` + -ImageX $Point.X -ImageY $Point.Y ` + -Tolerance (6.0/[math]::Max(0.05,[double]$layoutScale.ScaleX)) + if ([string]::IsNullOrWhiteSpace([string]$selectedId)) { + & $setSelectedAnnotation $null | Out-Null + $previewContext.Interaction = $null + return + } + & $setSelectedAnnotation $selectedId | Out-Null + } + $original = Get-PreviewAnnotationById $selectedId + if ($null -eq $original) { return } + $previewContext.Interaction = [pscustomobject][ordered]@{ + Kind='Select'; Mode=if($null -ne $handle){'Resize'}else{'Move'} + Handle=$handle; Start=$Point + Original=(Copy-SnipAnnotation -Annotation $original) + Candidate=(Copy-SnipAnnotation -Annotation $original) + Changed=$false + } + try { $highlightLayer.CaptureMouse() | Out-Null } catch {} + Render-PreviewInteraction + }.GetNewClosure() + + $updateSelectGesture = { + param([System.Windows.Point]$Point) + $interaction = $previewContext.Interaction + if ($null -eq $interaction -or $interaction.Kind -ne 'Select') { return } + $deltaX=[int][math]::Round($Point.X-[double]$interaction.Start.X) + $deltaY=[int][math]::Round($Point.Y-[double]$interaction.Start.Y) + try { + $candidate = if ($interaction.Mode -eq 'Resize') { + Resize-SnipAnnotation -Annotation $interaction.Original ` + -Handle $interaction.Handle -DeltaX $deltaX -DeltaY $deltaY ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height + } else { + Move-SnipAnnotation -Annotation $interaction.Original ` + -DeltaX $deltaX -DeltaY $deltaY ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height + } + } catch [System.ArgumentException] { + # A transient pointer sample may exactly collapse a Line or Points + # extent. Keep the last valid immutable candidate; release or a + # later valid sample remains safe and unexpected faults still escape. + Render-PreviewInteraction + return + } + $interaction.Candidate = $candidate + $interaction.Changed = -not (Test-PreviewAnnotationEqual ` + $interaction.Original $candidate) + Render-PreviewInteraction + }.GetNewClosure() + + $completeSelectGesture = { + $interaction = $previewContext.Interaction + if ($null -eq $interaction -or $interaction.Kind -ne 'Select') { return } + $previewContext.Interaction = $null + try { $highlightLayer.ReleaseMouseCapture() } catch {} + if ($interaction.Changed) { + $index = Get-PreviewAnnotationIndexById $interaction.Original.Id + if ($index -ge 0) { + Snapshot-State + $previewContext.Annotations[$index] = + Copy-SnipAnnotation -Annotation $interaction.Candidate + } + } + Render-Annotations + }.GetNewClosure() + + $cancelSelectGesture = { + if ($null -ne $previewContext.Interaction -and + $previewContext.Interaction.Kind -eq 'Select') { + $previewContext.Interaction = $null + try { $highlightLayer.ReleaseMouseCapture() } catch {} + Render-PreviewInteraction + } + }.GetNewClosure() + + $getCropHandleAt = { + param([System.Windows.Point]$Point) + $crop = if ($null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Crop') { + $previewContext.Draft.Candidate + } else { $previewContext.CropRectangle } + if ($null -eq $crop) { return $null } + $zoom=[math]::Max(0.05,[double]$layoutScale.ScaleX) + $tolerance=7.0/$zoom + $centers=[ordered]@{ + TopLeft=@(([double]$crop.X),([double]$crop.Y)) + Top=@(([double]$crop.X+([double]$crop.Width/2.0)),([double]$crop.Y)) + TopRight=@(([double]$crop.X+[double]$crop.Width),([double]$crop.Y)) + Right=@(([double]$crop.X+[double]$crop.Width), + ([double]$crop.Y+([double]$crop.Height/2.0))) + BottomRight=@(([double]$crop.X+[double]$crop.Width), + ([double]$crop.Y+[double]$crop.Height)) + Bottom=@(([double]$crop.X+([double]$crop.Width/2.0)), + ([double]$crop.Y+[double]$crop.Height)) + BottomLeft=@(([double]$crop.X),([double]$crop.Y+[double]$crop.Height)) + Left=@(([double]$crop.X), + ([double]$crop.Y+([double]$crop.Height/2.0))) + } + foreach($entry in $centers.GetEnumerator()){ + $dx=$Point.X-[double]$entry.Value[0] + $dy=$Point.Y-[double]$entry.Value[1] + if([math]::Sqrt($dx*$dx+$dy*$dy) -le $tolerance){return [string]$entry.Key} + } + $null + }.GetNewClosure() + + $beginCropDraft = { + param([System.Windows.Point]$Point) + $highlightLayer.Focus() | Out-Null + $resizeHandle=& $getCropHandleAt $Point + if($null -ne $resizeHandle){ + $existing=if($null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Crop'){ + $previewContext.Draft.Candidate + }else{$previewContext.CropRectangle} + $baseCandidate=[pscustomobject]@{ + X=[int]$existing.X;Y=[int]$existing.Y + Width=[int]$existing.Width;Height=[int]$existing.Height + } + $previewContext.Draft=[pscustomobject][ordered]@{ + Kind='Crop';Anchor=$Point;BaseCandidate=$baseCandidate + OriginalCandidate=$baseCandidate;Candidate=(Get-SnipCropRectangle ` + -Candidate $baseCandidate -SourceWidth $Bitmap.Width ` + -SourceHeight $Bitmap.Height -Preset Free) + Preset=$previewContext.ToolProperties.Crop.Preset + ResizeHandle=$resizeHandle + } + $state.Draft=$previewContext.Draft + $previewContext.Interaction=[pscustomobject]@{Kind='Crop'} + try{$highlightLayer.CaptureMouse()|Out-Null}catch{} + Render-PreviewInteraction + return + } + $anchor = [System.Windows.Point]::new( + [math]::Max(0,[math]::Min($Bitmap.Width-1,[int][math]::Round($Point.X))), + [math]::Max(0,[math]::Min($Bitmap.Height-1,[int][math]::Round($Point.Y)))) + $baseCandidate = [pscustomobject]@{ + X=[int]$anchor.X; Y=[int]$anchor.Y; Width=1; Height=1 + } + $candidate = Get-SnipCropRectangle -Candidate $baseCandidate ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height ` + -Preset $previewContext.ToolProperties.Crop.Preset + $previewContext.Draft = [pscustomobject][ordered]@{ + Kind='Crop'; Anchor=$anchor; BaseCandidate=$baseCandidate + Candidate=$candidate; Preset=$previewContext.ToolProperties.Crop.Preset + } + $state.Draft = $previewContext.Draft + $previewContext.Interaction = [pscustomobject]@{ Kind='Crop' } + try { $highlightLayer.CaptureMouse() | Out-Null } catch {} + Render-PreviewInteraction + }.GetNewClosure() + + $updateCropDraft = { + param([System.Windows.Point]$Point) + $draft = $previewContext.Draft + if ($null -eq $draft -or $draft.Kind -ne 'Crop') { return } + if($null -ne $draft.PSObject.Properties['ResizeHandle'] -and + -not [string]::IsNullOrWhiteSpace([string]$draft.ResizeHandle)){ + $deltaX=[int][math]::Round($Point.X-[double]$draft.Anchor.X) + $deltaY=[int][math]::Round($Point.Y-[double]$draft.Anchor.Y) + $left=[int]$draft.OriginalCandidate.X + $top=[int]$draft.OriginalCandidate.Y + $right=$left+[int]$draft.OriginalCandidate.Width + $bottom=$top+[int]$draft.OriginalCandidate.Height + switch([string]$draft.ResizeHandle){ + 'TopLeft'{$left+=$deltaX;$top+=$deltaY} + 'Top'{$top+=$deltaY} + 'TopRight'{$right+=$deltaX;$top+=$deltaY} + 'Right'{$right+=$deltaX} + 'BottomRight'{$right+=$deltaX;$bottom+=$deltaY} + 'Bottom'{$bottom+=$deltaY} + 'BottomLeft'{$left+=$deltaX;$bottom+=$deltaY} + 'Left'{$left+=$deltaX} + } + $raw=[pscustomobject]@{ + X=$left;Y=$top;Width=($right-$left);Height=($bottom-$top) + } + $draft.BaseCandidate=$raw + $draft.Candidate=Get-SnipCropRectangle -Candidate $raw ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height ` + -Preset $draft.Preset + Render-PreviewInteraction + return + } + $rectangle = Get-DragRectangle -AnchorX $draft.Anchor.X -AnchorY $draft.Anchor.Y ` + -CurrentX $Point.X -CurrentY $Point.Y + if ($rectangle.Width -le 0 -or $rectangle.Height -le 0) { return } + $draft.BaseCandidate = [pscustomobject]@{ + X=[int][math]::Round($rectangle.X); Y=[int][math]::Round($rectangle.Y) + Width=[int][math]::Round($rectangle.Width) + Height=[int][math]::Round($rectangle.Height) + } + $draft.Candidate = Get-SnipCropRectangle -Candidate $draft.BaseCandidate ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height ` + -Preset $draft.Preset + Render-PreviewInteraction + }.GetNewClosure() + + $selectCropPreset = { + param([ValidateSet('Free','Original','1:1','4:3','16:9')][string]$Preset) + $previewContext.ToolProperties.Crop.Preset = $Preset + $baseCandidate = if ($null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Crop') { + $previewContext.Draft.BaseCandidate + } elseif ($null -ne $previewContext.CropRectangle) { + $previewContext.CropRectangle + } else { $null } + $candidate = Get-SnipCropRectangle -Candidate $baseCandidate ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height -Preset $Preset + $previewContext.Draft = [pscustomobject][ordered]@{ + Kind='Crop'; Anchor=$null; BaseCandidate=$baseCandidate + Candidate=$candidate; Preset=$Preset + } + $state.Draft = $previewContext.Draft + foreach ($item in @($previewContext.PropertyControls.Aspect.MenuItems.Values)) { + $item.IsChecked = [string]$item.Header -eq $Preset + } + Render-PreviewInteraction + }.GetNewClosure() + + $cancelCropDraft = { + if ($null -ne $previewContext.Draft -and $previewContext.Draft.Kind -eq 'Crop') { + $previewContext.Draft = $null; $state.Draft = $null + $previewContext.Interaction = $null + try { $highlightLayer.ReleaseMouseCapture() } catch {} + Render-PreviewInteraction + } + }.GetNewClosure() + + $applyCropDraft = { + $draft = $previewContext.Draft + if ($null -eq $draft -or $draft.Kind -ne 'Crop' -or $null -eq $draft.Candidate) { + return + } + $applied = Set-SnipCrop -Action Apply -Candidate $draft.Candidate ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height ` + -Preset $draft.Preset + if (-not (Test-PreviewCropEqual $previewContext.CropRectangle $applied)) { + Snapshot-State + $previewContext.CropRectangle = $applied + $state.CropRectangle = $applied + } + $previewContext.Draft = $null; $state.Draft = $null + $previewContext.Interaction = $null + try { $highlightLayer.ReleaseMouseCapture() } catch {} + Render-PreviewInteraction + }.GetNewClosure() + + $resetCrop = { + & $cancelCropDraft + if ($null -eq $previewContext.CropRectangle) { return } + Snapshot-State + $previewContext.CropRectangle = Set-SnipCrop -Action Reset ` + -Candidate $previewContext.CropRectangle ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height + $state.CropRectangle = $previewContext.CropRectangle + Render-PreviewInteraction + }.GetNewClosure() + + $deleteSelection = { + $index = Get-PreviewAnnotationIndexById $previewContext.SelectedAnnotationId + if ($index -lt 0) { + & $setSelectedAnnotation $null | Out-Null + return + } + Snapshot-State + $previewContext.Annotations.RemoveAt($index) + & $setSelectedAnnotation $null | Out-Null + Render-Annotations + }.GetNewClosure() + + $duplicateSelection = { + $source = Get-PreviewAnnotationById $previewContext.SelectedAnnotationId + if ($null -eq $source) { return $null } + $offsetCopy = Move-SnipAnnotation -Annotation $source ` + -DeltaX 12 -DeltaY 12 -SourceWidth $Bitmap.Width ` + -SourceHeight $Bitmap.Height + $duplicate = New-SnipAnnotation -Kind $source.Kind ` + -Geometry $offsetCopy.Geometry -Color $source.Color ` + -StrokeWidth $source.StrokeWidth -Opacity $source.Opacity ` + -Properties $source.Properties -Z (& $getNextAnnotationZ) + Snapshot-State + $previewContext.Annotations.Add($duplicate) | Out-Null + & $setSelectedAnnotation $duplicate.Id | Out-Null + Render-Annotations + $duplicate + }.GetNewClosure() + + $applySelectionProperty = { + param([ValidateSet('Position','Size')][string]$Name,[string]$Text) + if($Text -notmatch '^\s*(-?\d+)\s*[,x×]\s*(-?\d+)\s*$'){return $false} + $first=[int]$Matches[1];$second=[int]$Matches[2] + $current=Get-PreviewAnnotationById $previewContext.SelectedAnnotationId + if($null -eq $current){return $false} + $bounds=Get-PreviewAnnotationBounds $current + if($null -eq $bounds){return $false} + $updated=if($Name -eq 'Position'){ + Move-SnipAnnotation -Annotation $current ` + -DeltaX ($first-[int]$bounds.X) -DeltaY ($second-[int]$bounds.Y) ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height + }elseif($current.Geometry.Type -ne 'Line'){ + Resize-SnipAnnotation -Annotation $current -Handle BottomRight ` + -DeltaX ($first-[int]$bounds.Width) ` + -DeltaY ($second-[int]$bounds.Height) ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height + }else{return $false} + if(Test-PreviewAnnotationEqual $current $updated){return $false} + Snapshot-State + $index=Get-PreviewAnnotationIndexById $current.Id + if($index -ge 0){$previewContext.Annotations[$index]=$updated} + Render-Annotations + $true + }.GetNewClosure() + + $cancelDraft = { + if ($null -ne $previewContext.Interaction -and + $previewContext.Interaction.Kind -eq 'Select') { + & $cancelSelectGesture + return + } + if ($null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Annotation') { + $visual = $previewContext.Draft.Visual + if ($null -ne $visual) { + [void]$interactionLayer.Children.Remove($visual) + } + & $setAnnotationDraft $null + try { $highlightLayer.ReleaseMouseCapture() } catch {} + Render-PreviewInteraction + return + } + if ($null -ne $previewContext.Draft -and $previewContext.Draft.Kind -eq 'Crop') { + & $cancelCropDraft + return + } + }.GetNewClosure() + $previewContext.SelectCropPreset = $selectCropPreset + $previewContext.ApplyCrop = $applyCropDraft + $previewContext.ResetCrop = $resetCrop + $previewContext.CancelDraft = $cancelDraft + $previewContext.DeleteSelection = $deleteSelection + $previewContext.DuplicateSelection = $duplicateSelection + $previewContext.ApplySelectionProperty = $applySelectionProperty + + # ---- Mouse interactions on the highlight layer ---- + # Named dispatcher for MouseLeftButtonDown so tests can drive it with + # synthetic points. Event handler below is a thin wrapper. + $handleMouseDown = { + param( + [System.Windows.Point]$HlPoint, + [System.Windows.Point]$SvPoint + ) + if ($state.EditingText) { return } + if ($state.Panning) { return } + $b = Get-DisplayedImageBounds; if (-not $b) { return } + $p = $HlPoint + if ($p.X -lt $b.X -or $p.Y -lt $b.Y -or + $p.X -gt $b.X + $b.W -or $p.Y -gt $b.Y + $b.H) { return } + + switch ([string]$previewContext.ActiveTool) { + 'Select' { & $beginSelectGesture $p; return } + 'Crop' { & $beginCropDraft $p; return } + } + + $tool = $null + if ($highlightBtn.IsChecked) { $tool = 'highlight' } + elseif ($rectBtn.IsChecked) { $tool = 'rect' } + elseif ($arrowBtn.IsChecked) { $tool = 'arrow' } + + if ($tool) { + & $beginDraw $tool $p + } + elseif ($textBtn.IsChecked) { + & $openText $p + } elseif ([string]::IsNullOrWhiteSpace([string]$previewContext.ActiveTool)) { + & $beginPan $SvPoint + } + }.GetNewClosure() + + $handleMouseMove = { + param( + [System.Windows.Point]$HlPoint, + [System.Windows.Point]$SvPoint + ) + if ($state.Panning) { & $updatePan $SvPoint; return } + if ($null -ne $previewContext.Interaction) { + switch ($previewContext.Interaction.Kind) { + 'Select' { & $updateSelectGesture $HlPoint; return } + 'Crop' { & $updateCropDraft $HlPoint; return } + } + } + if ($null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Annotation') { + & $updateDraw $HlPoint + } + }.GetNewClosure() + + $handleMouseUp = { + param([System.Windows.Point]$HlPoint) + if ($state.Panning) { + if (-not $state.TemporaryPan) { & $endPan } + return + } + if ($null -ne $previewContext.Interaction) { + switch ($previewContext.Interaction.Kind) { + 'Select' { & $completeSelectGesture; return } + 'Crop' { + & $updateCropDraft $HlPoint + $previewContext.Interaction = $null + try { $highlightLayer.ReleaseMouseCapture() } catch {} + Render-PreviewInteraction + return + } + } + } + if ($null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Annotation') { + & $finishDraw + } + }.GetNewClosure() + + $highlightLayer.Add_MouseLeftButtonDown({ + & $handleMouseDown ($_.GetPosition($highlightLayer)) ($_.GetPosition($scroller)) + if ($state.Panning -or $state.Drawing -or $state.EditingText -or + $null -ne $previewContext.Interaction -or $null -ne $previewContext.Draft) { + $_.Handled = $true + } + }.GetNewClosure()) + + $highlightLayer.Add_MouseMove({ + $isSynthetic = $_.GetType().Name -eq 'SnipTestMouseEventArgs' + if ($state.TemporaryPan -or $isSynthetic -or + $_.LeftButton -eq [System.Windows.Input.MouseButtonState]::Pressed) { + & $handleMouseMove ($_.GetPosition($highlightLayer)) ($_.GetPosition($scroller)) + } + }.GetNewClosure()) + + $highlightLayer.Add_MouseLeftButtonUp({ + & $handleMouseUp ($_.GetPosition($highlightLayer)) + }.GetNewClosure()) + $highlightLayer.Add_LostMouseCapture({ + if ($null -ne $previewContext.Interaction -or + ($null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Annotation')) { + & $cancelDraft + } elseif ($state.Panning) { + & $endPan + } + }.GetNewClosure()) + + # Re-render on resize (ImageHost growing/shrinking with zoom) + $imageHost.Add_SizeChanged({ Render-Annotations }) + + # Hit-test helper: returns the topmost annotation index under a canvas point, or -1 + function script:Find-AnnotationAt { + param([double]$CanvasX, [double]$CanvasY) + $hit = Find-SnipAnnotation -Annotations $state.Annotations ` + -ImageX $CanvasX -ImageY $CanvasY -Tolerance 6 + $previewContext.LastHitRoute = 'Find-SnipAnnotation' + if ($null -eq $hit) { return -1 } + Get-PreviewAnnotationIndexById $hit.Id + } + + # Right-click an existing annotation → color/delete context menu + $highlightLayer.Add_MouseRightButtonDown({ + if ($state.EditingText) { return } + $p = $_.GetPosition($highlightLayer) + $idx = Find-AnnotationAt -CanvasX $p.X -CanvasY $p.Y + if ($idx -lt 0) { return } + $_.Handled = $true + + # Local aliases so menu-item closures can find them + $stateL = $state + $paletteL = $palette + $targetId = [string]$state.Annotations[$idx].Id + + if ($null -ne $previewContext.AnnotationMenuControl) { + & $previewContext.AnnotationMenuControl.Disconnect + $previewContext.TransientMenus.Remove( + $previewContext.AnnotationMenuControl) + $previewContext.AnnotationMenuControl = $null + } + + $menu = New-Object System.Windows.Controls.ContextMenu + $menu.StaysOpen = $true + $menuBindings = [System.Collections.ArrayList]::new() + foreach ($name in $paletteL.Keys) { + $rgb = $paletteL[$name] + $mi = New-Object System.Windows.Controls.MenuItem + $mi.Header = $name + $swatch = New-Object System.Windows.Shapes.Rectangle + $swatch.Width = 14; $swatch.Height = 14 + $swatch.Fill = New-Object System.Windows.Media.SolidColorBrush( + ([System.Windows.Media.Color]::FromArgb(255, $rgb.R, $rgb.G, $rgb.B))) + $mi.Icon = $swatch + [System.Windows.Automation.AutomationProperties]::SetName($mi, $name) + $nameL = $name + $colorClickHandler = { + $currentIndex = Get-PreviewAnnotationIndexById $targetId + if ($currentIndex -ge 0 -and + [string]$stateL.Annotations[$currentIndex].Color -ne $nameL) { + Snapshot-State + $updated = Copy-SnipAnnotation ` + -Annotation $stateL.Annotations[$currentIndex] + $updated.Color = $nameL + $stateL.Annotations[$currentIndex] = $updated + Render-Annotations + } + if ($null -ne $previewContext.AnnotationMenuControl) { + & $previewContext.AnnotationMenuControl.CloseOptions + } + }.GetNewClosure() + $mi.Add_Click($colorClickHandler) + $menuBindings.Add([pscustomobject]@{ + Item=$mi; Handler=$colorClickHandler + }) | Out-Null + [void]$menu.Items.Add($mi) + } + [void]$menu.Items.Add((New-Object System.Windows.Controls.Separator)) + $delMi = New-Object System.Windows.Controls.MenuItem + $delMi.Header = 'Delete' + [System.Windows.Automation.AutomationProperties]::SetName($delMi, 'Delete') + $deleteClickHandler = { + $currentIndex = Get-PreviewAnnotationIndexById $targetId + if ($currentIndex -ge 0) { + Snapshot-State + $stateL.Annotations.RemoveAt($currentIndex) + if ($previewContext.SelectedAnnotationId -eq $targetId) { + & $setSelectedAnnotation $null | Out-Null + } + Render-Annotations + } + if ($null -ne $previewContext.AnnotationMenuControl) { + & $previewContext.AnnotationMenuControl.CloseOptions + } + }.GetNewClosure() + $delMi.Add_Click($deleteClickHandler) + $menuBindings.Add([pscustomobject]@{ + Item=$delMi; Handler=$deleteClickHandler + }) | Out-Null + [void]$menu.Items.Add($delMi) + + $annotationCleanup = { + foreach ($binding in @($menuBindings)) { + $binding.Item.Remove_Click($binding.Handler) + } + }.GetNewClosure() + $previewContext.AnnotationMenuControl = + Connect-SnipPreviewTransientContextMenu -Menu $menu ` + -PlacementTarget $highlightLayer -Context $previewContext ` + -Name Annotation -Cleanup $annotationCleanup + & $previewContext.AnnotationMenuControl.OpenOptions + }) + + # Toolbar buttons + $win.FindName('ClearBtn').Add_Click({ + if ($state.Annotations.Count -eq 0) { return } + Snapshot-State + $state.Annotations.Clear() + & $setSelectedAnnotation $null | Out-Null + Render-Annotations + }) + $win.FindName('UndoBtn').Add_Click({ Do-Undo }) + $win.FindName('RedoBtn').Add_Click({ Do-Redo }) + + $toggleMaximize = { + $win.WindowState = if ($win.WindowState -eq [System.Windows.WindowState]::Maximized) { + [System.Windows.WindowState]::Normal + } else { [System.Windows.WindowState]::Maximized } + }.GetNewClosure() + $beginWindowDrag = { + if ($win.WindowState -eq [System.Windows.WindowState]::Maximized) { + $win.WindowState = [System.Windows.WindowState]::Normal + } + try { $win.DragMove() } catch {} + }.GetNewClosure() + $showSystemMenu = { + & $previewContext.CommandRouter.SystemMenuAction + }.GetNewClosure() + $resizeHitTest = { + param([double]$X,[double]$Y,[double]$Width,[double]$Height) + $left = $X -lt 6; $right = $X -ge ($Width - 6) + $top = $Y -lt 6; $bottom = $Y -ge ($Height - 6) + if ($top -and $left) { return 'TopLeft' } + if ($top -and $right) { return 'TopRight' } + if ($bottom -and $left) { return 'BottomLeft' } + if ($bottom -and $right) { return 'BottomRight' } + if ($top) { return 'Top' }; if ($bottom) { return 'Bottom' } + if ($left) { return 'Left' }; if ($right) { return 'Right' } + 'Client' + }.GetNewClosure() + $setResponsiveMode = { + param([double]$Width,[double]$Height) + Set-SnipPreviewResponsiveMode -Context $previewContext -Width $Width -Height $Height | Out-Null + }.GetNewClosure() + $setPropertyIsland = { + param([string]$Tool) + Set-SnipPropertyIsland -Context $previewContext -Tool $Tool | Out-Null + }.GetNewClosure() + $getIslandBounds = { + param([double]$Width,[double]$Height) + $win.Width = $Width; $win.Height = $Height + & $setResponsiveMode $Width $Height + $win.UpdateLayout() + $root = $studioShell.StudioRoot + $result = @{} + foreach ($entry in ([ordered]@{ + Brand=$studioShell.BrandIsland; Actions=$studioShell.ActionsIsland + Property=$studioShell.PropertyIsland; ToolDock=$studioShell.ToolDock + Viewport=$studioShell.ViewportIsland; Status=$studioShell.StatusIsland + }).GetEnumerator()) { + $origin = $entry.Value.TranslatePoint([System.Windows.Point]::new(0,0), $root) + $result[$entry.Key] = [System.Windows.Rect]::new( + $origin.X, $origin.Y, $entry.Value.ActualWidth, $entry.Value.ActualHeight) + } + $result + }.GetNewClosure() + $responsiveSizeChanged = [System.Windows.SizeChangedEventHandler]{ + param($sender,$eventArgs) + & $setResponsiveMode $eventArgs.NewSize.Width $eventArgs.NewSize.Height + }.GetNewClosure() + $win.Add_SizeChanged($responsiveSizeChanged) + + # The brand island is the only drag surface. Buttons remain independent + # native focus targets and a double-click toggles work-area maximize. + $win.FindName('DragHeader').Add_MouseLeftButtonDown({ + $src = $_.OriginalSource + $p = $src + while ($p -and -not ($p -is [System.Windows.Controls.Primitives.ButtonBase])) { + $p = [System.Windows.Media.VisualTreeHelper]::GetParent($p) + } + if ($p -is [System.Windows.Controls.Primitives.ButtonBase]) { return } + if ($_.ClickCount -eq 2) { + & $toggleMaximize + } else { + & $beginWindowDrag + } + }.GetNewClosure()) + + # Always-on-top pin + $pinBtn = $win.FindName('PinBtn') + $pinBtn.Add_Checked({ $win.Topmost = $true }) + $pinBtn.Add_Unchecked({ $win.Topmost = $false }) + + # Zoom controls. Uses LayoutTransform on ImageHost. The ScaleTransform + # itself is the single source of truth for the current zoom — reading + # $layoutScale.ScaleX through a captured object reference is immune to + # the PS-scope / closure quirks that broke prior attempts using + # $script: or $Global: variables inside WPF event handlers. + # ($scroller and $zoomText already resolved near the top of this function) + + foreach ($canvas in @($annotationLayer,$interactionLayer,$selectionLayer,$highlightLayer)) { + $canvas.Width = $Bitmap.Width + $canvas.Height = $Bitmap.Height + } + + $setZoom = { + param([double]$s) + # NB: literal doubles required. [math]::Min(10, 1.25) resolves to + # the Min(int,int) overload in PowerShell and truncates to 1. + $s = [math]::Max(0.05, [math]::Min(10.0, $s)) + $layoutScale.ScaleX = $s + $layoutScale.ScaleY = $s + $imageHost.InvalidateMeasure() + $imageHost.UpdateLayout() + try { $scroller.InvalidateScrollInfo() } catch {} + if ($zoomText) { $zoomText.Text = '{0:P0}' -f $s } + $brandZoomText = $win.FindName('BrandZoomText') + if ($brandZoomText) { $brandZoomText.Text = '{0:P0}' -f $s } + Render-PreviewInteraction + }.GetNewClosure() + + $zoomBy = { + param([double]$factor) + & $setZoom ($layoutScale.ScaleX * $factor) + }.GetNewClosure() + + $fitToViewport = { + if (-not $scroller -or $scroller.ViewportWidth -le 0) { return } + $fw = $scroller.ViewportWidth / $Bitmap.Width + $fh = $scroller.ViewportHeight / $Bitmap.Height + $fit = [math]::Min($fw, $fh) + if ($fit -gt 1) { $fit = 1 } + & $setZoom $fit + }.GetNewClosure() + + $win.Add_Loaded({ & $fitToViewport }.GetNewClosure()) + + $win.FindName('ZoomInBtn').Add_Click({ & $zoomBy 1.25 }.GetNewClosure()) + $win.FindName('ZoomOutBtn').Add_Click({ & $zoomBy (1 / 1.25) }.GetNewClosure()) + $win.FindName('FitBtn').Add_Click({ & $fitToViewport }.GetNewClosure()) + + $win.Add_PreviewMouseWheel({ + if (([System.Windows.Input.Keyboard]::Modifiers -band [System.Windows.Input.ModifierKeys]::Control) -ne 0) { + $factor = if ($_.Delta -gt 0) { 1.25 } else { 1 / 1.25 } + $oldScale = $layoutScale.ScaleX + $cursor = $_.GetPosition($scroller) + $oldSx = $scroller.HorizontalOffset + $oldSy = $scroller.VerticalOffset + & $zoomBy $factor + $newScale = $layoutScale.ScaleX + $offset = Get-ZoomCenteredOffset ` + -CursorX $cursor.X -CursorY $cursor.Y ` + -OldScrollX $oldSx -OldScrollY $oldSy ` + -OldScale $oldScale -NewScale $newScale ` + -ContentWidth ($Bitmap.Width * $newScale) ` + -ContentHeight ($Bitmap.Height * $newScale) ` + -ViewportWidth $scroller.ViewportWidth ` + -ViewportHeight $scroller.ViewportHeight + $scroller.ScrollToHorizontalOffset($offset.X) + $scroller.ScrollToVerticalOffset( $offset.Y) + $_.Handled = $true + } + }.GetNewClosure()) + + # Keyboard shortcuts + $fireClick = { + param($btnName) + $b = $win.FindName($btnName) + if ($b) { + $e = New-Object System.Windows.RoutedEventArgs( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent) + $b.RaiseEvent($e) + } + }.GetNewClosure() + + $handlePreviewKeyDown = { + param([System.Windows.Input.KeyEventArgs]$EventArgs) + $modifierFlags = & $previewContext.GetKeyboardModifiers + $eventKey = if ($EventArgs.Key -eq [System.Windows.Input.Key]::System) { + $EventArgs.SystemKey + } else { $EventArgs.Key } + $eventKeyName = [string]$eventKey + if ($eventKeyName -eq 'Return') { $eventKeyName = 'Enter' } + $focused = [System.Windows.Input.Keyboard]::FocusedElement + $openMenus = @($previewContext.TransientMenus | Where-Object { + $null -ne $_.State -and $_.State.IsExpanded + }) + $focusedRole = if ($openMenus.Count -gt 0 -or + $focused -is [System.Windows.Controls.MenuItem] -or + $focused -is [System.Windows.Controls.ContextMenu]) { + 'Popup' + } elseif ($null -ne $focused -and $null -ne $focused.PSObject.Properties['Tag'] -and + $null -ne $focused.Tag -and + $null -ne $focused.Tag.PSObject.Properties['Role'] -and + $focused.Tag.Role -eq 'PropertyEditor') { + 'PropertyEditor' + } elseif ($state.EditingText -or + $focused -is [System.Windows.Controls.TextBox] -or + $focused -is [System.Windows.Controls.RichTextBox]) { + 'TextEditor' + } elseif ($focused -is [System.Windows.Controls.Primitives.ButtonBase]) { + 'Button' + } elseif ($highlightLayer.IsKeyboardFocusWithin -or + [object]::ReferenceEquals($focused,$highlightLayer)) { + 'Canvas' + } else { 'Window' } + $modifierNames = @() + if (($modifierFlags -band [System.Windows.Input.ModifierKeys]::Control) -ne 0) { + $modifierNames += 'Ctrl' + } + if (($modifierFlags -band [System.Windows.Input.ModifierKeys]::Shift) -ne 0) { + $modifierNames += 'Shift' + } + if (($modifierFlags -band [System.Windows.Input.ModifierKeys]::Alt) -ne 0) { + $modifierNames += 'Alt' + } + $draftForKeys = if ($null -ne $previewContext.Interaction) { + $previewContext.Interaction + } else { $previewContext.Draft } + $editorState = @{ + PopupOpen=($openMenus.Count -gt 0) + EditingText=[bool]$state.EditingText + EditingProperty=[bool]$previewContext.EditingProperty + Draft=$draftForKeys + SelectionId=$previewContext.SelectedAnnotationId + ActiveTool=$previewContext.ActiveTool + } + $previewContext.CommandRouter.ResolveCount++ + $command = & $previewContext.CommandRouter.Resolve $focusedRole ` + $editorState $eventKeyName $modifierNames + $previewContext.CommandRouter.LastCommand = $command + if ([string]::IsNullOrWhiteSpace([string]$command)) { return } + + switch -Regex ($command) { + '^ClosePopup$' { + $owningMenu = $null + if ($null -ne $previewContext.PopupRouteMenu) { + $owningMenu = @($openMenus | Where-Object { + [object]::ReferenceEquals( + $_.Menu,$previewContext.PopupRouteMenu) + } | Select-Object -Last 1) + $owningMenu = if ($owningMenu.Count -gt 0) { + $owningMenu[0] + } else { $null } + } + if ($null -eq $owningMenu -and $openMenus.Count -gt 0) { + $owningMenu = $openMenus[-1] + } + if ($null -ne $owningMenu) { & $owningMenu.CloseOptions } + $EventArgs.Handled=$true; return + } + '^(TextCopy|TextInput|CommitText|CancelTextEdit|PropertyInput|CancelPropertyEdit|ActivateFocusedButton|PopupNavigation)$' { + return + } + '^CancelDraft$' { + & $cancelDraft; $EventArgs.Handled=$true; return + } + '^ClearSelection$' { + & $setSelectedAnnotation $null | Out-Null + $EventArgs.Handled=$true; return + } + '^DeleteSelection$' { + & $deleteSelection; $EventArgs.Handled=$true; return + } + '^MoveSelection(Left|Right|Up|Down)(1|10)$' { + $direction=$Matches[1]; $distance=[int]$Matches[2] + $deltaX=0; $deltaY=0 + switch ($direction) { + 'Left' { $deltaX=-$distance } + 'Right' { $deltaX=$distance } + 'Up' { $deltaY=-$distance } + 'Down' { $deltaY=$distance } + } + $current = Get-PreviewAnnotationById $previewContext.SelectedAnnotationId + if ($null -ne $current) { + $moved = Move-SnipAnnotation -Annotation $current ` + -DeltaX $deltaX -DeltaY $deltaY ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height + if (-not (Test-PreviewAnnotationEqual $current $moved)) { + Snapshot-State + $index=Get-PreviewAnnotationIndexById $current.Id + if ($index -ge 0) { $previewContext.Annotations[$index]=$moved } + Render-Annotations + } + } + $EventArgs.Handled=$true; return + } + '^ActivateSelect$' { + & $setStudioTool Select + $EventArgs.Handled=$true; return + } + '^Undo$' { Do-Undo; $EventArgs.Handled=$true; return } + '^Redo$' { Do-Redo; $EventArgs.Handled=$true; return } + '^CopyKeepOpen$' { + $previewContext.SplitControls.Copy.MenuItems.CopyKeepOpen.RaiseEvent( + [System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.MenuItem]::ClickEvent)) + $EventArgs.Handled=$true; return + } + '^CopyAndClose$' { & $fireClick CopyBtn; $EventArgs.Handled=$true; return } + '^Save$' { & $fireClick SaveBtn; $EventArgs.Handled=$true; return } + '^NewSmartCapture$' { & $fireClick NewBtn; $EventArgs.Handled=$true; return } + '^ZoomIn$' { & $zoomBy 1.25; $EventArgs.Handled=$true; return } + '^ZoomOut$' { & $zoomBy (1/1.25); $EventArgs.Handled=$true; return } + '^ZoomFit$' { & $fitToViewport; $EventArgs.Handled=$true; return } + '^TemporaryPan$' { + if (-not $state.Panning) { + $state.TemporaryPan = $true + & $beginPan ([System.Windows.Input.Mouse]::GetPosition($scroller)) + } + $EventArgs.Handled=$true; return + } + '^ShowSystemMenu$' { + & $previewContext.CommandRouter.SystemMenuAction + $EventArgs.Handled=$true; return + } + '^ClosePreview$' { + $EventArgs.Handled=$true + & $previewContext.CommandRouter.CloseAction + return + } + } + }.GetNewClosure() + $previewContext.RoutePreviewKeyDown = $handlePreviewKeyDown + $previewKeyDownHandler = { + & $handlePreviewKeyDown $_ + }.GetNewClosure() + $win.Add_PreviewKeyDown($previewKeyDownHandler) + $previewKeyUpHandler = [System.Windows.Input.KeyEventHandler]({ + param($sender,$eventArgs) + $eventKey = if ($eventArgs.Key -eq [System.Windows.Input.Key]::System) { + $eventArgs.SystemKey + } else { $eventArgs.Key } + if ($eventKey -eq [System.Windows.Input.Key]::Space -and + $state.TemporaryPan) { + & $endPan + $eventArgs.Handled = $true + } + }.GetNewClosure()) + $win.Add_PreviewKeyUp($previewKeyUpHandler) + + function script:Get-FlattenedBitmap { + $orderedAnnotations = @(& $getOrderedAnnotations) + if ($orderedAnnotations.Count -eq 0) { return $Bitmap } + $flat = New-Object System.Drawing.Bitmap $Bitmap.Width, $Bitmap.Height, + ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + $g = [System.Drawing.Graphics]::FromImage($flat) + $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit + $g.DrawImage($Bitmap, 0, 0, $Bitmap.Width, $Bitmap.Height) + foreach ($a in $orderedAnnotations) { + $rgb = $palette[$a.Color] + if (-not $rgb) { continue } + $alpha = [int][math]::Round(255.0 * + [math]::Max(0.0,[math]::Min(1.0,[double]$a.Opacity))) + if ($a.Kind -eq 'Highlight') { + $brush = New-Object System.Drawing.SolidBrush( + [System.Drawing.Color]::FromArgb($alpha, $rgb.R, $rgb.G, $rgb.B)) + $g.FillRectangle($brush, [int]$a.Geometry.X, [int]$a.Geometry.Y, + [int]$a.Geometry.Width, [int]$a.Geometry.Height) + $brush.Dispose() + } elseif ($a.Kind -in @('Rectangle','Ellipse')) { + $pen = New-Object System.Drawing.Pen( + [System.Drawing.Color]::FromArgb($alpha, $rgb.R, $rgb.G, $rgb.B), + [single]$a.StrokeWidth) + if ($a.Kind -eq 'Ellipse') { + $g.DrawEllipse($pen, [int]$a.Geometry.X, [int]$a.Geometry.Y, + [int]$a.Geometry.Width, [int]$a.Geometry.Height) + } else { + $g.DrawRectangle($pen, [int]$a.Geometry.X, [int]$a.Geometry.Y, + [int]$a.Geometry.Width, [int]$a.Geometry.Height) + } + $pen.Dispose() + } elseif ($a.Kind -in @('Arrow','Line')) { + $pen = New-Object System.Drawing.Pen( + [System.Drawing.Color]::FromArgb($alpha, $rgb.R, $rgb.G, $rgb.B), + [single]$a.StrokeWidth) + $pen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round + $pen.EndCap = if ($a.Kind -eq 'Arrow') { + [System.Drawing.Drawing2D.LineCap]::ArrowAnchor + } else { [System.Drawing.Drawing2D.LineCap]::Round } + $g.DrawLine($pen, [int]$a.Geometry.Start.X, [int]$a.Geometry.Start.Y, + [int]$a.Geometry.End.X, [int]$a.Geometry.End.Y) + $pen.Dispose() + } elseif ($a.Kind -eq 'Text') { + $brush = New-Object System.Drawing.SolidBrush( + [System.Drawing.Color]::FromArgb($alpha, $rgb.R, $rgb.G, $rgb.B)) + $font = New-Object System.Drawing.Font 'Segoe UI', $a.Properties.FontSize, + ([System.Drawing.FontStyle]::Bold), ([System.Drawing.GraphicsUnit]::Pixel) + $g.DrawString([string]$a.Properties.Text, $font, $brush, + [single]$a.Geometry.X, [single]$a.Geometry.Y) + $font.Dispose(); $brush.Dispose() + } elseif ($a.Geometry.Type -eq 'Points') { + $points = @($a.Geometry.Points) + if ($points.Count -gt 1) { + $pen = New-Object System.Drawing.Pen( + [System.Drawing.Color]::FromArgb($alpha,$rgb.R,$rgb.G,$rgb.B), + [single]$a.StrokeWidth) + for ($index=1; $index -lt $points.Count; $index++) { + $g.DrawLine($pen,[int]$points[$index-1].X,[int]$points[$index-1].Y, + [int]$points[$index].X,[int]$points[$index].Y) + } + $pen.Dispose() + } + } } - try { $highlightLayer.CaptureMouse() | Out-Null } catch {} - }.GetNewClosure() + $g.Dispose() + return $flat + } - $updateDraw = { - param([System.Windows.Point]$P) - if (-not $state.Drawing -or -not $state.DraftRect) { return } - if ($state.DrawingTool -eq 'arrow') { - $state.DraftRect.X2 = $P.X - $state.DraftRect.Y2 = $P.Y - } else { - $r = Get-DragRectangle -AnchorX $state.AnchorCanvas.X -AnchorY $state.AnchorCanvas.Y ` - -CurrentX $P.X -CurrentY $P.Y - [System.Windows.Controls.Canvas]::SetLeft($state.DraftRect, $r.X) - [System.Windows.Controls.Canvas]::SetTop($state.DraftRect, $r.Y) - $state.DraftRect.Width = $r.Width - $state.DraftRect.Height = $r.Height + $copyBtn = $win.FindName('CopyBtn') + $invokeCopy = { + param([bool]$CloseAfterCopy) + $operation = if ($CloseAfterCopy) { 'CopyAndClose' } else { 'CopyKeepOpen' } + if ($OnOutputStarting) { & $OnOutputStarting $operation } + $flat = $null + $success = $false + $copyError = $null + try { + $flat = Get-FlattenedBitmap + $clipSrc = Convert-BitmapToBitmapSource $flat + $retrySchedule = @(50, 100, 200) + for ($attempt = 0; $attempt -le $retrySchedule.Count; $attempt++) { + try { + & $previewContext.ClipboardSetter $clipSrc + $success = $true + break + } catch { + $copyError = $_ + if ($attempt -lt $retrySchedule.Count) { + & $previewContext.RetryDelay $retrySchedule[$attempt] + } + } + } + if ($success) { + & $previewContext.SetStatus 'Copied to clipboard' 'Success' + } else { + & $previewContext.SetStatus 'Clipboard is unavailable' 'Error' + if ($null -ne $copyError) { + Write-SnipDiag -Message 'Clipboard copy failed' -ErrorRecord $copyError + } + } + } catch { + & $previewContext.SetStatus 'Clipboard is unavailable' 'Error' + Write-SnipDiag -Message 'Clipboard copy failed' -ErrorRecord $_ + } finally { + if ($null -ne $flat -and $flat -ne $Bitmap) { + try { $flat.Dispose() } catch {} + } + if ($OnOutputCompleted) { & $OnOutputCompleted $operation $success } + } + if ($success -and $CloseAfterCopy) { + $previewLifecycle.Result = 'Completed' + $win.Close() } + $success }.GetNewClosure() + $copyBtn.Add_Click({ & $invokeCopy $true | Out-Null }.GetNewClosure()) + $previewContext.SplitControls.Copy.MenuItems.CopyKeepOpen.Add_Click({ + & $invokeCopy $false | Out-Null + }.GetNewClosure()) + $win.FindName('SaveBtn').Add_Click({ + if ($OnOutputStarting) { & $OnOutputStarting 'Save' } + $flat = $null + $success = $false + try { + $flat = Get-FlattenedBitmap + $savedPath = & $previewContext.SaveBitmap $flat + $success = -not [string]::IsNullOrWhiteSpace([string]$savedPath) + } catch { + Write-SnipDiag -Message 'Save failed' -ErrorRecord $_ + } finally { + if ($null -ne $flat -and $flat -ne $Bitmap) { + try { $flat.Dispose() } catch {} + } + if ($OnOutputCompleted) { & $OnOutputCompleted 'Save' $success } + } + }.GetNewClosure()) + $win.FindName('NewBtn').Add_Click({ + $previewLifecycle.Result = 'Preempted' + if ($OnNewSnip) { & $OnNewSnip } + $win.Close() + }.GetNewClosure()) + $win.FindName('CloseBtn').Add_Click({ $win.Close() }) - $openText = { - param([System.Windows.Point]$P) - $b = Get-DisplayedImageBounds - if (-not $b) { return $null } + $script:CurrentPreviewWindow = $win + $previewHelper = New-Object System.Windows.Interop.WindowInteropHelper $win + # SourceInitialized fires once the OS hwnd exists but before the window + # paints. Register here so a window-capture triggered with the preview + # in focus correctly falls back to the virtual desktop. + $win.Add_SourceInitialized({ + Register-SelfWindowHandle -Hwnd $previewHelper.Handle + }.GetNewClosure()) + $previewSurface = [pscustomobject]@{ + Kind = 'Preview' + Window = $win + Close = { + param([string]$result) + $previewLifecycle.Result = $result + $win.Close() + }.GetNewClosure() + } + $win.Add_Closed({ + try { $win.Remove_SizeChanged($responsiveSizeChanged) } catch {} + try { & $cancelDraft } catch {} + try { & $previewContext.CommandRouter.CloseTransientMenus } catch {} + if ([object]::ReferenceEquals($script:ActivePreviewContext, $previewContext)) { + $script:ActivePreviewContext = $null + } + try { Unregister-SelfWindowHandle -Hwnd $previewHelper.Handle } catch {} + $script:CurrentPreviewWindow = $null + # Preview owns the original only after the transfer callback succeeds. + # The one-shot guard protects exceptional close paths from re-disposal. + if ($previewLifecycle.OwnershipAccepted -and -not $previewLifecycle.BitmapDisposed) { + $previewLifecycle.BitmapDisposed = $true + try { if ($Bitmap) { $Bitmap.Dispose() } } + catch { Write-SnipDiag -Message 'Bitmap dispose failed' -ErrorRecord $_ } + } + }.GetNewClosure()) + $previewLifecycle.CleanupInstalled = $true + try { + if ($OnSurfaceReady) { & $OnSurfaceReady $previewSurface } + if ($OnOwnershipAccepted) { & $OnOwnershipAccepted $previewLifecycle } + $previewLifecycle.OwnershipAccepted = $true + } catch { + try { $win.Close() } catch {} + throw + } - # Locals the inner $commit closure needs to capture. PS's chained - # GetNewClosure() does not propagate outer-closure captures into a - # nested .GetNewClosure(), so we must materialize them as real - # locals here before creating $commit. - $stateL = $state - $winL = $win - $hlLayerL = $highlightLayer - $textBtnL = $textBtn - $paletteL = $palette - $bL = $b + if ($TestAction) { + $kit = @{ + Win = $win + Context = $previewContext + Resources = $resources + ResponsiveMode = $previewContext.ModeState + CommandRouter = $previewContext.CommandRouter + StudioRoot = $studioShell.StudioRoot + State = $state + LayoutScale = $layoutScale + Scroller = $scroller + ImageHost = $imageHost + PreviewImage = $previewImage + AnnotationLayer = $annotationLayer + InteractionLayer = $interactionLayer + SelectionLayer = $selectionLayer + HighlightLayer = $highlightLayer + BrandIsland = $studioShell.BrandIsland + ActionsIsland = $studioShell.ActionsIsland + PropertyIsland = $studioShell.PropertyIsland + ToolDock = $studioShell.ToolDock + ViewportIsland = $studioShell.ViewportIsland + StatusIsland = $studioShell.StatusIsland + ActionOrder = [string[]]@('Pin','Save','CopyAndClose','Close') + SplitControls = $previewContext.SplitControls + ToolOrder = $previewContext.ToolOrder + MoreState = $previewContext.MoreState + PropertyState = $previewContext.PropertyState + SetResponsiveMode = $setResponsiveMode + SetPropertyIsland = $setPropertyIsland + SetStudioTool = $setStudioTool + SetSelectedAnnotation = $setSelectedAnnotation + SetStatus = $previewContext.SetStatus + ClearStatus = $previewContext.ClearStatus + GetIslandBounds = $getIslandBounds + ResizeHitTest = $resizeHitTest + ToggleMaximize = $toggleMaximize + BeginWindowDrag = $beginWindowDrag + ShowSystemMenu = $showSystemMenu + ZoomText = $zoomText + HighlightBtn = $highlightBtn + RectBtn = $rectBtn + ArrowBtn = $arrowBtn + TextBtn = $textBtn + Bitmap = $Bitmap + Palette = $palette + SetZoom = $setZoom + ZoomBy = $zoomBy + FitToViewport = $fitToViewport + BeginPan = $beginPan + UpdatePan = $updatePan + EndPan = $endPan + BeginDraw = $beginDraw + UpdateDraw = $updateDraw + FinishDraw = $finishDraw + OpenText = $openText + HandleMouseDown = $handleMouseDown + HandleMouseMove = $handleMouseMove + HandleMouseUp = $handleMouseUp + HandlePreviewKeyDown = $handlePreviewKeyDown + BeginSelectGesture = $beginSelectGesture + UpdateSelectGesture = $updateSelectGesture + CompleteSelectGesture = $completeSelectGesture + CancelSelectGesture = $cancelSelectGesture + BeginCropDraft = $beginCropDraft + GetCropHandleAt = $getCropHandleAt + UpdateCropDraft = $updateCropDraft + ApplyCropDraft = $applyCropDraft + CancelCropDraft = $cancelCropDraft + ResetCrop = $resetCrop + PickColor = $pickColor + Render = ${function:script:Render-Annotations} + Snapshot = ${function:script:Snapshot-State} + Undo = ${function:script:Do-Undo} + Redo = ${function:script:Do-Redo} + FindAt = ${function:script:Find-AnnotationAt} + Flatten = ${function:script:Get-FlattenedBitmap} + Surface = $previewSurface + OwnershipState = $previewLifecycle + } + $script:pwTestError = $null + # Hide off-screen so the window is effectively headless. + $win.WindowStartupLocation = 'Manual' + $win.Left = -5000; $win.Top = -5000 + $win.Add_Loaded({ + try { & $TestAction $kit } + catch { $script:pwTestError = $_ } + finally { try { $win.Close() } catch {} } + }.GetNewClosure()) + $win.ShowDialog() | Out-Null + if ($script:pwTestError) { throw $script:pwTestError } + return $previewLifecycle.Result + } - $tb = New-Object System.Windows.Controls.TextBox - $tb.Background = New-Object System.Windows.Media.SolidColorBrush( - ([System.Windows.Media.Color]::FromArgb(180, 30, 30, 30))) - $rgb = $palette[$state.ActiveColor] - $tb.Foreground = New-Object System.Windows.Media.SolidColorBrush( - (To-WpfColor 255 $rgb.R $rgb.G $rgb.B)) - $tb.BorderBrush = New-Object System.Windows.Media.SolidColorBrush( - (To-WpfColor 200 $rgb.R $rgb.G $rgb.B)) - $tb.BorderThickness = New-Object System.Windows.Thickness 1 - $tb.FontFamily = New-Object System.Windows.Media.FontFamily 'Segoe UI' - $tb.FontWeight = [System.Windows.FontWeights]::SemiBold - $tb.FontSize = 18 - $tb.Padding = New-Object System.Windows.Thickness 4, 1, 4, 1 - $tb.MinWidth = 80 - [System.Windows.Controls.Canvas]::SetLeft($tb, $P.X) - [System.Windows.Controls.Canvas]::SetTop($tb, $P.Y) - [void]$highlightLayer.Children.Add($tb) - $state.EditingText = $true + try { + $win.ShowDialog() | Out-Null + } catch { + $previewLifecycle.Result = 'Failed' + try { $win.Close() } catch {} + } + $script:CurrentPreviewWindow = $null + return $previewLifecycle.Result +} - # Reentrance guard: ClearFocus / Children.Remove can synchronously - # fire LostFocus on the TextBox and recurse back into $commit. Using - # a hashtable field so the mutation propagates across invocations. - $commitGuard = @{ Done = $false } - $commit = { - if ($commitGuard.Done) { return } - $commitGuard.Done = $true - $stateL.EditingText = $false - $text = $tb.Text - try { [System.Windows.Input.Keyboard]::ClearFocus() } catch {} - try { [System.Windows.Input.Mouse]::Capture($null) } catch {} - try { $winL.Focus() | Out-Null } catch {} - [void]$hlLayerL.Children.Remove($tb) - if ([string]::IsNullOrWhiteSpace($text)) { return } - $imgX = [int][math]::Round(($P.X - $bL.X) / $bL.Scale) - $imgY = [int][math]::Round(($P.Y - $bL.Y) / $bL.Scale) - $fontSize = [int][math]::Round(18 / $bL.Scale) - Snapshot-State - [void]$stateL.Annotations.Add([pscustomobject]@{ - Type='text'; Color=$stateL.ActiveColor - X=$imgX; Y=$imgY; W=0; H=0 - Text=$text; FontSize=$fontSize - }) - Render-Annotations - }.GetNewClosure() +# source: src/50-Tray.ps1 +#requires -Version 7.5 +# Generated deterministically from the reviewed SnipIT.ps1 AST by scripts/Export-SnipITModules.ps1. - $tb.Add_KeyDown({ - if ($_.Key -eq 'Enter') { - & $commit - $textBtnL.IsChecked = $false - $_.Handled = $true - } - elseif ($_.Key -eq 'Escape') { - $stateL.EditingText = $false - [void]$hlLayerL.Children.Remove($tb) - $_.Handled = $true - } - }.GetNewClosure()) - $tb.Add_LostFocus({ & $commit }.GetNewClosure()) - try { $tb.Focus() | Out-Null } catch {} - # Tests and external callers can drive commit via $tb.Tag - $tb.Tag = $commit - return $tb - }.GetNewClosure() +$script:WidgetWindow = $null - $finishDraw = { - if (-not $state.Drawing) { return } - $state.Drawing = $false - try { $highlightLayer.ReleaseMouseCapture() } catch {} - $b = Get-DisplayedImageBounds - if (-not $b) { - if ($state.DraftRect) { [void]$highlightLayer.Children.Remove($state.DraftRect) } - $state.DraftRect = $null; return - } - if ($state.DrawingTool -eq 'arrow') { - $line = $state.DraftRect - $dx = $line.X2 - $line.X1; $dy = $line.Y2 - $line.Y1 - if ([math]::Sqrt($dx * $dx + $dy * $dy) -lt 6) { - [void]$highlightLayer.Children.Remove($line); $state.DraftRect = $null; return - } - $x1 = [int][math]::Round(($line.X1 - $b.X) / $b.Scale) - $y1 = [int][math]::Round(($line.Y1 - $b.Y) / $b.Scale) - $x2 = [int][math]::Round(($line.X2 - $b.X) / $b.Scale) - $y2 = [int][math]::Round(($line.Y2 - $b.Y) / $b.Scale) - Snapshot-State - [void]$state.Annotations.Add([pscustomobject]@{ - Type='arrow'; Color=$state.ActiveColor - X=$x1; Y=$y1; W=($x2 - $x1); H=($y2 - $y1) - Text=$null; FontSize=0 - }) - [void]$highlightLayer.Children.Remove($line) - $state.DraftRect = $null - Render-Annotations - return +function Show-SettingsWindow { + [CmdletBinding()] + param( + $Context = $script:UtilityContext, + [scriptblock]$TestAction + ) + + if ($null -eq $Context) { + throw [ArgumentNullException]::new('Context') + } + foreach ($requiredProperty in 'Settings','SettingsPath','RegisteredHotkey','Hwnd') { + if ($null -eq $Context.PSObject.Properties[$requiredProperty]) { + throw [ArgumentException]::new("Settings context is missing '$requiredProperty'.", 'Context') } - if ($state.DraftRect.Width -lt 3 -or $state.DraftRect.Height -lt 3) { - [void]$highlightLayer.Children.Remove($state.DraftRect) - $state.DraftRect = $null; return + } + + $highContrastProperty = $Context.PSObject.Properties['HighContrast'] + $highContrast = if ($null -ne $highContrastProperty) { + [bool]$highContrastProperty.Value + } else { + [bool][System.Windows.SystemParameters]::HighContrast + } + $themeParameters = @{ HighContrast = $highContrast } + $animationsProperty = $Context.PSObject.Properties['AnimationsEnabled'] + if ($null -ne $animationsProperty) { + $themeParameters.AnimationsEnabled = [bool]$animationsProperty.Value + } + $resources = New-SnipThemeResources @themeParameters + + [xml]$xaml = [xml](Get-SnipXamlText -Name 'SettingsWindow') + $reader = [System.Xml.XmlNodeReader]::new($xaml) + try { + $window = [System.Windows.Markup.XamlReader]::Load($reader) + } finally { + $reader.Dispose() + } + Add-SnipThemeResources -Root $window -Resources $resources | Out-Null + + $dragHeader = $window.FindName('DragHeader') + $headerCloseButton = $window.FindName('HeaderCloseBtn') + $shortcutRecorder = $window.FindName('ShortcutRecorder') + $shortcutText = $window.FindName('ShortcutText') + $saveFolderBox = $window.FindName('SaveFolderBox') + $launchCheck = $window.FindName('LaunchCheck') + $widgetCheck = $window.FindName('WidgetCheck') + $errorText = $window.FindName('ErrorText') + $cancelButton = $window.FindName('CancelBtn') + $saveButton = $window.FindName('SaveBtn') + + $saveFolderBox.Text = [string]$Context.Settings.SaveFolder + $launchCheck.IsChecked = [bool]$Context.Settings.LaunchAtSignIn + $widgetCheck.IsChecked = [bool]$Context.Settings.WidgetVisible + $formatActiveShortcut = { + param($binding) + if ($null -eq $binding) { return 'Unavailable' } + Format-SnipHotkey -Modifiers ([int]$binding.Modifiers) -VirtualKey ([int]$binding.VirtualKey) + } + $shortcutText.Text = & $formatActiveShortcut $Context.RegisteredHotkey + + $registerProperty = $Context.PSObject.Properties['RegisterHotkey'] + $registerHotkey = if ($null -ne $registerProperty -and $registerProperty.Value -is [scriptblock]) { + $registerProperty.Value + } else { + { param($hwnd,$id,$modifiers,$virtualKey) [Native]::RegisterHotKey($hwnd,$id,$modifiers,$virtualKey) } + } + $unregisterProperty = $Context.PSObject.Properties['UnregisterHotkey'] + $unregisterHotkey = if ($null -ne $unregisterProperty -and $unregisterProperty.Value -is [scriptblock]) { + $unregisterProperty.Value + } else { + { param($hwnd,$id) [Native]::UnregisterHotKey($hwnd,$id) } + } + $setFeedback = { + param([string]$Message, [ValidateSet('Error','Success','Neutral')] [string]$Kind = 'Error') + $errorText.Text = $Message + $brushKey = switch ($Kind) { + 'Success' { 'SnipMintBrush' } + 'Neutral' { 'SnipSecondaryTextBrush' } + default { 'SnipCoralBrush' } } - $canvasX = [System.Windows.Controls.Canvas]::GetLeft($state.DraftRect) - $canvasY = [System.Windows.Controls.Canvas]::GetTop($state.DraftRect) - $rawX = [int][math]::Round(($canvasX - $b.X) / $b.Scale) - $rawY = [int][math]::Round(($canvasY - $b.Y) / $b.Scale) - $rawW = [int][math]::Round($state.DraftRect.Width / $b.Scale) - $rawH = [int][math]::Round($state.DraftRect.Height / $b.Scale) - $clamped = Get-ClampedAnnotationRect -X $rawX -Y $rawY -Width $rawW -Height $rawH ` - -BitmapWidth $Bitmap.Width -BitmapHeight $Bitmap.Height - Snapshot-State - [void]$state.Annotations.Add([pscustomobject]@{ - Type=$state.DrawingTool; Color=$state.ActiveColor - X=$clamped.X; Y=$clamped.Y; W=$clamped.Width; H=$clamped.Height - Text=$null; FontSize=0 - }) - [void]$highlightLayer.Children.Remove($state.DraftRect) - $state.DraftRect = $null - Render-Annotations + $errorText.Foreground = $window.Resources[$brushKey] }.GetNewClosure() + $recordShortcut = { + param([int]$Modifiers, [int]$VirtualKey) + + $result = Set-SnipHotkeyBinding -Context $Context ` + -Candidate ([pscustomobject]@{ Modifiers = $Modifiers; VirtualKey = $VirtualKey }) ` + -Register $registerHotkey -Unregister $unregisterHotkey + $shortcutText.Text = & $formatActiveShortcut $result.ActiveBinding + if ($result.Success) { + & $setFeedback '' Neutral + } elseif ($result.RollbackError -and $null -ne $result.ActiveBinding) { + & $setFeedback ("{0} {1} Retry with another shortcut or close SnipIT to retry cleanup." -f + $result.CandidateError, $result.RollbackError) Error + } elseif ($result.RollbackError) { + & $setFeedback ("{0} {1} No global shortcut is active; capture remains available from the tray." -f + $result.CandidateError, $result.RollbackError) Error + } elseif ($null -ne $result.ActiveBinding) { + & $setFeedback ("{0}; previous shortcut remains active." -f $result.CandidateError) Error + } else { + & $setFeedback ("{0} No global shortcut is active; capture remains available from the tray." -f + $result.CandidateError) Error + } - # ---- Mouse interactions on the highlight layer ---- - # Named dispatcher for MouseLeftButtonDown so tests can drive it with - # synthetic points. Event handler below is a thin wrapper. - $handleMouseDown = { + $hotkeyChangedProperty = $Context.PSObject.Properties['OnHotkeyChanged'] + if ($null -ne $hotkeyChangedProperty -and $hotkeyChangedProperty.Value -is [scriptblock]) { + & $hotkeyChangedProperty.Value $result | Out-Null + } + $result + }.GetNewClosure() + $recorderState = [pscustomobject]@{ LastHandled = $false } + $recordKey = { param( - [System.Windows.Point]$HlPoint, - [System.Windows.Point]$SvPoint + [System.Windows.Input.Key]$Key, + [System.Windows.Input.Key]$SystemKey, + [System.Windows.Input.ModifierKeys]$Modifiers ) - if ($state.EditingText) { return } - $anyTool = $highlightBtn.IsChecked -or $rectBtn.IsChecked -or - $arrowBtn.IsChecked -or $textBtn.IsChecked - if (-not $anyTool) { - & $beginPan $SvPoint - return + $recorderState.LastHandled = $false + $resolvedKey = if ($Key -eq [System.Windows.Input.Key]::System) { $SystemKey } else { $Key } + if ($resolvedKey -eq [System.Windows.Input.Key]::Tab) { + return $null + } + $recorderState.LastHandled = $true + if ($resolvedKey -in @( + [System.Windows.Input.Key]::LeftCtrl, [System.Windows.Input.Key]::RightCtrl, + [System.Windows.Input.Key]::LeftAlt, [System.Windows.Input.Key]::RightAlt, + [System.Windows.Input.Key]::LeftShift, [System.Windows.Input.Key]::RightShift, + [System.Windows.Input.Key]::LWin, [System.Windows.Input.Key]::RWin)) { + return $null } - $b = Get-DisplayedImageBounds; if (-not $b) { return } - $p = $HlPoint - if ($p.X -lt $b.X -or $p.Y -lt $b.Y -or - $p.X -gt $b.X + $b.W -or $p.Y -gt $b.Y + $b.H) { return } + $modifierBits = 0x4000 + if (($Modifiers -band [System.Windows.Input.ModifierKeys]::Control) -ne 0) { $modifierBits = $modifierBits -bor 0x2 } + if (($Modifiers -band [System.Windows.Input.ModifierKeys]::Alt) -ne 0) { $modifierBits = $modifierBits -bor 0x1 } + if (($Modifiers -band [System.Windows.Input.ModifierKeys]::Shift) -ne 0) { $modifierBits = $modifierBits -bor 0x4 } + if (($Modifiers -band [System.Windows.Input.ModifierKeys]::Windows) -ne 0) { $modifierBits = $modifierBits -bor 0x8 } + $virtualKey = [System.Windows.Input.KeyInterop]::VirtualKeyFromKey($resolvedKey) + & $recordShortcut $modifierBits $virtualKey + }.GetNewClosure() + $saveChanges = { + $previousSettings = [pscustomobject][ordered]@{ + Version = $Context.Settings.Version + Hotkey = $Context.Settings.Hotkey + SaveFolder = $Context.Settings.SaveFolder + SaveFormat = $Context.Settings.SaveFormat + LaunchAtSignIn = $Context.Settings.LaunchAtSignIn + WidgetVisible = $Context.Settings.WidgetVisible + } + $candidatePersisted = $false + $startupAttempted = $false + $widgetAttempted = $false + try { + $folder = $saveFolderBox.Text.Trim() + if ([string]::IsNullOrWhiteSpace($folder)) { + throw [ArgumentException]::new('Choose a default save folder.') + } + $updatedSettings = [pscustomobject][ordered]@{ + Version = $Context.Settings.Version + Hotkey = $Context.Settings.Hotkey + SaveFolder = $folder + SaveFormat = $Context.Settings.SaveFormat + LaunchAtSignIn = [bool]$launchCheck.IsChecked + WidgetVisible = [bool]$widgetCheck.IsChecked + } + Save-SnipSettings -Settings $updatedSettings -Path ([string]$Context.SettingsPath) + $candidatePersisted = $true + + $Context.Settings.SaveFolder = $updatedSettings.SaveFolder + $Context.Settings.LaunchAtSignIn = $updatedSettings.LaunchAtSignIn + $Context.Settings.WidgetVisible = $updatedSettings.WidgetVisible + $syncProperty = $Context.PSObject.Properties['SyncStartup'] + if ($null -ne $syncProperty -and $syncProperty.Value -is [scriptblock]) { + $startupAttempted = $true + & $syncProperty.Value $Context.Settings | Out-Null + } + $widgetProperty = $Context.PSObject.Properties['SetWidgetVisible'] + if ($null -ne $widgetProperty -and $widgetProperty.Value -is [scriptblock]) { + $widgetAttempted = $true + & $widgetProperty.Value ([bool]$Context.Settings.WidgetVisible) | Out-Null + } + & $setFeedback 'Settings saved.' Success + return $true + } catch { + $failureMessage = $_.Exception.Message + $rollbackErrors = [System.Collections.Generic.List[string]]::new() + try { + $Context.Settings.SaveFolder = $previousSettings.SaveFolder + $Context.Settings.LaunchAtSignIn = $previousSettings.LaunchAtSignIn + $Context.Settings.WidgetVisible = $previousSettings.WidgetVisible + } catch { + $rollbackErrors.Add("in-memory settings: $($_.Exception.Message)") + } + if ($startupAttempted) { + try { & $syncProperty.Value $previousSettings | Out-Null } + catch { $rollbackErrors.Add("launch-at-sign-in: $($_.Exception.Message)") } + } + if ($widgetAttempted) { + try { & $widgetProperty.Value ([bool]$previousSettings.WidgetVisible) | Out-Null } + catch { $rollbackErrors.Add("widget visibility: $($_.Exception.Message)") } + } + if ($candidatePersisted) { + try { + Save-SnipSettings -Settings $previousSettings -Path ([string]$Context.SettingsPath) + } catch { + $rollbackErrors.Add("settings file: $($_.Exception.Message)") + } + } + if ($rollbackErrors.Count -eq 0) { + & $setFeedback ("Settings could not be saved: {0} Previous settings were restored." -f + $failureMessage) Error + } else { + & $setFeedback ("Settings could not be saved: {0} Previous settings could not be fully restored: {1}" -f + $failureMessage, ($rollbackErrors -join '; ')) Error + } + return $false + } + }.GetNewClosure() + + $surfaceState = [pscustomobject]@{ Result = 'UserCancelled'; Published = $false } + $surface = [pscustomobject]@{ Window = $window; RequestedResult = 'UserCancelled'; Close = $null } + $closeSurface = { + param([string]$Result = 'Preempted') + $surfaceState.Result = $Result + $surface.RequestedResult = $Result + if ($window.IsVisible) { $window.Close() } + }.GetNewClosure() + $surface.Close = $closeSurface - $tool = $null - if ($highlightBtn.IsChecked) { $tool = 'highlight' } - elseif ($rectBtn.IsChecked) { $tool = 'rect' } - elseif ($arrowBtn.IsChecked) { $tool = 'arrow' } + $lifecycleParameters = @{ Window = $window } + $registerWindowProperty = $Context.PSObject.Properties['RegisterWindow'] + $unregisterWindowProperty = $Context.PSObject.Properties['UnregisterWindow'] + if ($null -ne $registerWindowProperty -and $registerWindowProperty.Value -is [scriptblock]) { + $lifecycleParameters.Register = $registerWindowProperty.Value + } + if ($null -ne $unregisterWindowProperty -and $unregisterWindowProperty.Value -is [scriptblock]) { + $lifecycleParameters.Unregister = $unregisterWindowProperty.Value + } + $lifecycle = Connect-SnipWindowLifecycle @lifecycleParameters + $surfaceReadyProperty = $Context.PSObject.Properties['OnSurfaceReady'] + $surfaceReadyHandler = [EventHandler]{ + param($sender, $eventArgs) + if (-not $surfaceState.Published -and $null -ne $surfaceReadyProperty -and + $surfaceReadyProperty.Value -is [scriptblock]) { + $surfaceState.Published = $true + & $surfaceReadyProperty.Value $surface | Out-Null + } + }.GetNewClosure() - if ($tool) { - & $beginDraw $tool $p + $recorderKeyHandler = [System.Windows.Input.KeyEventHandler]{ + param($sender, $eventArgs) + & $recordKey $eventArgs.Key $eventArgs.SystemKey ([System.Windows.Input.Keyboard]::Modifiers) | Out-Null + $eventArgs.Handled = [bool]$recorderState.LastHandled + }.GetNewClosure() + $windowKeyHandler = [System.Windows.Input.KeyEventHandler]{ + param($sender, $eventArgs) + if ($eventArgs.Key -eq [System.Windows.Input.Key]::Escape) { + $surfaceState.Result = 'UserCancelled' + $window.Close() + $eventArgs.Handled = $true } - elseif ($textBtn.IsChecked) { - & $openText $p + }.GetNewClosure() + $dragHandler = [System.Windows.Input.MouseButtonEventHandler]{ + param($sender, $eventArgs) + if (-not $headerCloseButton.IsMouseOver -and + $eventArgs.ChangedButton -eq [System.Windows.Input.MouseButton]::Left) { + try { $window.DragMove() } catch {} } }.GetNewClosure() + $closeClickHandler = [System.Windows.RoutedEventHandler]{ + param($sender, $eventArgs) + $surfaceState.Result = 'UserCancelled' + $window.Close() + }.GetNewClosure() + $saveClickHandler = [System.Windows.RoutedEventHandler]{ param($sender,$eventArgs) & $saveChanges | Out-Null }.GetNewClosure() + $window.Add_SourceInitialized($surfaceReadyHandler) + $shortcutRecorder.Add_PreviewKeyDown($recorderKeyHandler) + $window.Add_KeyDown($windowKeyHandler) + $dragHeader.Add_MouseLeftButtonDown($dragHandler) + $headerCloseButton.Add_Click($closeClickHandler) + $cancelButton.Add_Click($closeClickHandler) + $saveButton.Add_Click($saveClickHandler) + + $kit = [pscustomobject]@{ + Window = $window + Resources = $resources + Lifecycle = $lifecycle + ShortcutRecorder = $shortcutRecorder + ShortcutText = $shortcutText + SaveFolderBox = $saveFolderBox + LaunchCheck = $launchCheck + WidgetCheck = $widgetCheck + ErrorText = $errorText + RecordShortcut = $recordShortcut + RecordKey = $recordKey + RecorderState = $recorderState + SaveChanges = $saveChanges + Close = $closeSurface + } - $highlightLayer.Add_MouseLeftButtonDown({ - & $handleMouseDown ($_.GetPosition($highlightLayer)) ($_.GetPosition($scroller)) - if ($state.Panning -or $state.Drawing -or $state.EditingText) { - $_.Handled = $true + try { + if ($TestAction) { + $window.WindowStartupLocation = 'Manual' + $window.Left = -10000 + $window.Top = -10000 + $window.ShowActivated = $false + $window.Show() + $window.UpdateLayout() + & $TestAction $kit | Out-Null + if ($window.IsVisible) { $window.Close() } + } else { + $window.ShowDialog() | Out-Null } - }.GetNewClosure()) - - $highlightLayer.Add_MouseMove({ - if ($state.Panning) { & $updatePan ($_.GetPosition($scroller)); return } - if (-not $state.Drawing) { return } - & $updateDraw ($_.GetPosition($highlightLayer)) - }.GetNewClosure()) - - $highlightLayer.Add_MouseLeftButtonUp({ - if ($state.Panning) { & $endPan; return } - if (-not $state.Drawing) { return } - & $finishDraw - }.GetNewClosure()) - - # Re-render on resize (ImageHost growing/shrinking with zoom) - $imageHost.Add_SizeChanged({ Render-Annotations }) - - # Hit-test helper: returns the topmost annotation index under a canvas point, or -1 - function script:Find-AnnotationAt { - param([double]$CanvasX, [double]$CanvasY) - $bb = Get-DisplayedImageBounds - if (-not $bb) { return -1 } - for ($i = $state.Annotations.Count - 1; $i -ge 0; $i--) { - $a = $state.Annotations[$i] - if ($a.Type -eq 'highlight' -or $a.Type -eq 'rect' -or $a.Type -eq 'arrow') { - # For arrow, W/H can be negative; normalize. - $minX = $a.X; $maxX = $a.X + $a.W - $minY = $a.Y; $maxY = $a.Y + $a.H - if ($minX -gt $maxX) { $t = $minX; $minX = $maxX; $maxX = $t } - if ($minY -gt $maxY) { $t = $minY; $minY = $maxY; $maxY = $t } - # Small padding for thin arrows - if ($a.Type -eq 'arrow') { $minX -= 6; $maxX += 6; $minY -= 6; $maxY += 6 } - $x1 = $bb.X + $minX * $bb.Scale - $y1 = $bb.Y + $minY * $bb.Scale - $x2 = $bb.X + $maxX * $bb.Scale - $y2 = $bb.Y + $maxY * $bb.Scale - } else { - # Rough text bounding box. FontSize is in image pixels. - $approxW = [math]::Max(20, $a.Text.Length * $a.FontSize * 0.6) - $approxH = $a.FontSize * 1.3 - $x1 = $bb.X + $a.X * $bb.Scale - $y1 = $bb.Y + $a.Y * $bb.Scale - $x2 = $x1 + $approxW * $bb.Scale - $y2 = $y1 + $approxH * $bb.Scale + } catch { + $surfaceState.Result = 'Failed' + throw + } finally { + try { + if ($window.IsVisible) { $window.Close() } + $window.Remove_SourceInitialized($surfaceReadyHandler) + $shortcutRecorder.Remove_PreviewKeyDown($recorderKeyHandler) + $window.Remove_KeyDown($windowKeyHandler) + $dragHeader.Remove_MouseLeftButtonDown($dragHandler) + $headerCloseButton.Remove_Click($closeClickHandler) + $cancelButton.Remove_Click($closeClickHandler) + $saveButton.Remove_Click($saveClickHandler) + } finally { + $surfaceClosedProperty = $Context.PSObject.Properties['OnSurfaceClosed'] + if ($null -ne $surfaceClosedProperty -and + $surfaceClosedProperty.Value -is [scriptblock]) { + & $surfaceClosedProperty.Value $surfaceState.Result $surface | Out-Null } - if ($CanvasX -ge $x1 -and $CanvasX -le $x2 -and - $CanvasY -ge $y1 -and $CanvasY -le $y2) { return $i } } - return -1 } + $surfaceState.Result +} - # Right-click an existing annotation → color/delete context menu - $highlightLayer.Add_MouseRightButtonDown({ - if ($state.EditingText) { return } - $p = $_.GetPosition($highlightLayer) - $idx = Find-AnnotationAt -CanvasX $p.X -CanvasY $p.Y - if ($idx -lt 0) { return } - $_.Handled = $true - - # Local aliases so menu-item closures can find them - $stateL = $state - $paletteL = $palette - $idxL = $idx +function Show-AboutWindow { + [CmdletBinding()] + param( + $Context = $script:UtilityContext, + [scriptblock]$TestAction + ) - $menu = New-Object System.Windows.Controls.ContextMenu - foreach ($name in $paletteL.Keys) { - $rgb = $paletteL[$name] - $mi = New-Object System.Windows.Controls.MenuItem - $mi.Header = $name - $swatch = New-Object System.Windows.Shapes.Rectangle - $swatch.Width = 14; $swatch.Height = 14 - $swatch.Fill = New-Object System.Windows.Media.SolidColorBrush( - ([System.Windows.Media.Color]::FromArgb(255, $rgb.R, $rgb.G, $rgb.B))) - $mi.Icon = $swatch - $nameL = $name - $mi.Add_Click({ - Snapshot-State - $stateL.Annotations[$idxL].Color = $nameL - Render-Annotations - }.GetNewClosure()) - [void]$menu.Items.Add($mi) + if ($null -eq $Context) { $Context = [pscustomobject]@{} } + $appVersionProperty = $Context.PSObject.Properties['AppVersion'] + $repositoryProperty = $Context.PSObject.Properties['Repository'] + $licenseProperty = $Context.PSObject.Properties['License'] + $hotkeyProperty = $Context.PSObject.Properties['RegisteredHotkey'] + $activeBinding = if ($null -ne $hotkeyProperty) { $hotkeyProperty.Value } else { $null } + $metadata = [pscustomobject][ordered]@{ + Version = if ($null -ne $appVersionProperty -and + -not [string]::IsNullOrWhiteSpace([string]$appVersionProperty.Value)) { + [string]$appVersionProperty.Value + } else { $script:SnipITAppVersion } + PowerShell = $PSVersionTable.PSVersion.ToString() + DotNet = [Environment]::Version.ToString() + Repository = if ($null -ne $repositoryProperty -and + -not [string]::IsNullOrWhiteSpace([string]$repositoryProperty.Value)) { + [string]$repositoryProperty.Value + } else { 'https://github.com/RandomCodeSpace/snipIT' } + License = if ($null -ne $licenseProperty -and + -not [string]::IsNullOrWhiteSpace([string]$licenseProperty.Value)) { + [string]$licenseProperty.Value + } else { 'MIT License' } + ActiveShortcut = if ($null -eq $activeBinding) { + 'Unavailable' + } else { + Format-SnipHotkey -Modifiers ([int]$activeBinding.Modifiers) ` + -VirtualKey ([int]$activeBinding.VirtualKey) } - [void]$menu.Items.Add((New-Object System.Windows.Controls.Separator)) - $delMi = New-Object System.Windows.Controls.MenuItem - $delMi.Header = 'Delete' - $delMi.Add_Click({ - Snapshot-State - $stateL.Annotations.RemoveAt($idxL) - Render-Annotations - }.GetNewClosure()) - [void]$menu.Items.Add($delMi) - - $menu.PlacementTarget = $highlightLayer - $menu.IsOpen = $true - }) - - # Toolbar buttons - $win.FindName('ClearBtn').Add_Click({ - if ($state.Annotations.Count -eq 0) { return } - Snapshot-State - $state.Annotations.Clear() - Render-Annotations - }) - $win.FindName('UndoBtn').Add_Click({ Do-Undo }) - $win.FindName('RedoBtn').Add_Click({ Do-Redo }) + } - # Chromeless: header bar drags the window, but skip if the click landed - # on a button / togglebutton in the header (otherwise DragMove hijacks the - # mouse before the button's click release). - $win.FindName('DragHeader').Add_MouseLeftButtonDown({ - $src = $_.OriginalSource - $p = $src - while ($p -and -not ($p -is [System.Windows.Controls.Primitives.ButtonBase])) { - $p = [System.Windows.Media.VisualTreeHelper]::GetParent($p) + $highContrastProperty = $Context.PSObject.Properties['HighContrast'] + $highContrast = if ($null -ne $highContrastProperty) { + [bool]$highContrastProperty.Value + } else { + [bool][System.Windows.SystemParameters]::HighContrast + } + $themeParameters = @{ HighContrast = $highContrast } + $animationsProperty = $Context.PSObject.Properties['AnimationsEnabled'] + if ($null -ne $animationsProperty) { $themeParameters.AnimationsEnabled = [bool]$animationsProperty.Value } + $resources = New-SnipThemeResources @themeParameters + + [xml]$xaml = [xml](Get-SnipXamlText -Name 'AboutWindow') + $reader = [System.Xml.XmlNodeReader]::new($xaml) + try { $window = [System.Windows.Markup.XamlReader]::Load($reader) } + finally { $reader.Dispose() } + Add-SnipThemeResources -Root $window -Resources $resources | Out-Null + + $window.FindName('VersionText').Text = $metadata.Version + $window.FindName('PowerShellText').Text = $metadata.PowerShell + $window.FindName('DotNetText').Text = $metadata.DotNet + $window.FindName('ShortcutText').Text = $metadata.ActiveShortcut + $window.FindName('RepositoryText').Text = $metadata.Repository + $window.FindName('LicenseText').Text = $metadata.License + $dragHeader = $window.FindName('DragHeader') + $headerCloseButton = $window.FindName('HeaderCloseBtn') + $closeButton = $window.FindName('CloseBtn') + + $lifecycleParameters = @{ Window = $window } + $registerWindowProperty = $Context.PSObject.Properties['RegisterWindow'] + $unregisterWindowProperty = $Context.PSObject.Properties['UnregisterWindow'] + if ($null -ne $registerWindowProperty -and $registerWindowProperty.Value -is [scriptblock]) { + $lifecycleParameters.Register = $registerWindowProperty.Value + } + if ($null -ne $unregisterWindowProperty -and $unregisterWindowProperty.Value -is [scriptblock]) { + $lifecycleParameters.Unregister = $unregisterWindowProperty.Value + } + $lifecycle = Connect-SnipWindowLifecycle @lifecycleParameters + $surfaceState = [pscustomobject]@{ Result = 'UserCancelled'; Published = $false } + $surface = [pscustomobject]@{ Window = $window; RequestedResult = 'UserCancelled'; Close = $null } + $closeSurface = { + param([string]$Result = 'Preempted') + $surfaceState.Result = $Result + $surface.RequestedResult = $Result + if ($window.IsVisible) { $window.Close() } + }.GetNewClosure() + $surface.Close = $closeSurface + $surfaceReadyProperty = $Context.PSObject.Properties['OnSurfaceReady'] + $surfaceReadyHandler = [EventHandler]{ + param($sender,$eventArgs) + if (-not $surfaceState.Published -and $null -ne $surfaceReadyProperty -and + $surfaceReadyProperty.Value -is [scriptblock]) { + $surfaceState.Published = $true + & $surfaceReadyProperty.Value $surface | Out-Null } - if ($p -is [System.Windows.Controls.Primitives.ButtonBase]) { return } - if ($_.ClickCount -eq 2) { - $win.WindowState = if ($win.WindowState -eq 'Maximized') { 'Normal' } else { 'Maximized' } + }.GetNewClosure() + $closeClickHandler = [System.Windows.RoutedEventHandler]{ + param($sender,$eventArgs) + $surfaceState.Result = 'UserCancelled' + $window.Close() + }.GetNewClosure() + $keyHandler = [System.Windows.Input.KeyEventHandler]{ + param($sender,$eventArgs) + if ($eventArgs.Key -eq [System.Windows.Input.Key]::Escape) { + $surfaceState.Result = 'UserCancelled' + $window.Close() + $eventArgs.Handled = $true + } + }.GetNewClosure() + $dragHandler = [System.Windows.Input.MouseButtonEventHandler]{ + param($sender,$eventArgs) + if (-not $headerCloseButton.IsMouseOver -and + $eventArgs.ChangedButton -eq [System.Windows.Input.MouseButton]::Left) { + try { $window.DragMove() } catch {} + } + }.GetNewClosure() + $window.Add_SourceInitialized($surfaceReadyHandler) + $window.Add_KeyDown($keyHandler) + $dragHeader.Add_MouseLeftButtonDown($dragHandler) + $headerCloseButton.Add_Click($closeClickHandler) + $closeButton.Add_Click($closeClickHandler) + + $kit = [pscustomobject]@{ + Window = $window + Resources = $resources + Lifecycle = $lifecycle + Metadata = $metadata + Close = $closeSurface + } + try { + if ($TestAction) { + $window.WindowStartupLocation = 'Manual' + $window.Left = -10000 + $window.Top = -10000 + $window.ShowActivated = $false + $window.Show() + $window.UpdateLayout() + & $TestAction $kit | Out-Null + if ($window.IsVisible) { $window.Close() } } else { - $win.DragMove() + $window.ShowDialog() | Out-Null } - }) - - # Always-on-top pin - $pinBtn = $win.FindName('PinBtn') - $pinBtn.Add_Checked({ $win.Topmost = $true }) - $pinBtn.Add_Unchecked({ $win.Topmost = $false }) + } catch { + $surfaceState.Result = 'Failed' + throw + } finally { + try { + if ($window.IsVisible) { $window.Close() } + $window.Remove_SourceInitialized($surfaceReadyHandler) + $window.Remove_KeyDown($keyHandler) + $dragHeader.Remove_MouseLeftButtonDown($dragHandler) + $headerCloseButton.Remove_Click($closeClickHandler) + $closeButton.Remove_Click($closeClickHandler) + } finally { + $surfaceClosedProperty = $Context.PSObject.Properties['OnSurfaceClosed'] + if ($null -ne $surfaceClosedProperty -and + $surfaceClosedProperty.Value -is [scriptblock]) { + & $surfaceClosedProperty.Value $surfaceState.Result $surface | Out-Null + } + } + } + $surfaceState.Result +} - # Zoom controls. Uses LayoutTransform on ImageHost. The ScaleTransform - # itself is the single source of truth for the current zoom — reading - # $layoutScale.ScaleX through a captured object reference is immune to - # the PS-scope / closure quirks that broke prior attempts using - # $script: or $Global: variables inside WPF event handlers. - # ($scroller and $zoomText already resolved near the top of this function) +function Clear-SnipWidgetWindow { + param([System.Windows.Window]$Window) + if ($script:WidgetWindow -eq $Window) { $script:WidgetWindow = $null } +} - $highlightLayer.Width = $Bitmap.Width - $highlightLayer.Height = $Bitmap.Height +function Show-FloatingWidget { + [CmdletBinding()] + param( + $Context = $script:UtilityContext, + [scriptblock]$TestAction + ) - $layoutScale = New-Object System.Windows.Media.ScaleTransform 1, 1 - $imageHost.LayoutTransform = $layoutScale + if ($null -eq $Context -or $null -eq $Context.PSObject.Properties['Settings']) { + throw [ArgumentException]::new('The widget requires a settings context.', 'Context') + } + if (-not [bool]$Context.Settings.WidgetVisible) { + if ($script:WidgetWindow -and $script:WidgetWindow.IsVisible) { + $script:WidgetWindow.Close() + } + return $null + } + if ($script:WidgetWindow -and -not $TestAction) { + if (-not $script:WidgetWindow.IsVisible) { $script:WidgetWindow.Show() } + return $script:WidgetWindow + } - $setZoom = { - param([double]$s) - # NB: literal doubles required. [math]::Min(10, 1.25) resolves to - # the Min(int,int) overload in PowerShell and truncates to 1. - $s = [math]::Max(0.05, [math]::Min(10.0, $s)) - $layoutScale.ScaleX = $s - $layoutScale.ScaleY = $s - $imageHost.InvalidateMeasure() - $imageHost.UpdateLayout() - try { $scroller.InvalidateScrollInfo() } catch {} - if ($zoomText) { $zoomText.Text = '{0:P0}' -f $s } + $submitProperty = $Context.PSObject.Properties['SubmitRequest'] + $settingsProperty = $Context.PSObject.Properties['OpenSettings'] + if ($null -eq $submitProperty -or $submitProperty.Value -isnot [scriptblock]) { + throw [ArgumentException]::new('The widget requires a SubmitRequest service.', 'Context') + } + if ($null -eq $settingsProperty -or $settingsProperty.Value -isnot [scriptblock]) { + throw [ArgumentException]::new('The widget requires an OpenSettings service.', 'Context') + } + $submitRequest = $submitProperty.Value + $openSettings = $settingsProperty.Value + + $highContrastProperty = $Context.PSObject.Properties['HighContrast'] + $highContrast = if ($null -ne $highContrastProperty) { + [bool]$highContrastProperty.Value + } else { [bool][System.Windows.SystemParameters]::HighContrast } + $themeParameters = @{ HighContrast = $highContrast } + $animationsProperty = $Context.PSObject.Properties['AnimationsEnabled'] + if ($null -ne $animationsProperty) { $themeParameters.AnimationsEnabled = [bool]$animationsProperty.Value } + $resources = New-SnipThemeResources @themeParameters + + [xml]$xaml = [xml](Get-SnipXamlText -Name 'FloatingWidget') + $reader = [System.Xml.XmlNodeReader]::new($xaml) + try { $window = [System.Windows.Markup.XamlReader]::Load($reader) } + finally { $reader.Dispose() } + Add-SnipThemeResources -Root $window -Resources $resources | Out-Null + + $workArea = [System.Windows.SystemParameters]::WorkArea + $window.Left = $workArea.Left + (($workArea.Width - $window.Width) / 2) + $shownTop = $workArea.Top + 10 + $hiddenTop = $workArea.Top - $window.Height + 9 + $window.Top = $hiddenTop + $widgetState = [pscustomobject]@{ + LastAnimationDuration = [timespan]::Zero + Closed = $false + ClosedHandler = $null + } + $moveWidget = { + param([double]$TargetTop) + if ([bool]$resources['SnipAnimationsEnabled']) { + $duration = [timespan]$resources['SnipMotionDuration'] + $animation = [System.Windows.Media.Animation.DoubleAnimation]::new() + $animation.From = $window.Top + $animation.To = $TargetTop + $animation.Duration = [System.Windows.Duration]::new($duration) + $animation.FillBehavior = [System.Windows.Media.Animation.FillBehavior]::Stop + $ease = [System.Windows.Media.Animation.CubicEase]::new() + $ease.EasingMode = [System.Windows.Media.Animation.EasingMode]::EaseOut + $animation.EasingFunction = $ease + $window.Top = $TargetTop + $window.BeginAnimation([System.Windows.Window]::TopProperty, $animation) + $widgetState.LastAnimationDuration = $duration + } else { + $window.BeginAnimation([System.Windows.Window]::TopProperty, $null) + $window.Top = $TargetTop + $widgetState.LastAnimationDuration = [timespan]::Zero + } }.GetNewClosure() - - $zoomBy = { - param([double]$factor) - & $setZoom ($layoutScale.ScaleX * $factor) + $reveal = { & $moveWidget $shownTop }.GetNewClosure() + $conceal = { & $moveWidget $hiddenTop }.GetNewClosure() + + $buttons = [ordered]@{ + SmartBtn = $window.FindName('SmartBtn') + FullBtn = $window.FindName('FullBtn') + WindowBtn = $window.FindName('WindowBtn') + SettingsBtn = $window.FindName('SettingsBtn') + } + $activeShortcutProperty = $Context.PSObject.Properties['ActiveShortcut'] + $activeShortcut = if ($null -ne $activeShortcutProperty -and + -not [string]::IsNullOrWhiteSpace([string]$activeShortcutProperty.Value)) { + [string]$activeShortcutProperty.Value + } elseif ($null -ne $Context.PSObject.Properties['RegisteredHotkey'] -and + $null -ne $Context.RegisteredHotkey) { + Format-SnipHotkey -Modifiers ([int]$Context.RegisteredHotkey.Modifiers) ` + -VirtualKey ([int]$Context.RegisteredHotkey.VirtualKey) + } else { 'Unavailable' } + $buttons.SmartBtn.ToolTip = "Smart capture ($activeShortcut)" + $smartClick = [System.Windows.RoutedEventHandler]{ param($sender,$eventArgs) & $submitRequest 'Smart' ([timespan]::Zero) 'Widget' | Out-Null }.GetNewClosure() + $fullClick = [System.Windows.RoutedEventHandler]{ param($sender,$eventArgs) & $submitRequest 'Full' ([timespan]::Zero) 'Widget' | Out-Null }.GetNewClosure() + $windowClick = [System.Windows.RoutedEventHandler]{ param($sender,$eventArgs) & $submitRequest 'Window' ([timespan]::Zero) 'Widget' | Out-Null }.GetNewClosure() + $settingsClick = [System.Windows.RoutedEventHandler]{ param($sender,$eventArgs) & $openSettings | Out-Null }.GetNewClosure() + $buttons.SmartBtn.Add_Click($smartClick) + $buttons.FullBtn.Add_Click($fullClick) + $buttons.WindowBtn.Add_Click($windowClick) + $buttons.SettingsBtn.Add_Click($settingsClick) + + $mouseEnterHandler = [System.Windows.Input.MouseEventHandler]{ param($sender,$eventArgs) & $reveal }.GetNewClosure() + $mouseLeaveHandler = [System.Windows.Input.MouseEventHandler]{ + param($sender,$eventArgs) + if (-not $window.IsMouseOver) { & $conceal } }.GetNewClosure() - - $fitToViewport = { - if (-not $scroller -or $scroller.ViewportWidth -le 0) { return } - $fw = $scroller.ViewportWidth / $Bitmap.Width - $fh = $scroller.ViewportHeight / $Bitmap.Height - $fit = [math]::Min($fw, $fh) - if ($fit -gt 1) { $fit = 1 } - & $setZoom $fit + $window.Add_MouseEnter($mouseEnterHandler) + $window.Add_MouseLeave($mouseLeaveHandler) + + $timer = [System.Windows.Threading.DispatcherTimer]::new() + $timer.Interval = [timespan]::FromMilliseconds(150) + $timerTickHandler = [EventHandler]{ + param($sender,$eventArgs) + $position = [System.Windows.Forms.Control]::MousePosition + $scaleX = [math]::Max(0.01, [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Width / + [System.Windows.SystemParameters]::PrimaryScreenWidth) + $scaleY = [math]::Max(0.01, [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Height / + [System.Windows.SystemParameters]::PrimaryScreenHeight) + $dipX = $workArea.Left + (($position.X - [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Left) / $scaleX) + $dipY = $workArea.Top + (($position.Y - [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Top) / $scaleY) + if ($dipY -le $workArea.Top + 4 -and $dipX -ge $window.Left -and + $dipX -le $window.Left + $window.Width) { + & $reveal + } elseif (-not $window.IsMouseOver -and $dipY -gt $shownTop + $window.Height + 8) { + & $conceal + } }.GetNewClosure() + $timer.Add_Tick($timerTickHandler) - $win.Add_Loaded({ & $fitToViewport }.GetNewClosure()) - - $win.FindName('ZoomInBtn').Add_Click({ & $zoomBy 1.25 }.GetNewClosure()) - $win.FindName('ZoomOutBtn').Add_Click({ & $zoomBy (1 / 1.25) }.GetNewClosure()) - $win.FindName('FitBtn').Add_Click({ & $fitToViewport }.GetNewClosure()) + $lifecycleParameters = @{ Window = $window } + $registerWindowProperty = $Context.PSObject.Properties['RegisterWindow'] + $unregisterWindowProperty = $Context.PSObject.Properties['UnregisterWindow'] + if ($null -ne $registerWindowProperty -and $registerWindowProperty.Value -is [scriptblock]) { + $lifecycleParameters.Register = $registerWindowProperty.Value + } + if ($null -ne $unregisterWindowProperty -and $unregisterWindowProperty.Value -is [scriptblock]) { + $lifecycleParameters.Unregister = $unregisterWindowProperty.Value + } + $lifecycle = Connect-SnipWindowLifecycle @lifecycleParameters + $closedHandler = [EventHandler]{ + param($sender,$eventArgs) + if ($widgetState.Closed) { return } + $widgetState.Closed = $true + $timer.Stop() + $timer.Remove_Tick($timerTickHandler) + $window.Remove_MouseEnter($mouseEnterHandler) + $window.Remove_MouseLeave($mouseLeaveHandler) + $buttons.SmartBtn.Remove_Click($smartClick) + $buttons.FullBtn.Remove_Click($fullClick) + $buttons.WindowBtn.Remove_Click($windowClick) + $buttons.SettingsBtn.Remove_Click($settingsClick) + Clear-SnipWidgetWindow -Window $window + $window.Remove_Closed($widgetState.ClosedHandler) + }.GetNewClosure() + $widgetState.ClosedHandler = $closedHandler + $window.Add_Closed($closedHandler) + + $click = { + param([Parameter(Mandatory)] [string]$Name) + if (-not $buttons.Contains($Name)) { throw [ArgumentException]::new("Unknown widget control '$Name'.") } + $eventArgs = [System.Windows.RoutedEventArgs]::new([System.Windows.Controls.Button]::ClickEvent) + $buttons[$Name].RaiseEvent($eventArgs) + }.GetNewClosure() + $kit = [pscustomobject]@{ + Window = $window + Resources = $resources + Lifecycle = $lifecycle + Order = [string[]]@('Smart','Full','Window','Settings') + Buttons = $buttons + Click = $click + Reveal = $reveal + Conceal = $conceal + State = $widgetState + } - $win.Add_PreviewMouseWheel({ - if (([System.Windows.Input.Keyboard]::Modifiers -band [System.Windows.Input.ModifierKeys]::Control) -ne 0) { - $factor = if ($_.Delta -gt 0) { 1.25 } else { 1 / 1.25 } - $oldScale = $layoutScale.ScaleX - $cursor = $_.GetPosition($scroller) - $oldSx = $scroller.HorizontalOffset - $oldSy = $scroller.VerticalOffset - & $zoomBy $factor - $newScale = $layoutScale.ScaleX - $offset = Get-ZoomCenteredOffset ` - -CursorX $cursor.X -CursorY $cursor.Y ` - -OldScrollX $oldSx -OldScrollY $oldSy ` - -OldScale $oldScale -NewScale $newScale ` - -ContentWidth ($Bitmap.Width * $newScale) ` - -ContentHeight ($Bitmap.Height * $newScale) ` - -ViewportWidth $scroller.ViewportWidth ` - -ViewportHeight $scroller.ViewportHeight - $scroller.ScrollToHorizontalOffset($offset.X) - $scroller.ScrollToVerticalOffset( $offset.Y) - $_.Handled = $true + $script:WidgetWindow = $window + if ($TestAction) { + $window.Left = -10000 + $window.Top = -10000 + $window.Show() + $window.UpdateLayout() + try { & $TestAction $kit | Out-Null } + finally { + if ($window.IsVisible) { $window.Close() } + Clear-SnipWidgetWindow -Window $window } - }.GetNewClosure()) + return $null + } - # Keyboard shortcuts - $fireClick = { - param($btnName) - $b = $win.FindName($btnName) - if ($b) { - $e = New-Object System.Windows.RoutedEventArgs( - [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent) - $b.RaiseEvent($e) - } - }.GetNewClosure() + $window.Show() + $timer.Start() + $window +} - $win.Add_PreviewKeyDown({ - if ($state.EditingText) { return } - $ctrl = ([System.Windows.Input.Keyboard]::Modifiers -band [System.Windows.Input.ModifierKeys]::Control) -ne 0 - $shift = ([System.Windows.Input.Keyboard]::Modifiers -band [System.Windows.Input.ModifierKeys]::Shift) -ne 0 - if ($ctrl -and $_.Key -eq 'Z') { - if ($shift) { Do-Redo } else { Do-Undo } - $_.Handled = $true - } elseif ($ctrl -and $_.Key -eq 'C') { & $fireClick 'CopyBtn'; $_.Handled = $true } - elseif ($ctrl -and $_.Key -eq 'S') { & $fireClick 'SaveBtn'; $_.Handled = $true } - elseif ($ctrl -and $_.Key -eq 'N') { & $fireClick 'NewBtn'; $_.Handled = $true } - elseif ($ctrl -and $_.Key -eq 'D0') { & $setZoom 1.0; $_.Handled = $true } - elseif ($ctrl -and ($_.Key -eq 'OemPlus' -or $_.Key -eq 'Add')) { & $zoomBy 1.25; $_.Handled = $true } - elseif ($ctrl -and ($_.Key -eq 'OemMinus' -or $_.Key -eq 'Subtract')) { & $zoomBy (1 / 1.25); $_.Handled = $true } - elseif ($_.Key -eq 'Escape') { & $fireClick 'CloseBtn'; $_.Handled = $true } - }) +function Register-SnipHotkeyBinding { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [IntPtr]$Hwnd, + [Parameter(Mandatory)] [object]$Binding, + [Parameter(Mandatory)] [scriptblock]$Register + ) - function script:Get-FlattenedBitmap { - if ($state.Annotations.Count -eq 0) { return $Bitmap } - $flat = New-Object System.Drawing.Bitmap $Bitmap.Width, $Bitmap.Height, - ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb) - $g = [System.Drawing.Graphics]::FromImage($flat) - $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias - $g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit - $g.DrawImage($Bitmap, 0, 0, $Bitmap.Width, $Bitmap.Height) - foreach ($a in $state.Annotations) { - $rgb = $palette[$a.Color] - if (-not $rgb) { continue } - if ($a.Type -eq 'highlight') { - $brush = New-Object System.Drawing.SolidBrush( - [System.Drawing.Color]::FromArgb(110, $rgb.R, $rgb.G, $rgb.B)) - $g.FillRectangle($brush, [int]$a.X, [int]$a.Y, [int]$a.W, [int]$a.H) - $brush.Dispose() - } elseif ($a.Type -eq 'rect') { - $pen = New-Object System.Drawing.Pen( - [System.Drawing.Color]::FromArgb(255, $rgb.R, $rgb.G, $rgb.B), 4) - $g.DrawRectangle($pen, [int]$a.X, [int]$a.Y, [int]$a.W, [int]$a.H) - $pen.Dispose() - } elseif ($a.Type -eq 'arrow') { - $pen = New-Object System.Drawing.Pen( - [System.Drawing.Color]::FromArgb(255, $rgb.R, $rgb.G, $rgb.B), 5) - $pen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round - $pen.EndCap = [System.Drawing.Drawing2D.LineCap]::ArrowAnchor - $g.DrawLine($pen, [int]$a.X, [int]$a.Y, [int]($a.X + $a.W), [int]($a.Y + $a.H)) - $pen.Dispose() - } elseif ($a.Type -eq 'text') { - $brush = New-Object System.Drawing.SolidBrush( - [System.Drawing.Color]::FromArgb(255, $rgb.R, $rgb.G, $rgb.B)) - $font = New-Object System.Drawing.Font 'Segoe UI', $a.FontSize, - ([System.Drawing.FontStyle]::Bold), ([System.Drawing.GraphicsUnit]::Pixel) - $g.DrawString($a.Text, $font, $brush, [single]$a.X, [single]$a.Y) - $font.Dispose(); $brush.Dispose() - } + $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.') } - $g.Dispose() - return $flat + $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 } +} - $copyBtn = $win.FindName('CopyBtn') - $copyBtn.Add_Click({ - $flat = Get-FlattenedBitmap - $clipSrc = Convert-BitmapToBitmapSource $flat - [System.Windows.Clipboard]::SetImage($clipSrc) - if ($flat -ne $Bitmap) { $flat.Dispose() } - # Brief confirmation — second StackPanel child is the "Copy" label. - $lbl = $copyBtn.Content.Children[1] - $lbl.Text = 'Copied!' - $timer = New-Object System.Windows.Threading.DispatcherTimer - $timer.Interval = [timespan]::FromMilliseconds(1200) - $timer.Add_Tick({ $lbl.Text = 'Copy'; $timer.Stop() }.GetNewClosure()) - $timer.Start() - }.GetNewClosure()) - $win.FindName('SaveBtn').Add_Click({ - $flat = Get-FlattenedBitmap - Save-CaptureToFile -Bitmap $flat | Out-Null - if ($flat -ne $Bitmap) { $flat.Dispose() } - }) - $win.FindName('NewBtn').Add_Click({ - $win.Close() - $script:RequestNewSnip = $true - }) - $win.FindName('CloseBtn').Add_Click({ $win.Close() }) +function Set-SnipHotkeyBinding { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object]$Context, + [Parameter(Mandatory)] [object]$Candidate, + [Parameter(Mandatory)] [scriptblock]$Register, + [Parameter(Mandatory)] [scriptblock]$Unregister + ) - $script:RequestNewSnip = $false - $script:CurrentPreviewWindow = $win - $previewHelper = New-Object System.Windows.Interop.WindowInteropHelper $win - # SourceInitialized fires once the OS hwnd exists but before the window - # paints. Register here so a window-capture triggered with the preview - # in focus correctly falls back to the virtual desktop. - $win.Add_SourceInitialized({ - Register-SelfWindowHandle -Hwnd $previewHelper.Handle - }.GetNewClosure()) - $win.Add_Closed({ - try { Unregister-SelfWindowHandle -Hwnd $previewHelper.Handle } catch {} - $script:CurrentPreviewWindow = $null - # Release the backing Bitmap — the frozen BitmapSource ($src) no longer depends on it. - try { if ($Bitmap) { $Bitmap.Dispose() } } catch { Write-SnipDiag "Bitmap dispose failed" $_ } - }.GetNewClosure()) + $hwnd = [IntPtr]::Zero + $hwndProperty = $Context.PSObject.Properties['Hwnd'] + if ($null -ne $hwndProperty -and $null -ne $hwndProperty.Value) { + $hwnd = [IntPtr]$hwndProperty.Value + } - if ($TestAction) { - $kit = @{ - Win = $win - State = $state - LayoutScale = $layoutScale - Scroller = $scroller - ImageHost = $imageHost - HighlightLayer = $highlightLayer - ZoomText = $zoomText - HighlightBtn = $highlightBtn - RectBtn = $rectBtn - ArrowBtn = $arrowBtn - TextBtn = $textBtn - Bitmap = $Bitmap - Palette = $palette - SetZoom = $setZoom - ZoomBy = $zoomBy - FitToViewport = $fitToViewport - BeginPan = $beginPan - UpdatePan = $updatePan - EndPan = $endPan - BeginDraw = $beginDraw - UpdateDraw = $updateDraw - FinishDraw = $finishDraw - OpenText = $openText - HandleMouseDown = $handleMouseDown - PickColor = $pickColor - Render = ${function:script:Render-Annotations} - Snapshot = ${function:script:Snapshot-State} - Undo = ${function:script:Do-Undo} - Redo = ${function:script:Do-Redo} - FindAt = ${function:script:Find-AnnotationAt} - Flatten = ${function:script:Get-FlattenedBitmap} + $previousBinding = $null + if ($null -ne $Context.RegisteredHotkey) { + $previousBinding = [pscustomobject][ordered]@{ + Modifiers = [int]$Context.RegisteredHotkey.Modifiers + VirtualKey = [int]$Context.RegisteredHotkey.VirtualKey } - $script:pwTestError = $null - # Hide off-screen so the window is effectively headless. - $win.WindowStartupLocation = 'Manual' - $win.Left = -5000; $win.Top = -5000 - $win.Add_Loaded({ - try { & $TestAction $kit } - catch { $script:pwTestError = $_ } - finally { try { $win.Close() } catch {} } - }.GetNewClosure()) - $win.ShowDialog() | Out-Null - if ($script:pwTestError) { throw $script:pwTestError } - return } - $win.ShowDialog() | Out-Null - $script:CurrentPreviewWindow = $null - return $script:RequestNewSnip -} + $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 + } + } -#endregion + 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 + } + } + } -#region Capture Orchestration =============================================== + $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 + } -# When a hotkey fires while a preview window is open, the handler sets -# $script:PendingCaptureType and closes the preview. After ShowDialog returns, -# the capture loop checks the pending type and chains into the next capture. -$script:PendingCaptureType = $null # 1 = smart, 2 = full, 3 = window -$script:CurrentPreviewWindow = $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 + } + } -function Invoke-PendingCapture { - $next = $script:PendingCaptureType - $script:PendingCaptureType = $null - switch ($next) { - 1 { Invoke-SmartCapture } - 2 { Invoke-FullScreenCapture } - 3 { Invoke-WindowCapture } + $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)" + $activeBinding = $null + $candidateRemoved = $false + try { + if (-not [bool](& $Unregister $hwnd 1)) { + throw [InvalidOperationException]::new('Could not unregister the unsaved candidate hotkey.') + } + $candidateRemoved = $true + } catch { + $candidateText = Format-SnipHotkey -Modifiers $candidateBinding.Modifiers ` + -VirtualKey $candidateBinding.VirtualKey + $rollbackError = "The unsaved shortcut $candidateText may remain active because native cleanup failed: $($_.Exception.Message)" + $activeBinding = $candidateBinding + $Context.RegisteredHotkey = $candidateBinding + } + if ($candidateRemoved) { + try { + 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 + } + $activeBinding = $Context.RegisteredHotkey + } catch { + $rollbackError = "Could not restore the previous hotkey: $($_.Exception.Message)" + $Context.RegisteredHotkey = $null + $activeBinding = $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 = $activeBinding + CandidateError = $candidateError + RollbackError = $rollbackError + } } -} -function Invoke-SmartCapture { - do { - $bmp = Show-SmartOverlay - if (-not $bmp) { break } - $again = Show-PreviewWindow -Bitmap $bmp - $bmp.Dispose() - if ($script:PendingCaptureType) { Invoke-PendingCapture; return } - } while ($again) + $Context.Settings.Hotkey = $candidateBinding + $Context.RegisteredHotkey = $candidateBinding + [pscustomobject]@{ + Success = $true + ActiveBinding = $candidateBinding + CandidateError = $null + RollbackError = $null + } } -function Invoke-FullScreenCapture { - $vs = Get-VirtualScreenBounds - # Recreate the screenshot each iteration — the preview takes ownership of - # the bitmap and disposes it on close (see Invoke-CaptureLoop contract, - # RAN-14). Hide own chrome around each grab so the widget/preview isn't - # baked into the frame. - $factory = { - $hidden = Hide-OwnSnipITWindowsForCapture - try { - return New-ScreenBitmap -X $vs.X -Y $vs.Y -Width $vs.Width -Height $vs.Height - } finally { - Show-OwnSnipITWindowsForCapture -Hidden $hidden +function New-SnipTrayMenu { + [CmdletBinding()] + param([Parameter(Mandatory)] $Context) + + foreach ($requiredService in 'SubmitRequest','OpenSettings','OpenAbout','Exit') { + $property = $Context.PSObject.Properties[$requiredService] + if ($null -eq $property -or $property.Value -isnot [scriptblock]) { + throw [ArgumentException]::new("Tray context is missing the $requiredService service.", 'Context') } + } + $tokens = Get-SnipThemeTokens + $highContrastProperty = $Context.PSObject.Properties['HighContrast'] + $highContrast = if ($null -ne $highContrastProperty) { + [bool]$highContrastProperty.Value + } else { + [bool][System.Windows.Forms.SystemInformation]::HighContrast + } + if ($highContrast) { + $backgroundColor = [System.Drawing.SystemColors]::Menu + $textColor = [System.Drawing.SystemColors]::MenuText + $accentColor = [System.Drawing.SystemColors]::Highlight + $accentTextColor = [System.Drawing.SystemColors]::HighlightText + $borderColor = [System.Drawing.SystemColors]::ActiveBorder + } else { + $backgroundColor = [System.Drawing.ColorTranslator]::FromHtml($tokens.PageBlack) + $textColor = [System.Drawing.ColorTranslator]::FromHtml($tokens.PrimaryText) + $accentColor = [System.Drawing.ColorTranslator]::FromHtml($tokens.Accent) + $accentTextColor = [System.Drawing.ColorTranslator]::FromHtml($tokens.PageBlack) + $borderColor = [System.Drawing.Color]::FromArgb(0x59, 255, 255, 255) + } + $palette = [pscustomobject][ordered]@{ + PageBlack = [System.Drawing.ColorTranslator]::ToHtml($backgroundColor) + PrimaryText = [System.Drawing.ColorTranslator]::ToHtml($textColor) + Accent = [System.Drawing.ColorTranslator]::ToHtml($accentColor) + AccentText = [System.Drawing.ColorTranslator]::ToHtml($accentTextColor) + } + + $menu = [System.Windows.Forms.ContextMenuStrip]::new() + $menu.Name = 'SnipTrayMenu' + $menu.ShowImageMargin = $false + $menu.ShowCheckMargin = $true + $menu.BackColor = $backgroundColor + $menu.ForeColor = $textColor + $menu.Padding = [System.Windows.Forms.Padding]::new(5) + $menu.Font = [System.Drawing.Font]::new('Segoe UI Variable Text', 9.0, + [System.Drawing.FontStyle]::Regular, [System.Drawing.GraphicsUnit]::Point) + + $renderer = [System.Windows.Forms.ToolStripProfessionalRenderer]::new() + $renderer.RoundedEdges = $true + $renderBackground = [System.Windows.Forms.ToolStripRenderEventHandler]{ + param($sender,$eventArgs) + $brush = [System.Drawing.SolidBrush]::new($backgroundColor) + try { $eventArgs.Graphics.FillRectangle($brush, $eventArgs.AffectedBounds) } + finally { $brush.Dispose() } }.GetNewClosure() - $handler = { - param($bmp) - $again = Show-PreviewWindow -Bitmap $bmp - if ($script:PendingCaptureType) { return $false } - return $again + $renderBorder = [System.Windows.Forms.ToolStripRenderEventHandler]{ + param($sender,$eventArgs) + $rectangle = $eventArgs.ToolStrip.ClientRectangle + $rectangle.Width = [math]::Max(0, $rectangle.Width - 1) + $rectangle.Height = [math]::Max(0, $rectangle.Height - 1) + $pen = [System.Drawing.Pen]::new($borderColor, 1) + try { $eventArgs.Graphics.DrawRectangle($pen, $rectangle) } + finally { $pen.Dispose() } }.GetNewClosure() - $null = Invoke-CaptureLoop -CaptureFactory $factory -PreviewHandler $handler - if ($script:PendingCaptureType) { Invoke-PendingCapture } -} - -function Invoke-WindowCapture { - # Capture the currently foreground window. If that's a SnipIT-owned - # window (widget clicked, preview focused, hotkey form, etc.), the - # decision layer returns $null and we fall back to a full virtual- - # desktop capture instead of snapshotting ourselves. - $fg = [Native]::GetForegroundWindow() - $target = Resolve-WindowCaptureTarget -ForegroundHwnd $fg ` - -SelfWindowHandles $script:SelfWindowHandles - if ($null -eq $target) { - Invoke-FullScreenCapture - return + $renderItemBackground = [System.Windows.Forms.ToolStripItemRenderEventHandler]{ + param($sender,$eventArgs) + $color = if ($eventArgs.Item.Selected) { $accentColor } else { $backgroundColor } + $brush = [System.Drawing.SolidBrush]::new($color) + try { + $bounds = [System.Drawing.Rectangle]::new(2, 1, + [math]::Max(0, $eventArgs.Item.Width - 4), + [math]::Max(0, $eventArgs.Item.Height - 2)) + $eventArgs.Graphics.FillRectangle($brush, $bounds) + } finally { $brush.Dispose() } + }.GetNewClosure() + $renderText = [System.Windows.Forms.ToolStripItemTextRenderEventHandler]{ + param($sender,$eventArgs) + $eventArgs.TextColor = if ($eventArgs.Item.Selected) { $accentTextColor } else { $textColor } + }.GetNewClosure() + $renderSeparator = [System.Windows.Forms.ToolStripSeparatorRenderEventHandler]{ + param($sender,$eventArgs) + $pen = [System.Drawing.Pen]::new($borderColor, 1) + try { + $y = [int]($eventArgs.Item.Height / 2) + $eventArgs.Graphics.DrawLine($pen, 10, $y, [math]::Max(10, $eventArgs.Item.Width - 10), $y) + } finally { $pen.Dispose() } + }.GetNewClosure() + $checkPalette = [pscustomobject][ordered]@{ + Fill = $accentColor + Glyph = $accentTextColor } - $r = New-Object Native+RECT - $ok = ([Native]::DwmGetWindowAttribute($target, [Native]::DWMWA_EXTENDED_FRAME_BOUNDS, [ref]$r, 16) -eq 0) - if (-not $ok) { [Native]::GetWindowRect($target, [ref]$r) | Out-Null } - $w = $r.Right - $r.Left - $h = $r.Bottom - $r.Top - if ($w -le 0 -or $h -le 0) { return } - # Recreate the screenshot each iteration — the preview owns and disposes - # the bitmap on close (see Invoke-CaptureLoop contract, RAN-14). Even when - # the target is foreign, our widget can be sitting on top of it (always- - # Topmost), so hide own chrome around every snapshot. - $factory = { - $hidden = Hide-OwnSnipITWindowsForCapture + $renderItemCheck = [System.Windows.Forms.ToolStripItemImageRenderEventHandler]{ + param($sender,$eventArgs) + $rectangle = $eventArgs.ImageRectangle + if ($rectangle.Width -le 0 -or $rectangle.Height -le 0) { return } + + $contentRectangle = $eventArgs.Item.ContentRectangle + $chromeLeft = [math]::Max(2, $contentRectangle.Left) + $chromeRight = [math]::Min( + $eventArgs.Item.Width - 2, $rectangle.Right + 3) + $chromeRectangle = [System.Drawing.Rectangle]::new( + $chromeLeft, 0, + [math]::Max(0, $chromeRight - $chromeLeft), + $eventArgs.Item.Height) + $itemBackground = if ($eventArgs.Item.Selected) { + $accentColor + } else { + $backgroundColor + } + $backgroundBrush = [System.Drawing.SolidBrush]::new($itemBackground) + $brush = [System.Drawing.SolidBrush]::new($checkPalette.Fill) + $pen = [System.Drawing.Pen]::new($checkPalette.Glyph, 2) + $previousSmoothing = $eventArgs.Graphics.SmoothingMode try { - return New-ScreenBitmap -X $r.Left -Y $r.Top -Width $w -Height $h + $eventArgs.Graphics.FillRectangle($backgroundBrush, $chromeRectangle) + $eventArgs.Graphics.FillRectangle($brush, $rectangle) + $eventArgs.Graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $pen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round + $pen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round + $points = [System.Drawing.PointF[]]@( + [System.Drawing.PointF]::new( + $rectangle.Left + ($rectangle.Width * 0.22), + $rectangle.Top + ($rectangle.Height * 0.52)), + [System.Drawing.PointF]::new( + $rectangle.Left + ($rectangle.Width * 0.43), + $rectangle.Top + ($rectangle.Height * 0.72)), + [System.Drawing.PointF]::new( + $rectangle.Left + ($rectangle.Width * 0.78), + $rectangle.Top + ($rectangle.Height * 0.30)) + ) + $eventArgs.Graphics.DrawLines($pen, $points) } finally { - Show-OwnSnipITWindowsForCapture -Hidden $hidden + $eventArgs.Graphics.SmoothingMode = $previousSmoothing + $pen.Dispose() + $brush.Dispose() + $backgroundBrush.Dispose() + } + }.GetNewClosure() + $renderer.add_RenderToolStripBackground($renderBackground) + $renderer.add_RenderToolStripBorder($renderBorder) + $renderer.add_RenderMenuItemBackground($renderItemBackground) + $renderer.add_RenderItemText($renderText) + $renderer.add_RenderSeparator($renderSeparator) + $renderer.add_RenderItemCheck($renderItemCheck) + $menu.Renderer = $renderer + + $submitRequest = $Context.SubmitRequest + $openSettings = $Context.OpenSettings + $openAbout = $Context.OpenAbout + $exitApplication = $Context.Exit + $handlerList = [System.Collections.Generic.List[object]]::new() + $items = [ordered]@{} + $activeShortcutProperty = $Context.PSObject.Properties['ActiveShortcut'] + $activeShortcut = if ($null -ne $activeShortcutProperty -and + -not [string]::IsNullOrWhiteSpace([string]$activeShortcutProperty.Value)) { + [string]$activeShortcutProperty.Value + } else { 'Unavailable' } + + $items.Smart = [System.Windows.Forms.ToolStripMenuItem]::new("Smart capture ($activeShortcut)") + $items.Smart.Name = 'Smart' + $smartClick = [EventHandler]{ param($sender,$eventArgs) & $submitRequest 'Smart' ([timespan]::Zero) | Out-Null }.GetNewClosure() + $items.Smart.Add_Click($smartClick); $handlerList.Add($smartClick) + [void]$menu.Items.Add($items.Smart) + + $items.Full = [System.Windows.Forms.ToolStripMenuItem]::new('Full desktop') + $items.Full.Name = 'Full' + $fullClick = [EventHandler]{ param($sender,$eventArgs) & $submitRequest 'Full' ([timespan]::Zero) | Out-Null }.GetNewClosure() + $items.Full.Add_Click($fullClick); $handlerList.Add($fullClick) + [void]$menu.Items.Add($items.Full) + + $items.Window = [System.Windows.Forms.ToolStripMenuItem]::new('Active window') + $items.Window.Name = 'Window' + $windowClick = [EventHandler]{ param($sender,$eventArgs) & $submitRequest 'Window' ([timespan]::Zero) | Out-Null }.GetNewClosure() + $items.Window.Add_Click($windowClick); $handlerList.Add($windowClick) + [void]$menu.Items.Add($items.Window) + + $items.Separator1 = [System.Windows.Forms.ToolStripSeparator]::new() + $items.Separator1.Name = 'Separator1' + [void]$menu.Items.Add($items.Separator1) + + $items.Delay = [System.Windows.Forms.ToolStripMenuItem]::new('Delay capture') + $items.Delay.Name = 'Delay' + foreach ($delaySpec in @( + [pscustomobject]@{ Text='Smart in 3 seconds'; Mode='Smart'; Seconds=3 }, + [pscustomobject]@{ Text='Smart in 5 seconds'; Mode='Smart'; Seconds=5 }, + [pscustomobject]@{ Text='Smart in 10 seconds'; Mode='Smart'; Seconds=10 }, + [pscustomobject]@{ Text='Full in 3 seconds'; Mode='Full'; Seconds=3 }, + [pscustomobject]@{ Text='Window in 3 seconds'; Mode='Window'; Seconds=3 })) { + $delayItem = [System.Windows.Forms.ToolStripMenuItem]::new($delaySpec.Text) + $delayItem.Tag = $delaySpec + $delayClick = [EventHandler]{ + param($sender,$eventArgs) + & $submitRequest $sender.Tag.Mode ([timespan]::FromSeconds([int]$sender.Tag.Seconds)) | Out-Null + }.GetNewClosure() + $delayItem.Add_Click($delayClick) + $handlerList.Add($delayClick) + [void]$items.Delay.DropDownItems.Add($delayItem) + } + [void]$menu.Items.Add($items.Delay) + + $items.Settings = [System.Windows.Forms.ToolStripMenuItem]::new('Settings') + $items.Settings.Name = 'Settings' + $settingsClick = [EventHandler]{ param($sender,$eventArgs) & $openSettings | Out-Null }.GetNewClosure() + $items.Settings.Add_Click($settingsClick); $handlerList.Add($settingsClick) + [void]$menu.Items.Add($items.Settings) + + $items.Widget = [System.Windows.Forms.ToolStripMenuItem]::new('Edge-reveal widget') + $items.Widget.Name = 'Widget' + $items.Widget.CheckOnClick = $true + $items.Widget.Checked = [bool]$Context.Settings.WidgetVisible + $toggleWidgetProperty = $Context.PSObject.Properties['SetWidgetVisible'] + $widgetClick = [EventHandler]{ + param($sender,$eventArgs) + $visible = [bool]$sender.Checked + if ($null -ne $toggleWidgetProperty -and $toggleWidgetProperty.Value -is [scriptblock]) { + & $toggleWidgetProperty.Value $visible | Out-Null + } else { + $Context.Settings.WidgetVisible = $visible } }.GetNewClosure() - $handler = { - param($bmp) - $again = Show-PreviewWindow -Bitmap $bmp - if ($script:PendingCaptureType) { return $false } - return $again + $items.Widget.Add_Click($widgetClick); $handlerList.Add($widgetClick) + [void]$menu.Items.Add($items.Widget) + + $items.OpenFolder = [System.Windows.Forms.ToolStripMenuItem]::new('Open snips folder') + $items.OpenFolder.Name = 'OpenFolder' + $openFolderProperty = $Context.PSObject.Properties['OpenFolder'] + $openFolderClick = [EventHandler]{ + param($sender,$eventArgs) + if ($null -ne $openFolderProperty -and $openFolderProperty.Value -is [scriptblock]) { + & $openFolderProperty.Value | Out-Null + } + }.GetNewClosure() + $items.OpenFolder.Add_Click($openFolderClick); $handlerList.Add($openFolderClick) + [void]$menu.Items.Add($items.OpenFolder) + + $items.Separator2 = [System.Windows.Forms.ToolStripSeparator]::new() + $items.Separator2.Name = 'Separator2' + [void]$menu.Items.Add($items.Separator2) + + $items.About = [System.Windows.Forms.ToolStripMenuItem]::new('About SnipIT') + $items.About.Name = 'About' + $aboutClick = [EventHandler]{ param($sender,$eventArgs) & $openAbout | Out-Null }.GetNewClosure() + $items.About.Add_Click($aboutClick); $handlerList.Add($aboutClick) + [void]$menu.Items.Add($items.About) + + $items.Uninstall = [System.Windows.Forms.ToolStripMenuItem]::new('Uninstall') + $items.Uninstall.Name = 'Uninstall' + $uninstallProperty = $Context.PSObject.Properties['Uninstall'] + $uninstallClick = [EventHandler]{ + param($sender,$eventArgs) + if ($null -ne $uninstallProperty -and $uninstallProperty.Value -is [scriptblock]) { + & $uninstallProperty.Value | Out-Null + } + }.GetNewClosure() + $items.Uninstall.Add_Click($uninstallClick); $handlerList.Add($uninstallClick) + [void]$menu.Items.Add($items.Uninstall) + + $items.Exit = [System.Windows.Forms.ToolStripMenuItem]::new('Exit') + $items.Exit.Name = 'Exit' + $exitClick = [EventHandler]{ param($sender,$eventArgs) & $exitApplication | Out-Null }.GetNewClosure() + $items.Exit.Add_Click($exitClick); $handlerList.Add($exitClick) + [void]$menu.Items.Add($items.Exit) + + $registerWindowProperty = $Context.PSObject.Properties['RegisterWindow'] + $unregisterWindowProperty = $Context.PSObject.Properties['UnregisterWindow'] + $registerMenuWindow = if ($null -ne $registerWindowProperty -and + $registerWindowProperty.Value -is [scriptblock]) { $registerWindowProperty.Value } else { $null } + $unregisterMenuWindow = if ($null -ne $unregisterWindowProperty -and + $unregisterWindowProperty.Value -is [scriptblock]) { $unregisterWindowProperty.Value } else { $null } + $dropDownLifecycles = [System.Collections.Generic.List[object]]::new() + $connectDropDownLifecycle = { + param([Parameter(Mandatory)] [System.Windows.Forms.ToolStripDropDown]$DropDown) + + $windowState = [pscustomobject]@{ + DropDown = $DropDown + Handle = [IntPtr]::Zero + Registered = $false + OpenedHandler = $null + ClosedHandler = $null + DisposedHandler = $null + RegisterWindow = $registerMenuWindow + UnregisterWindow = $unregisterMenuWindow + } + $disconnect = { + if (-not $windowState.Registered) { return } + try { + if ($null -ne $windowState.UnregisterWindow -and + $windowState.Handle -ne [IntPtr]::Zero) { + & $windowState.UnregisterWindow $windowState.Handle + } + } finally { + $windowState.Handle = [IntPtr]::Zero + $windowState.Registered = $false + } + }.GetNewClosure() + $opened = [EventHandler]{ + param($sender,$eventArgs) + if ($windowState.Registered -or $null -eq $windowState.RegisterWindow) { return } + $handle = $DropDown.Handle + if ($handle -eq [IntPtr]::Zero) { return } + & $windowState.RegisterWindow $handle + $windowState.Handle = $handle + $windowState.Registered = $true + }.GetNewClosure() + $closed = [System.Windows.Forms.ToolStripDropDownClosedEventHandler]{ + param($sender,$eventArgs) + & $disconnect + }.GetNewClosure() + $disposed = [EventHandler]{ + param($sender,$eventArgs) + try { & $disconnect } + finally { + $DropDown.Remove_Opened($windowState.OpenedHandler) + $DropDown.Remove_Closed($windowState.ClosedHandler) + $DropDown.Remove_Disposed($windowState.DisposedHandler) + } + }.GetNewClosure() + $windowState.OpenedHandler = $opened + $windowState.ClosedHandler = $closed + $windowState.DisposedHandler = $disposed + $DropDown.Add_Opened($opened) + $DropDown.Add_Closed($closed) + $DropDown.Add_Disposed($disposed) + $dropDownLifecycles.Add($windowState) + $windowState }.GetNewClosure() - $null = Invoke-CaptureLoop -CaptureFactory $factory -PreviewHandler $handler - if ($script:PendingCaptureType) { Invoke-PendingCapture } -} -function Start-DelayedCapture { - param([int]$Seconds, [ValidateSet('smart','full','window')] [string]$Type) - $plural = if ($Seconds -ne 1) { 's' } else { '' } - try { - $script:tray.BalloonTipTitle = 'SnipIT' - $script:tray.BalloonTipText = "Capturing ($Type) in $Seconds second$plural..." - $script:tray.ShowBalloonTip(1500) - } catch {} - $timer = New-Object System.Windows.Forms.Timer - $timer.Interval = [int]($Seconds * 1000) - $timer.Add_Tick({ - $timer.Stop(); $timer.Dispose() - switch ($Type) { - 'smart' { Invoke-SmartCapture } - 'full' { Invoke-FullScreenCapture } - 'window' { Invoke-WindowCapture } + $pendingDropDowns = [System.Collections.Generic.Queue[System.Windows.Forms.ToolStripDropDown]]::new() + $pendingDropDowns.Enqueue($menu) + while ($pendingDropDowns.Count -gt 0) { + $dropDown = $pendingDropDowns.Dequeue() + $dropDown.Renderer = $renderer + $dropDown.BackColor = $backgroundColor + $dropDown.ForeColor = $textColor + $dropDown.Font = $menu.Font + $dropDown.Margin = $menu.Margin + $dropDown.Padding = $menu.Padding + if ($dropDown -is [System.Windows.Forms.ToolStripDropDownMenu]) { + $dropDown.ShowImageMargin = $menu.ShowImageMargin + $dropDown.ShowCheckMargin = $menu.ShowCheckMargin } - }.GetNewClosure()) - $timer.Start() + & $connectDropDownLifecycle $dropDown | Out-Null + foreach ($item in $dropDown.Items) { + if ($item -is [System.Windows.Forms.ToolStripDropDownItem] -and + $item.HasDropDownItems) { + $pendingDropDowns.Enqueue($item.DropDown) + } + } + } + $rootWindowState = $dropDownLifecycles[0] + + $state = [pscustomobject]@{ + Palette = $palette + HighContrast = $highContrast + OwnerDrawn = $true + PrimaryOrder = [string[]]@('Smart','Full','Window','Settings','About','Exit') + Order = [string[]]@( + 'Smart','Full','Window','Separator1','Delay','Settings','Widget', + 'OpenFolder','Separator2','About','Uninstall','Exit') + Items = $items + Handlers = $handlerList + RenderHandlers = [object[]]@( + $renderBackground,$renderBorder,$renderItemBackground,$renderText,$renderSeparator, + $renderItemCheck) + Renderer = $renderer + MenuFont = $menu.Font + CheckPalette = $checkPalette + CheckRenderHandler = $renderItemCheck + CheckHandlerAttached = $true + WindowState = $rootWindowState + OpenedHandler = $rootWindowState.OpenedHandler + ClosedHandler = $rootWindowState.ClosedHandler + DropDownLifecycles = $dropDownLifecycles + DisposeHandler = $null + } + $menu.Tag = $state + $disposeHandler = [EventHandler]{ + param($sender,$eventArgs) + $renderer.remove_RenderToolStripBackground($renderBackground) + $renderer.remove_RenderToolStripBorder($renderBorder) + $renderer.remove_RenderMenuItemBackground($renderItemBackground) + $renderer.remove_RenderItemText($renderText) + $renderer.remove_RenderSeparator($renderSeparator) + $renderer.remove_RenderItemCheck($state.CheckRenderHandler) + $state.CheckHandlerAttached = $false + $menu.Remove_Disposed($state.DisposeHandler) + $state.MenuFont.Dispose() + }.GetNewClosure() + $state.DisposeHandler = $disposeHandler + $menu.Add_Disposed($disposeHandler) + $menu } -#endregion +# source: src/90-Main.ps1 +#requires -Version 7.5 +# Generated deterministically from the reviewed SnipIT.ps1 AST by scripts/Export-SnipITModules.ps1. -#region Floating Widget ===================================================== +$hotkeyForm = $null -$script:WidgetWindow = $null +$tray = $null -function Show-FloatingWidget { - if ($script:WidgetWindow) { $script:WidgetWindow.Show(); $script:WidgetWindow.Activate(); return } +$menu = $null - [xml]$xaml = @" - - - - - - - - -"@ - $reader = New-Object System.Xml.XmlNodeReader $xaml - $win = [System.Windows.Markup.XamlReader]::Load($reader) - - $screen = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea - $win.Left = $screen.X + ($screen.Width - $win.Width) / 2 - $shownTop = $screen.Y + 8 - $hiddenTop = $screen.Y - $win.Height + 6 - $win.Top = $hiddenTop - - $win.FindName('SmartBtn').Add_Click({ Invoke-SmartCapture }) - $win.FindName('FullBtn').Add_Click({ Invoke-FullScreenCapture }) - - $win.Add_MouseEnter({ $win.Top = $shownTop }) - $win.Add_MouseLeave({ - $p = [System.Windows.Forms.Control]::MousePosition - if ($p.Y -gt $shownTop + $win.Height + 4) { $win.Top = $hiddenTop } - }) +$trayDoubleClickHandler = $null - # Poll mouse position to slide down when cursor approaches top edge - $timer = New-Object System.Windows.Threading.DispatcherTimer - $timer.Interval = [TimeSpan]::FromMilliseconds(150) - $timer.Add_Tick({ - $p = [System.Windows.Forms.Control]::MousePosition - if ($p.Y -le $screen.Y + 4 -and $p.X -ge $win.Left -and $p.X -le $win.Left + $win.Width) { - $win.Top = $shownTop - } elseif (-not $win.IsMouseOver -and $win.Top -ne $hiddenTop) { - if ($p.Y -gt $shownTop + $win.Height + 8) { $win.Top = $hiddenTop } - } - }) - $timer.Start() - $widgetHelper = New-Object System.Windows.Interop.WindowInteropHelper $win - $win.Add_Closed({ - $timer.Stop() - $script:WidgetWindow = $null - try { Unregister-SelfWindowHandle -Hwnd $widgetHelper.Handle } catch {} - }.GetNewClosure()) +$hkWin = $null - $script:WidgetWindow = $win - $win.Show() - # Register after Show() so the OS hwnd exists. Used by the capture path - # to (a) skip the widget when it's foreground and (b) hide it before - # snapshotting the desktop. - Register-SelfWindowHandle -Hwnd $widgetHelper.Handle -} +$registeredHotkey = $null + +try { +$bootstrapReady = & $script:SnipBootstrapInitializer -Phase Bootstrap + +if (-not $bootstrapReady) { return } + +& $script:SnipNativeInitializer -#endregion +$null = & $script:SnipBootstrapInitializer -Phase Settings -#region Tray + Hotkeys ====================================================== +$script:SnipBootstrapInitializer = $null + +$script:SnipNativeInitializer = $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 } -# Hidden message-only window for hotkeys $hotkeyForm = New-Object System.Windows.Forms.Form + $hotkeyForm.FormBorderStyle = 'FixedToolWindow' + $hotkeyForm.ShowInTaskbar = $false + $hotkeyForm.Opacity = 0 + $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 -# the action on the form so the message returns immediately. Doing UI work -# (especially opening a WPF window) inside WndProc reentrantly causes hangs on -# some Win11 builds. if (-not ('HotkeyWindow' -as [type])) { $nativeWindowSrc = @' using System; @@ -2492,30 +10745,38 @@ public class HotkeyWindow : NativeWindow { } $hotkeyForm.CreateControl() + $null = $hotkeyForm.Handle -# Track the hotkey form and the (hidden) console window so the capture path -# treats them as SnipIT-owned. The form is invisible/off-screen but it can -# still become foreground momentarily after a hotkey fires. + Register-SelfWindowHandle -Hwnd $hotkeyForm.Handle + if ($script:ConsoleHwnd) { Register-SelfWindowHandle -Hwnd $script:ConsoleHwnd } + +$postToHost = { + param($work) + if ($null -eq $hotkeyForm -or $hotkeyForm.IsDisposed -or + -not $hotkeyForm.IsHandleCreated) { return $false } + $postedAction = [Action]{ & $work }.GetNewClosure() + [void]$hotkeyForm.BeginInvoke($postedAction) + return $true +}.GetNewClosure() + +$script:CaptureCoordinator = New-SnipCaptureCoordinator ` + -Post $postToHost ` + -Services (New-SnipRuntimeCaptureServices) ` + -Settings $script:Settings + $hkWin = New-Object HotkeyWindow $hotkeyForm + $hkWin.Callback = [Action[int]]{ param([int]$id) try { - # If a preview is already up, mark the new capture as pending and - # close the preview. Its ShowDialog returns and the orchestration loop - # in Invoke-* picks up $script:PendingCaptureType to chain. - if ($script:CurrentPreviewWindow) { - $script:PendingCaptureType = $id - $script:CurrentPreviewWindow.Close() - return - } - switch ($id) { - 1 { Invoke-SmartCapture } - 2 { Invoke-FullScreenCapture } - 3 { Invoke-WindowCapture } + if ($id -eq $HOTKEY_SMART) { + Request-SnipCapture -Coordinator $script:CaptureCoordinator ` + -Mode Smart -Source Hotkey | Out-Null } } catch { + Write-SnipDiag -Message 'Hotkey capture failed' -ErrorRecord $_ try { $script:tray.BalloonTipTitle = 'SnipIT error' $script:tray.BalloonTipText = $_.Exception.Message @@ -2524,82 +10785,240 @@ $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 + +$script:CaptureCoordinator.RegisteredHotkey = $registeredHotkey + +$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('-') -$delayMenu = New-Object System.Windows.Forms.ToolStripMenuItem 'Delay capture' -[void]$delayMenu.DropDownItems.Add('Smart in 3 seconds', $null, { Start-DelayedCapture 3 'smart' }) -[void]$delayMenu.DropDownItems.Add('Smart in 5 seconds', $null, { Start-DelayedCapture 5 'smart' }) -[void]$delayMenu.DropDownItems.Add('Smart in 10 seconds', $null, { Start-DelayedCapture 10 'smart' }) -[void]$delayMenu.DropDownItems.Add('-') -[void]$delayMenu.DropDownItems.Add('Full in 3 seconds', $null, { Start-DelayedCapture 3 'full' }) -[void]$delayMenu.DropDownItems.Add('Window in 3 seconds', $null, { Start-DelayedCapture 3 'window' }) -[void]$menu.Items.Add($delayMenu) -[void]$menu.Items.Add('Show floating widget', $null, { Show-FloatingWidget }) -[void]$menu.Items.Add('Open snips folder', $null, { - $dir = Join-Path ([Environment]::GetFolderPath('MyPictures')) 'Snips' - New-Item -ItemType Directory -Force -Path $dir | Out-Null - Start-Process explorer.exe $dir -}) -[void]$menu.Items.Add('-') -[void]$menu.Items.Add('About', $null, { Show-AboutWindow }) -[void]$menu.Items.Add('Uninstall', $null, { - $r = [System.Windows.Forms.MessageBox]::Show( - "Remove SnipIT shortcuts and AppData folder?", +$utilityState = [pscustomobject]@{ + Coordinator = $script:CaptureCoordinator + Tray = $tray + Menu = $null + Context = $null +} + +$installPathsForUtilities = $script:InstallPaths + +$unregisterNativeHotkey = { + param($hwnd, $id) + [Native]::UnregisterHotKey($hwnd, $id) +} + +$submitUtilityRequest = { + param( + [string]$Mode, + [timespan]$Delay = [timespan]::Zero, + [string]$Source = 'Tray' + ) + Request-SnipCapture -Coordinator $utilityState.Coordinator ` + -Mode $Mode -Delay $Delay -Source $Source | Out-Null +}.GetNewClosure() + +$publishAuxiliarySurface = { + param($surface) + Set-SnipAuxiliarySurface -Coordinator $utilityState.Coordinator -Surface $surface +}.GetNewClosure() + +$completeAuxiliarySurface = { + param([string]$Result, $surface) + Complete-SnipAuxiliarySurface -Coordinator $utilityState.Coordinator ` + -Surface $surface -Result $Result | Out-Null +}.GetNewClosure() + +$openSettings = { + if ($utilityState.Coordinator.Phase -ne 'Idle') { + $utilityState.Tray.BalloonTipTitle = 'SnipIT is busy' + $utilityState.Tray.BalloonTipText = 'Finish or cancel the current capture before opening Settings.' + $utilityState.Tray.ShowBalloonTip(2500) + return + } + try { Show-SettingsWindow -Context $utilityState.Context | Out-Null } + catch { + Write-SnipDiag -Message 'Settings window failed' -ErrorRecord $_ + $utilityState.Tray.BalloonTipTitle = 'SnipIT Settings error' + $utilityState.Tray.BalloonTipText = $_.Exception.Message + $utilityState.Tray.ShowBalloonTip(3000) + } +}.GetNewClosure() + +$openAbout = { + if ($utilityState.Coordinator.Phase -ne 'Idle') { + $utilityState.Tray.BalloonTipTitle = 'SnipIT is busy' + $utilityState.Tray.BalloonTipText = 'Finish or cancel the current capture before opening About.' + $utilityState.Tray.ShowBalloonTip(2500) + return + } + try { Show-AboutWindow -Context $utilityState.Context | Out-Null } + catch { + Write-SnipDiag -Message 'About window failed' -ErrorRecord $_ + $utilityState.Tray.BalloonTipTitle = 'SnipIT About error' + $utilityState.Tray.BalloonTipText = $_.Exception.Message + $utilityState.Tray.ShowBalloonTip(3000) + } +}.GetNewClosure() + +$syncStartup = { + param($settings) + Sync-SnipStartupShortcut -Settings $settings -Paths $installPathsForUtilities +}.GetNewClosure() + +$setWidgetVisible = { + param([bool]$Visible) + $previous = [bool]$utilityState.Context.Settings.WidgetVisible + if ($previous -ne $Visible) { + $utilityState.Context.Settings.WidgetVisible = $Visible + try { + Save-SnipSettings -Settings $utilityState.Context.Settings ` + -Path $utilityState.Context.SettingsPath + } catch { + $utilityState.Context.Settings.WidgetVisible = $previous + $Visible = $previous + Write-SnipDiag -Message 'Widget visibility could not be saved' -ErrorRecord $_ + $utilityState.Tray.BalloonTipTitle = 'SnipIT Settings error' + $utilityState.Tray.BalloonTipText = 'Widget visibility could not be saved.' + $utilityState.Tray.ShowBalloonTip(3000) + } + } + if ($utilityState.Menu -and $utilityState.Menu.Tag -and + $utilityState.Menu.Tag.Items['Widget']) { + $utilityState.Menu.Tag.Items['Widget'].Checked = $Visible + } + Show-FloatingWidget -Context $utilityState.Context | Out-Null +}.GetNewClosure() + +$openFolder = { + $directory = [string]$utilityState.Context.Settings.SaveFolder + New-Item -ItemType Directory -Force -Path $directory | Out-Null + Start-Process explorer.exe -ArgumentList $directory +}.GetNewClosure() + +$exitApplication = { + Stop-SnipCaptureCoordinator -Coordinator $utilityState.Coordinator + $utilityState.Tray.Visible = $false + [System.Windows.Forms.Application]::Exit() +}.GetNewClosure() + +$uninstallApplication = { + $response = [System.Windows.Forms.MessageBox]::Show( + 'Remove SnipIT shortcuts and AppData folder?', 'Uninstall SnipIT', 'YesNo', 'Warning') - if ($r -eq 'Yes') { + if ($response -eq 'Yes') { + Stop-SnipCaptureCoordinator -Coordinator $utilityState.Coordinator Uninstall-SnipIT - $tray.Visible = $false + $utilityState.Tray.Visible = $false [System.Windows.Forms.Application]::Exit() } -}) -[void]$menu.Items.Add('Exit', $null, { - $tray.Visible = $false - [System.Windows.Forms.Application]::Exit() -}) +}.GetNewClosure() + +$hotkeyChanged = { + param($result) + $active = $result.ActiveBinding + $utilityState.Coordinator.RegisteredHotkey = $active + $utilityState.Context.RegisteredHotkey = $active + $text = if ($null -eq $active) { + 'Unavailable' + } else { + Format-SnipHotkey -Modifiers ([int]$active.Modifiers) -VirtualKey ([int]$active.VirtualKey) + } + $utilityState.Context.ActiveShortcut = $text + $utilityState.Tray.Text = if ($null -eq $active) { 'SnipIT - use tray to capture' } else { "SnipIT - $text to snip" } + if ($utilityState.Menu -and $utilityState.Menu.Tag -and + $utilityState.Menu.Tag.Items['Smart']) { + $utilityState.Menu.Tag.Items['Smart'].Text = "Smart capture ($text)" + } +}.GetNewClosure() + +$script:UtilityContext = [pscustomobject][ordered]@{ + AppVersion = $script:SnipITAppVersion + Repository = 'https://github.com/RandomCodeSpace/snipIT' + License = 'MIT License' + Settings = $script:Settings + SettingsPath = $script:SettingsPath + RegisteredHotkey = $registeredHotkey + ActiveShortcut = $hotkeyText + Hwnd = $hotkeyForm.Handle + RegisterHotkey = $registerNativeHotkey + UnregisterHotkey = $unregisterNativeHotkey + SubmitRequest = $submitUtilityRequest + OpenSettings = $openSettings + OpenAbout = $openAbout + OpenFolder = $openFolder + Exit = $exitApplication + Uninstall = $uninstallApplication + SyncStartup = $syncStartup + SetWidgetVisible = $setWidgetVisible + OnHotkeyChanged = $hotkeyChanged + OnSurfaceReady = $publishAuxiliarySurface + OnSurfaceClosed = $completeAuxiliarySurface + RegisterWindow = { param($hwnd) Register-SelfWindowHandle -Hwnd $hwnd } + UnregisterWindow = { param($hwnd) Unregister-SelfWindowHandle -Hwnd $hwnd } +} + +$utilityState.Context = $script:UtilityContext + +$menu = New-SnipTrayMenu -Context $script:UtilityContext + +$utilityState.Menu = $menu + $tray.ContextMenuStrip = $menu -$tray.Add_DoubleClick({ Invoke-SmartCapture }) -if ($hotkeyErrors.Count -gt 0) { - Write-SnipDiag "Hotkey registration failed: $($hotkeyErrors -join ', ')" +$trayDoubleClickHandler = [EventHandler]{ + param($sender,$eventArgs) + & $utilityState.Context.SubmitRequest 'Smart' ([timespan]::Zero) 'Tray' | Out-Null +}.GetNewClosure() + +$tray.Add_DoubleClick($trayDoubleClickHandler) + +if ($script:Settings.WidgetVisible) { + Show-FloatingWidget -Context $script:UtilityContext | Out-Null +} + +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. Choose another shortcut in Settings; capture remains available from the tray." $tray.ShowBalloonTip(5000) -} elseif ($freshInstall) { + $conflictSettingsAction = [Action]{ + & $utilityState.Context.OpenSettings | Out-Null + }.GetNewClosure() + [void]$hotkeyForm.BeginInvoke($conflictSettingsAction) +} elseif ($script:SnipFreshInstall) { $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) } -#endregion - -#region Main loop & cleanup ================================================= - -try { - [System.Windows.Forms.Application]::Run() -} catch { +[System.Windows.Forms.Application]::Run() +} +catch { # Surface the actual inner exception (PS wraps the .NET one in a # MethodInvocationException whose Message is unhelpfully generic). $msg = $_.Exception.Message @@ -2607,21 +11026,41 @@ 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 {} +} +finally { + if ($script:CaptureCoordinator) { + try { Stop-SnipCaptureCoordinator -Coordinator $script:CaptureCoordinator } + catch { Write-SnipDiag -Message 'Capture coordinator cleanup failed' -ErrorRecord $_ } + } + $activeHotkeyAtShutdown = if ($script:UtilityContext) { + $script:UtilityContext.RegisteredHotkey + } else { + $registeredHotkey + } + if ($activeHotkeyAtShutdown -and $hotkeyForm) { + try { [Native]::UnregisterHotKey($hotkeyForm.Handle, 1) | Out-Null } + catch { Write-SnipDiag -Message 'Hotkey cleanup failed' -ErrorRecord $_ } + } + if ($script:WidgetWindow -and $script:WidgetWindow.IsVisible) { + try { $script:WidgetWindow.Close() } catch {} + } + if ($tray -and $trayDoubleClickHandler) { + try { $tray.Remove_DoubleClick($trayDoubleClickHandler) } catch {} + } + if ($menu) { try { $menu.Dispose() } catch {} } if ($tray) { try { $tray.Dispose() } catch {} } if ($hotkeyForm) { try { $hotkeyForm.Dispose() } catch {} } if ($script:SingleInstanceMutex) { try { $script:SingleInstanceMutex.ReleaseMutex() } catch {} try { $script:SingleInstanceMutex.Dispose() } catch {} } + $script:SnipBootstrapInitializer = $null + $script:SnipNativeInitializer = $null } - -#endregion diff --git a/Test-SnipIT-Build.ps1 b/Test-SnipIT-Build.ps1 new file mode 100644 index 0000000..e8315d6 --- /dev/null +++ b/Test-SnipIT-Build.ps1 @@ -0,0 +1,879 @@ +#requires -Version 7.5 +[CmdletBinding()] +param( + [string]$ScriptPath = (Join-Path $PSScriptRoot 'SnipIT.ps1'), + [string]$ExpectedSurfacePath = + (Join-Path $PSScriptRoot 'tests/baselines/snipit-function-surface.json') +) + +$ErrorActionPreference = 'Stop' + +function Should-Be { + param($Actual, $Expected) + if ($Actual -ne $Expected) { + throw "Expected '$Expected' but got '$Actual'" + } +} + +function Should-BeTrue { + param($Value) + if (-not $Value) { throw 'Expected $true' } +} + +function Should-BeSequence { + param($Actual, $Expected) + + $actualValues = @($Actual) + $expectedValues = @($Expected) + Should-Be $actualValues.Count $expectedValues.Count + for ($index = 0; $index -lt $expectedValues.Count; $index++) { + Should-Be $actualValues[$index] $expectedValues[$index] + } +} + +function Get-SnipFunctionSurface { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Path) + + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile( + $Path, [ref]$tokens, [ref]$errors) + if ($errors.Count) { + throw ($errors.Message -join [Environment]::NewLine) + } + + Get-SnipFunctionSurfaceFromAst -Ast $ast +} + +function Should-BeBytes { + [CmdletBinding()] + param( + [Parameter(Mandatory)][byte[]]$Actual, + [Parameter(Mandatory)][byte[]]$Expected + ) + + Should-Be $Actual.Length $Expected.Length + for ($index = 0; $index -lt $Expected.Length; $index++) { + Should-Be $Actual[$index] $Expected[$index] + } +} + +function New-SnipBuildFixture { + [CmdletBinding()] + param() + + $root = Join-Path ([IO.Path]::GetTempPath()) ( + 'snipit-build-fixture-{0}' -f [guid]::NewGuid().ToString('N')) + [IO.Directory]::CreateDirectory($root) | Out-Null + [IO.File]::Copy( + (Join-Path $PSScriptRoot 'Build-SnipIT.ps1'), + (Join-Path $root 'Build-SnipIT.ps1')) + foreach ($file in @('SnipIT.Dev.ps1')) { + [IO.File]::Copy((Join-Path $PSScriptRoot $file), (Join-Path $root $file)) + } + foreach ($directory in @('src', 'xaml')) { + Copy-Item -LiteralPath (Join-Path $PSScriptRoot $directory) ` + -Destination (Join-Path $root $directory) -Recurse + } + $root +} + +function Set-SnipFixtureText { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][AllowEmptyString()][string]$Text + ) + + [IO.File]::WriteAllText( + $Path, + (($Text -replace "`r`n", "`n") -replace "`r", "`n"), + [Text.UTF8Encoding]::new($false)) +} + +function Assert-SnipFixtureFailure { + [CmdletBinding()] + param( + [Parameter(Mandatory)][scriptblock]$Arrange, + [string]$MessageLike, + [scriptblock]$MessageAssertion + ) + + $root = New-SnipBuildFixture + $outputPath = Join-Path $root 'SnipIT.ps1' + $sentinel = [byte[]](0, 255, 13, 10, 83, 78, 73, 80) + try { + [IO.File]::WriteAllBytes($outputPath, $sentinel) + & $Arrange $root + + $message = try { + & (Join-Path $root 'Build-SnipIT.ps1') -OutputPath $outputPath + $null + } + catch { + $_.Exception.Message + } + + Should-BeTrue ($null -ne $message) + if ($MessageAssertion) { + & $MessageAssertion $message $root + } + else { + Should-BeTrue ($message -like $MessageLike) + } + Should-BeBytes ([IO.File]::ReadAllBytes($outputPath)) $sentinel + Should-Be @(Get-ChildItem -LiteralPath $root -Filter 'SnipIT.ps1.tmp.*').Count 0 + } + finally { + Remove-Item -LiteralPath $root -Recurse -Force + } +} + +function Get-SnipFunctionSurfaceFromAst { + [CmdletBinding()] + param([Parameter(Mandatory)][Management.Automation.Language.Ast]$Ast) + + $normalizeExtent = { + param($Extent) + if ($null -eq $Extent) { return '' } + (([string]$Extent.Text -replace "`r`n", "`n") -replace "`r", "`n").Trim() + } + [ordered]@{ + ScriptParamBlock = if ($null -eq $Ast.ParamBlock) { + '' + } else { + & $normalizeExtent $Ast.ParamBlock.Extent + } + Functions = @($Ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] + }, $true) | Sort-Object Name, { $_.Extent.StartOffset } | ForEach-Object { + $paramExtent = if ($null -eq $_.Body.ParamBlock) { + $null + } else { + $_.Body.ParamBlock.Extent + } + [ordered]@{ + Name = $_.Name + IsFilter = [bool]$_.IsFilter + IsWorkflow = [bool]$_.IsWorkflow + ParamBlock = & $normalizeExtent $paramExtent + } + }) + } +} + +function Get-SnipAst { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Path) + + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile( + $Path, [ref]$tokens, [ref]$errors) + if ($errors.Count) { + throw "Module does not parse independently: $Path :: $($errors.Message -join [Environment]::NewLine)" + } + + $ast +} + +$builderPath = Join-Path $PSScriptRoot 'Build-SnipIT.ps1' +$manifest = & $builderPath -ManifestOnly +Should-BeSequence $manifest.Modules @( + 'src/00-Core.ps1' + 'src/10-Bootstrap.ps1' + 'src/20-Native.ps1' + 'src/30-Capture.ps1' + 'src/40-Preview.ps1' + 'src/50-Tray.ps1' + 'src/90-Main.ps1' +) + +$moduleAsts = [ordered]@{} +$rootFunctionOwners = @{} +foreach ($relativePath in $manifest.Modules) { + $modulePath = Join-Path $PSScriptRoot $relativePath + if (-not (Test-Path -LiteralPath $modulePath -PathType Leaf)) { + throw "Missing manifest module leaf: $relativePath" + } + $moduleAsts[$relativePath] = Get-SnipAst -Path $modulePath + $rootFunctions = @($moduleAsts[$relativePath].FindAll({ + param($node) + if ($node -isnot [Management.Automation.Language.FunctionDefinitionAst]) { + return $false + } + $parent = $node.Parent + while ($null -ne $parent) { + if ($parent -is [Management.Automation.Language.FunctionDefinitionAst]) { + return $false + } + $parent = $parent.Parent + } + $true + }, $true)) + foreach ($function in $rootFunctions) { + if ($rootFunctionOwners.ContainsKey($function.Name)) { + throw "Duplicate root function ownership: $($function.Name) in $relativePath and $($rootFunctionOwners[$function.Name])" + } + $rootFunctionOwners[$function.Name] = $relativePath + } + + $moduleText = [IO.File]::ReadAllText($modulePath) + if ($moduleText -match '\$script:SnipEmbeddedXaml\s*=' -or + $moduleText -match 'schemas\.microsoft\.com/winfx/2006/xaml/presentation') { + throw "Embedded XAML body remains in development module: $relativePath" + } + if ($relativePath -eq 'src/10-Bootstrap.ps1' -and + $moduleText -match '\$PSCommandPath') { + throw 'Bootstrap module retained module-local PSCommandPath semantics' + } +} +Write-Host 'PASS All seven manifest modules exist and parse independently' -ForegroundColor Green +Write-Host 'PASS Root function ownership is unique and modules contain no embedded XAML' -ForegroundColor Green + +$installFunction = $moduleAsts['src/10-Bootstrap.ps1'].Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Install-SnipIT' +}, $true) +Should-BeTrue ($null -ne $installFunction) +if ($installFunction.Extent.Text -notmatch '\$script:SnipInstallSourcePath' -or + $installFunction.Extent.Text -match '\$script:SnipEntryPath') { + throw 'Install-SnipIT must copy the standalone install payload, never the development launcher' +} +Write-Host 'PASS Install-SnipIT uses standalone payload state, not relaunch entry state' -ForegroundColor Green + +$combinedModuleText = ($manifest.Modules | ForEach-Object { + [IO.File]::ReadAllText((Join-Path $PSScriptRoot $_)) +}) -join "`n" +$combinedTokens = $null +$combinedErrors = $null +$combinedModuleAst = [Management.Automation.Language.Parser]::ParseInput( + $combinedModuleText, [ref]$combinedTokens, [ref]$combinedErrors) +if ($combinedErrors.Count) { + throw ($combinedErrors.Message -join [Environment]::NewLine) +} + +$resolvedExpected = [IO.Path]::GetFullPath($ExpectedSurfacePath) +if (-not (Test-Path -LiteralPath $resolvedExpected -PathType Leaf)) { + throw "Missing function surface baseline: $resolvedExpected" +} +$expected = (Get-Content -Raw -LiteralPath $resolvedExpected) -replace "`r`n", "`n" +$moduleSurface = Get-SnipFunctionSurfaceFromAst -Ast $combinedModuleAst +$moduleJson = ($moduleSurface | ConvertTo-Json -Depth 10) -replace "`r`n", "`n" +if ($moduleJson.Trim() -ne $expected.Trim()) { + throw 'Combined module function surface or multiplicity differs from the Task 3 baseline' +} +Write-Host 'PASS Combined module function surface and multiplicity match the Task 3 baseline' -ForegroundColor Green + +$exportRoot = Join-Path ([IO.Path]::GetTempPath()) ( + 'snipit-module-export-{0}' -f [guid]::NewGuid().ToString('N')) +try { + [IO.Directory]::CreateDirectory($exportRoot) | Out-Null + & (Join-Path $PSScriptRoot 'scripts/Export-SnipITModules.ps1') ` + -SourcePath (Join-Path $PSScriptRoot 'SnipIT.ps1') ` + -DestinationRoot $exportRoot | Out-Null + foreach ($relativePath in $manifest.Modules) { + $actualBytes = [IO.File]::ReadAllBytes((Join-Path $PSScriptRoot $relativePath)) + $exportedBytes = [IO.File]::ReadAllBytes((Join-Path $exportRoot $relativePath)) + Should-BeSequence $exportedBytes $actualBytes + } +} +finally { + if (Test-Path -LiteralPath $exportRoot) { + Remove-Item -LiteralPath $exportRoot -Recurse -Force + } +} +Write-Host 'PASS AST exporter round trip is byte-identical' -ForegroundColor Green + +function Invoke-SnipChildCheck { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Code, + [switch]$Sta + ) + + $arguments = @('-NoProfile') + if ($Sta) { $arguments += '-Sta' } + $arguments += @('-Command', $Code) + & pwsh @arguments + if ($LASTEXITCODE -ne 0) { + throw "Child PowerShell check failed with exit code $LASTEXITCODE`n$Code" + } +} + +function Assert-SnipDevPreflightFailure { + [CmdletBinding()] + param( + [Parameter(Mandatory)][scriptblock]$Arrange, + [Parameter(Mandatory)][string]$MessageLike + ) + + $root = New-SnipBuildFixture + try { + & $Arrange $root + $devPath = (Join-Path $root 'SnipIT.Dev.ps1').Replace("'", "''") + $expectedMessage = $MessageLike.Replace("'", "''") + Invoke-SnipChildCheck -Code @" +try { . '$devPath' -CoreOnly; exit 2 } catch { + if (Get-Command Get-DragRectangle -ErrorAction Ignore) { exit 3 } + if (`$_.Exception.Message -notlike '$expectedMessage') { exit 4 } +} +exit 0 +"@ + } + finally { + Remove-Item -LiteralPath $root -Recurse -Force + } +} + +$coreModulePath = (Join-Path $PSScriptRoot 'src/00-Core.ps1').Replace("'", "''") +$devScriptPath = (Join-Path $PSScriptRoot 'SnipIT.Dev.ps1').Replace("'", "''") +$releaseScriptPath = (Join-Path $PSScriptRoot 'SnipIT.ps1').Replace("'", "''") +Invoke-SnipChildCheck -Code @" +`$before = @([AppDomain]::CurrentDomain.GetAssemblies().GetName().Name) +. '$coreModulePath' -CoreOnly +if (-not (Get-Command Get-DragRectangle -ErrorAction Ignore) -or + -not (Get-Command Invoke-SnipCapturePump -ErrorAction Ignore) -or + (Get-Command Get-SnipSettingsPath -ErrorAction Ignore)) { exit 2 } +if (`$script:SnipEntryPath -ne '$coreModulePath' -or + `$script:SnipInstallSourcePath -ne '$coreModulePath') { exit 4 } +`$after = @([AppDomain]::CurrentDomain.GetAssemblies().GetName().Name) +if (@(`$after | Where-Object { `$_ -notin `$before -and `$_ -in @( + 'PresentationFramework', 'PresentationCore', 'WindowsBase', 'System.Windows.Forms') }).Count) { exit 3 } +"@ +Invoke-SnipChildCheck -Code @" +. '$devScriptPath' -CoreOnly +if (-not (Get-Command Invoke-SnipCapturePump -ErrorAction Ignore)) { exit 2 } +if (`$script:SnipEntryPath -ne '$devScriptPath') { exit 4 } +if (`$script:SnipInstallSourcePath -ne '$releaseScriptPath' -or + -not (Test-Path -LiteralPath `$script:SnipInstallSourcePath -PathType Leaf)) { exit 5 } +`$resolver = Get-Variable -Name SnipDevelopmentXamlResolver -Scope Script -ValueOnly -ErrorAction Ignore +if (`$resolver -isnot [scriptblock]) { exit 6 } +`$expectedXaml = [IO.File]::ReadAllText( + (Join-Path '$($PSScriptRoot.Replace("'", "''"))' 'xaml/ThemeResources.xaml'), + [Text.UTF8Encoding]::new(`$false, `$true)) +if ((& `$resolver 'ThemeResources') -ne `$expectedXaml) { exit 7 } +try { & `$resolver 'UnknownSurface'; exit 8 } catch { + if (`$_.Exception.Message -notlike '*Unknown XAML*UnknownSurface*') { exit 9 } +} +try { `$null.Count | Out-Null } catch { exit 3 } +"@ +Invoke-SnipChildCheck -Code @" +. '$releaseScriptPath' -CoreOnly +if (`$script:SnipEntryPath -ne '$releaseScriptPath' -or + `$script:SnipInstallSourcePath -ne '$releaseScriptPath') { exit 2 } +"@ +Write-Host 'PASS Direct Core and development CoreOnly loads avoid Windows-only assemblies' -ForegroundColor Green +Write-Host 'PASS Development CoreOnly preserves the monolith caller StrictMode semantics' -ForegroundColor Green +Write-Host 'PASS Generated release entry and install paths target the portable script' -ForegroundColor Green + +$preflightFixture = New-SnipBuildFixture +try { + $fixtureDev = (Join-Path $preflightFixture 'SnipIT.Dev.ps1').Replace("'", "''") + $fixtureXaml = (Join-Path $preflightFixture 'xaml/ThemeResources.xaml').Replace("'", "''") + Invoke-SnipChildCheck -Code @" +. '$fixtureDev' -CoreOnly +if (-not (Get-Command Get-DragRectangle -ErrorAction Ignore) -or + -not (Get-Command Invoke-SnipCapturePump -ErrorAction Ignore) -or + (Get-Command Get-SnipSettingsPath -ErrorAction Ignore)) { exit 2 } +`$resolver = Get-Variable -Name SnipDevelopmentXamlResolver -Scope Script -ValueOnly -ErrorAction Ignore +if (`$resolver -isnot [scriptblock]) { exit 3 } +`$expected = [IO.File]::ReadAllText('$fixtureXaml', [Text.UTF8Encoding]::new(`$false, `$true)) +Remove-Item -LiteralPath '$fixtureXaml' +if ((& `$resolver 'ThemeResources') -ne `$expected) { exit 4 } +try { & `$resolver 'UnknownSurface'; exit 5 } catch { + if (`$_.Exception.Message -notlike '*Unknown XAML*UnknownSurface*') { exit 6 } +} +exit 0 +"@ +} +finally { + Remove-Item -LiteralPath $preflightFixture -Recurse -Force +} +Write-Host 'PASS Development CoreOnly exposes the full Core surface and preloaded XAML' -ForegroundColor Green + +Assert-SnipDevPreflightFailure -MessageLike '*Missing SnipIT development module:*90-Main.ps1*' -Arrange { + param($root) + Remove-Item -LiteralPath (Join-Path $root 'src/90-Main.ps1') +} +Assert-SnipDevPreflightFailure -MessageLike '*Invalid UTF-8 SnipIT development module:*90-Main.ps1*' -Arrange { + param($root) + [IO.File]::WriteAllBytes( + (Join-Path $root 'src/90-Main.ps1'), + [byte[]](0x66, 0x6F, 0x80, 0x6F)) +} +Assert-SnipDevPreflightFailure -MessageLike '*Invalid PowerShell SnipIT development module:*90-Main.ps1*' -Arrange { + param($root) + Set-SnipFixtureText -Path (Join-Path $root 'src/90-Main.ps1') -Text 'function {' +} +Assert-SnipDevPreflightFailure -MessageLike '*XAML source file*ThemeResources*does not exist*' -Arrange { + param($root) + Remove-Item -LiteralPath (Join-Path $root 'xaml/ThemeResources.xaml') +} +Assert-SnipDevPreflightFailure -MessageLike '*Invalid UTF-8 XAML*ThemeResources*' -Arrange { + param($root) + [IO.File]::WriteAllBytes( + (Join-Path $root 'xaml/ThemeResources.xaml'), + [byte[]](0x3C, 0x80, 0x3E)) +} +Assert-SnipDevPreflightFailure -MessageLike '*Invalid XML XAML*ThemeResources*' -Arrange { + param($root) + Set-SnipFixtureText -Path (Join-Path $root 'xaml/ThemeResources.xaml') -Text '' +} +Write-Host 'PASS Development preflight rejects incomplete or malformed inputs before Core loads' -ForegroundColor Green + +if ($IsWindows) { + Invoke-SnipChildCheck -Sta -Code @" +`$env:SNIPIT_TEST_MODE = '1' +. '$devScriptPath' +if (-not (Get-Command Get-SnipSettingsPath -ErrorAction Ignore) -or + -not (Get-Command Show-PreviewWindow -ErrorAction Ignore) -or + -not (Get-Command New-SnipTrayMenu -ErrorAction Ignore)) { exit 2 } +if (`$null -ne `$script:SingleInstanceMutex -or `$null -ne `$hotkeyForm -or + `$null -ne `$tray -or `$null -ne `$script:CaptureCoordinator) { exit 3 } +if (`$null -ne `$script:SnipBootstrapInitializer -or + `$null -ne `$script:SnipNativeInitializer -or + -not (Get-Variable -Name SnipFreshInstall -Scope Script -ErrorAction Ignore)) { exit 4 } +"@ + Write-Host 'PASS Development test mode exposes the callable surface without startup side effects' -ForegroundColor Green +} +else { + Write-Host 'SKIP Development full-runtime test-mode probe requires Windows STA' -ForegroundColor DarkYellow +} + +$resolved = [IO.Path]::GetFullPath($ScriptPath) +if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) { + throw "Script under test does not exist: $resolved" +} + +# Exercise the same pure-code load used by the cross-platform release suite. +. $resolved -CoreOnly + +$isDevelopmentLauncher = [IO.Path]::GetFileName($resolved) -eq 'SnipIT.Dev.ps1' +$surface = if ($isDevelopmentLauncher) { + $moduleSurface +} else { + Get-SnipFunctionSurface -Path $resolved +} +$json = ($surface | ConvertTo-Json -Depth 10) -replace "`r`n", "`n" +$resolvedExpected = [IO.Path]::GetFullPath($ExpectedSurfacePath) +if (-not (Test-Path -LiteralPath $resolvedExpected -PathType Leaf)) { + throw "Missing function surface baseline: $resolvedExpected" +} + +$expected = (Get-Content -Raw -LiteralPath $resolvedExpected) -replace "`r`n", "`n" +if ($json.Trim() -ne $expected.Trim()) { + throw "Function surface differs for $resolved" +} + +Write-Host "PASS Function surface matches $resolvedExpected" -ForegroundColor Green + +$builderPath = Join-Path $PSScriptRoot 'Build-SnipIT.ps1' +$manifest = & $builderPath -ManifestOnly +Should-BeSequence $manifest.Modules @( + 'src/00-Core.ps1' + 'src/10-Bootstrap.ps1' + 'src/20-Native.ps1' + 'src/30-Capture.ps1' + 'src/40-Preview.ps1' + 'src/50-Tray.ps1' + 'src/90-Main.ps1' +) +Should-BeSequence $manifest.Xaml.Keys @( + 'ThemeResources' + 'PreviewWindow' + 'SmartOverlay' + 'SettingsWindow' + 'AboutWindow' + 'FloatingWidget' +) +Should-BeSequence $manifest.Xaml.Values @( + 'xaml/ThemeResources.xaml' + 'xaml/PreviewWindow.xaml' + 'xaml/SmartOverlay.xaml' + 'xaml/SettingsWindow.xaml' + 'xaml/AboutWindow.xaml' + 'xaml/FloatingWidget.xaml' +) +Write-Host 'PASS Modular source manifest has the exact required order' -ForegroundColor Green + +$utf8 = [Text.UTF8Encoding]::new($false, $true) +$xamlSources = [ordered]@{} +foreach ($entry in $manifest.Xaml.GetEnumerator()) { + $xamlPath = Join-Path $PSScriptRoot $entry.Value + if (-not (Test-Path -LiteralPath $xamlPath -PathType Leaf)) { + throw "Missing manifest XAML source: $xamlPath" + } + + $xamlText = [IO.File]::ReadAllText($xamlPath, $utf8) + if ($xamlText.Contains('$')) { + throw "Manifest XAML source contains PowerShell interpolation: $xamlPath" + } + try { + [void][xml]$xamlText + } + catch { + throw "Manifest XAML source is not valid XML: $xamlPath :: $($_.Exception.Message)" + } + $xamlSources[$entry.Key] = $xamlText +} +Write-Host 'PASS Manifest XAML sources exist, contain no interpolation, and parse as XML' -ForegroundColor Green + +$tokens = $null +$parseErrors = $null +$scriptAst = if ($isDevelopmentLauncher) { + $combinedModuleAst +} else { + [Management.Automation.Language.Parser]::ParseFile( + $resolved, [ref]$tokens, [ref]$parseErrors) +} +if ($parseErrors.Count) { + throw ($parseErrors.Message -join [Environment]::NewLine) +} + +$surfaceNames = [ordered]@{ + 'New-SnipThemeResources' = 'ThemeResources' + 'Show-SettingsWindow' = 'SettingsWindow' + 'Show-AboutWindow' = 'AboutWindow' + 'New-SnipOverlayWindow' = 'SmartOverlay' + 'New-SnipPreviewWindow' = 'PreviewWindow' + 'Show-FloatingWidget' = 'FloatingWidget' +} +foreach ($entry in $surfaceNames.GetEnumerator()) { + $functionAst = $scriptAst.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $entry.Key + }, $true) + Should-BeTrue ($null -ne $functionAst) + if ($functionAst.Extent.Text -match '(?m)^\s*(?:\[xml\])?\$(?:styleXaml|xaml)\s*=\s*@[''\"]') { + throw "Inline XAML assignment remains in $($entry.Key)" + } + if ($functionAst.Extent.Text -notmatch + ('Get-SnipXamlText\s+-Name\s+[''"]{0}[''"]' -f $entry.Value)) { + throw "$($entry.Key) does not load manifest XAML '$($entry.Value)'" + } +} +Write-Host 'PASS Extracted release surfaces contain no inline XAML assignments' -ForegroundColor Green + +$loaderAst = $scriptAst.Find({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Get-SnipXamlText' +}, $true) +Should-BeTrue ($null -ne $loaderAst) +$loaderDefinition = [scriptblock]::Create($loaderAst.Extent.Text) +$embeddedAssignment = $scriptAst.Find({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -eq '$script:SnipEmbeddedXaml' +}, $true) +$embeddedDefinition = if ($null -eq $embeddedAssignment) { + if (-not $isDevelopmentLauncher) { + throw 'Release script is missing the embedded XAML assignment' + } + { $script:SnipEmbeddedXaml = [ordered]@{} } +} else { + [scriptblock]::Create($embeddedAssignment.Extent.Text) +} + +& { + . $loaderDefinition + . $embeddedDefinition + $resolverState = [pscustomobject]@{ InvocationCount = 0 } + $script:SnipDevelopmentXamlResolver = { + param([string]$Name) + $resolverState.InvocationCount++ + throw "Hostile development resolver invoked for '$Name'." + }.GetNewClosure() + Remove-Variable -Name SnipSourceRoot -Scope Script -ErrorAction Ignore + foreach ($entry in $manifest.Xaml.GetEnumerator()) { + Should-Be (Get-SnipXamlText -Name $entry.Key) $xamlSources[$entry.Key] + } + Should-Be $resolverState.InvocationCount 0 + + $script:SnipEmbeddedXaml = [ordered]@{} + $script:SnipDevelopmentXamlResolver = { + param([string]$Name) + if (-not $manifest.Xaml.Contains($Name)) { + throw "Unknown XAML key '$Name'." + } + [string]$xamlSources[$Name] + }.GetNewClosure() + foreach ($entry in $manifest.Xaml.GetEnumerator()) { + Should-Be (Get-SnipXamlText -Name $entry.Key) $xamlSources[$entry.Key] + } + + $unknownKey = try { Get-SnipXamlText -Name 'UnknownSurface'; $false } catch { + $_.Exception.Message -like '*Unknown XAML*UnknownSurface*' + } + Should-BeTrue $unknownKey + + Remove-Variable -Name SnipDevelopmentXamlResolver -Scope Script -ErrorAction Ignore + $noResolver = try { Get-SnipXamlText -Name 'ThemeResources'; $false } catch { + $_.Exception.Message -like '*Embedded XAML*ThemeResources*no development XAML resolver*' + } + Should-BeTrue $noResolver +} +Write-Host 'PASS Embedded and injected development XAML loader paths match with clear errors' -ForegroundColor Green + +$releaseText = [IO.File]::ReadAllText((Join-Path $PSScriptRoot 'SnipIT.ps1'), $utf8) +foreach ($forbiddenText in @('Build-SnipIT.ps1', 'SnipSourceRoot')) { + Should-BeTrue (-not $releaseText.Contains($forbiddenText)) +} +foreach ($relativePath in $manifest.Xaml.Values) { + Should-BeTrue (-not $releaseText.Contains([string]$relativePath)) +} +Write-Host 'PASS Portable release contains no development manifest or external XAML lookup' -ForegroundColor Green + +$standaloneRoot = Join-Path ([IO.Path]::GetTempPath()) ( + 'snipit-standalone-{0}' -f [guid]::NewGuid().ToString('N')) +try { + [IO.Directory]::CreateDirectory($standaloneRoot) | Out-Null + $standaloneRelease = Join-Path $standaloneRoot 'SnipIT.ps1' + [IO.File]::Copy((Join-Path $PSScriptRoot 'SnipIT.ps1'), $standaloneRelease) + $standaloneRelease = $standaloneRelease.Replace("'", "''") + if ($IsWindows) { + Invoke-SnipChildCheck -Sta -Code @" +`$env:SNIPIT_TEST_MODE = '1' +. '$standaloneRelease' +if (Get-Variable -Name SnipDevelopmentXamlResolver -Scope Script -ErrorAction Ignore) { exit 2 } +foreach (`$name in @('ThemeResources', 'PreviewWindow', 'SmartOverlay', + 'SettingsWindow', 'AboutWindow', 'FloatingWidget')) { + if ([string]::IsNullOrEmpty((Get-SnipXamlText -Name `$name))) { exit 3 } +} +exit 0 +"@ + } +} +finally { + Remove-Item -LiteralPath $standaloneRoot -Recurse -Force +} +if ($IsWindows) { + Write-Host 'PASS Standalone release loads embedded XAML without repository files' -ForegroundColor Green +} +else { + Write-Host 'SKIP Standalone full-runtime embedded-XAML probe requires Windows STA' -ForegroundColor DarkYellow +} + +$transactionDirectory = Join-Path ([IO.Path]::GetTempPath()) ( + 'snipit-build-test-{0}' -f [guid]::NewGuid().ToString('N')) +$isolatedBuilderPath = Join-Path $transactionDirectory 'Build-SnipIT.ps1' +$transactionOutput = Join-Path $transactionDirectory 'SnipIT.ps1' +$sentinel = "existing output`n" +[IO.Directory]::CreateDirectory($transactionDirectory) | Out-Null +try { + [IO.File]::Copy($builderPath, $isolatedBuilderPath) + [IO.File]::WriteAllText($transactionOutput, $sentinel, + [Text.UTF8Encoding]::new($false)) + + $buildFailed = $false + try { + & $isolatedBuilderPath -OutputPath $transactionOutput + } + catch { + $buildFailed = $true + } + + Should-BeTrue $buildFailed + Should-Be ([IO.File]::ReadAllText($transactionOutput)) $sentinel + Should-Be @(Get-ChildItem -LiteralPath $transactionDirectory -Filter 'SnipIT.ps1.tmp.*').Count 0 +} +finally { + Remove-Item -LiteralPath $transactionDirectory -Recurse -Force +} +Write-Host 'PASS Missing sources preserve an existing build output' -ForegroundColor Green + +foreach ($entry in $manifest.Xaml.GetEnumerator()) { + $path = Join-Path $PSScriptRoot $entry.Value + $bytes = [IO.File]::ReadAllBytes($path) + Should-BeTrue ($bytes.Length -gt 0) + Should-BeTrue (-not ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and + $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF)) + Should-BeTrue (-not $bytes.Contains([byte]13)) + Should-Be $bytes[-1] 10 + if ($bytes.Length -gt 1) { Should-BeTrue ($bytes[-2] -ne 10) } +} +Write-Host 'PASS Authoritative XAML uses UTF-8 without BOM and one LF terminator' -ForegroundColor Green + +$compositionRoot = New-SnipBuildFixture +try { + $firstOutput = Join-Path $compositionRoot 'first.ps1' + $secondOutput = Join-Path $compositionRoot 'second.ps1' + $releaseCopy = Join-Path $compositionRoot 'SnipIT.ps1' + & (Join-Path $compositionRoot 'Build-SnipIT.ps1') -OutputPath $firstOutput + & (Join-Path $compositionRoot 'Build-SnipIT.ps1') -OutputPath $secondOutput + [IO.File]::Copy((Join-Path $PSScriptRoot 'SnipIT.ps1'), $releaseCopy) + & (Join-Path $compositionRoot 'Build-SnipIT.ps1') -OutputPath $releaseCopy + + $firstBytes = [IO.File]::ReadAllBytes($firstOutput) + $secondBytes = [IO.File]::ReadAllBytes($secondOutput) + $releaseBytes = [IO.File]::ReadAllBytes((Join-Path $PSScriptRoot 'SnipIT.ps1')) + Should-BeBytes $firstBytes $secondBytes + Should-BeBytes $firstBytes $releaseBytes + Should-Be (Get-FileHash -LiteralPath $firstOutput -Algorithm SHA256).Hash ` + (Get-FileHash -LiteralPath $secondOutput -Algorithm SHA256).Hash + Should-BeTrue (-not ($firstBytes.Length -ge 3 -and $firstBytes[0] -eq 0xEF -and + $firstBytes[1] -eq 0xBB -and $firstBytes[2] -eq 0xBF)) + Should-BeTrue (-not $firstBytes.Contains([byte]13)) + Should-Be $firstBytes[-1] 10 + Should-BeTrue ($firstBytes[-2] -ne 10) + + $builtText = [Text.UTF8Encoding]::new($false, $true).GetString($firstBytes) + $previousOffset = -1 + foreach ($relativePath in $manifest.Modules) { + $header = '# source: {0}' -f $relativePath.Replace('\', '/') + Should-Be ([regex]::Matches($builtText, [regex]::Escape($header)).Count) 1 + $offset = $builtText.IndexOf($header, [StringComparison]::Ordinal) + Should-BeTrue ($offset -gt $previousOffset) + $previousOffset = $offset + } + $xamlOffset = $builtText.IndexOf( + '$script:SnipEmbeddedXaml = [ordered]@{', [StringComparison]::Ordinal) + $coreOffset = $builtText.IndexOf('# source: src/00-Core.ps1', [StringComparison]::Ordinal) + $bootstrapOffset = $builtText.IndexOf('# source: src/10-Bootstrap.ps1', [StringComparison]::Ordinal) + Should-BeTrue ($xamlOffset -gt $coreOffset -and $xamlOffset -lt $bootstrapOffset) + Should-Be ([regex]::Matches($builtText, [regex]::Escape('# ')).Count) 0 + + $tokens = $null + $errors = $null + [void][Management.Automation.Language.Parser]::ParseInput( + $builtText, [ref]$tokens, [ref]$errors) + Should-Be $errors.Count 0 +} +finally { + Remove-Item -LiteralPath $compositionRoot -Recurse -Force +} +Write-Host 'PASS Deterministic full composition matches the checked-in portable release' -ForegroundColor Green + +Assert-SnipFixtureFailure -MessageAssertion { + param($message, $root) + Should-Be $message ('Missing SnipIT build source: {0}' -f + [IO.Path]::GetFullPath((Join-Path $root 'src/10-Bootstrap.ps1'))) +} -Arrange { + param($root) + Remove-Item -LiteralPath (Join-Path $root 'src/10-Bootstrap.ps1') +} +Write-Host 'PASS Missing module reports its first exact source and preserves output' -ForegroundColor Green + +Assert-SnipFixtureFailure -MessageLike "*Invalid XAML*PreviewWindow*xaml/PreviewWindow.xaml*" -Arrange { + param($root) + Set-SnipFixtureText -Path (Join-Path $root 'xaml/PreviewWindow.xaml') -Text '' +} +Write-Host 'PASS Malformed XAML is rejected transactionally before composition' -ForegroundColor Green + +Assert-SnipFixtureFailure -MessageLike '*PowerShell parse failed*src/20-Native.ps1*' -Arrange { + param($root) + Set-SnipFixtureText -Path (Join-Path $root 'src/20-Native.ps1') -Text 'function {' +} +Write-Host 'PASS Invalid module PowerShell preserves output and cleans temporary files' -ForegroundColor Green + +Assert-SnipFixtureFailure -MessageLike '*Generated SnipIT PowerShell parse failed:*before any other statements*' -Arrange { + param($root) + $path = Join-Path $root 'src/10-Bootstrap.ps1' + Set-SnipFixtureText -Path $path -Text ( + "using namespace System.Text`n" + [IO.File]::ReadAllText($path)) +} +Write-Host 'PASS Final composed parse failure preserves output and cleans temporary files' -ForegroundColor Green + +Assert-SnipFixtureFailure -MessageLike '*Invalid rooted or traversing SnipIT manifest path:*' -Arrange { + param($root) + $builder = Join-Path $root 'Build-SnipIT.ps1' + $text = [IO.File]::ReadAllText($builder) + Set-SnipFixtureText -Path $builder -Text $text.Replace( + "'src/10-Bootstrap.ps1'", "'../outside.ps1'") +} +Assert-SnipFixtureFailure -MessageLike '*Invalid rooted or traversing SnipIT manifest path:*' -Arrange { + param($root) + $builder = Join-Path $root 'Build-SnipIT.ps1' + $text = [IO.File]::ReadAllText($builder) + $rooted = ([IO.Path]::GetFullPath((Join-Path $root 'src/10-Bootstrap.ps1'))).Replace("'", "''") + Set-SnipFixtureText -Path $builder -Text $text.Replace( + "'src/10-Bootstrap.ps1'", "'$rooted'") +} +Write-Host 'PASS Traversing and rooted manifest paths are rejected transactionally' -ForegroundColor Green + +$linkFixtureRoot = New-SnipBuildFixture +$linkTargetRoot = Join-Path ([IO.Path]::GetTempPath()) ( + 'snipit-build-link-target-{0}' -f [guid]::NewGuid().ToString('N')) +$linkOutput = Join-Path $linkFixtureRoot 'SnipIT.ps1' +$linkSentinel = [byte[]](83, 78, 73, 80, 0, 255) +$linkPath = Join-Path $linkFixtureRoot 'src' +try { + [IO.Directory]::CreateDirectory($linkTargetRoot) | Out-Null + Copy-Item -LiteralPath $linkPath -Destination $linkTargetRoot -Recurse + Remove-Item -LiteralPath $linkPath -Recurse -Force + $linkType = if ($IsWindows) { 'Junction' } else { 'SymbolicLink' } + try { + [void](New-Item -ItemType $linkType -Path $linkPath ` + -Target (Join-Path $linkTargetRoot 'src')) + } + catch { + throw "Unable to create required $linkType containment fixture: $($_.Exception.Message)" + } + [IO.File]::WriteAllBytes($linkOutput, $linkSentinel) + + $linkMessage = try { + & (Join-Path $linkFixtureRoot 'Build-SnipIT.ps1') -OutputPath $linkOutput + $null + } + catch { + $_.Exception.Message + } + Should-BeTrue ($linkMessage -like '*reparse point or symbolic link*src*') + Should-BeBytes ([IO.File]::ReadAllBytes($linkOutput)) $linkSentinel + Should-Be @(Get-ChildItem -LiteralPath $linkFixtureRoot -Filter 'SnipIT.ps1.tmp.*').Count 0 +} +finally { + if (Test-Path -LiteralPath $linkPath) { + (Get-Item -LiteralPath $linkPath -Force).Delete() + } + if (Test-Path -LiteralPath $linkFixtureRoot) { + Remove-Item -LiteralPath $linkFixtureRoot -Recurse -Force + } + if (Test-Path -LiteralPath $linkTargetRoot) { + Remove-Item -LiteralPath $linkTargetRoot -Recurse -Force + } +} +Write-Host 'PASS Reparse-point manifest escape is rejected transactionally' -ForegroundColor Green + +Assert-SnipFixtureFailure -MessageLike '*Duplicate SnipIT manifest module:*src/10-Bootstrap.ps1*' -Arrange { + param($root) + $builder = Join-Path $root 'Build-SnipIT.ps1' + $text = [IO.File]::ReadAllText($builder) + Set-SnipFixtureText -Path $builder -Text $text.Replace( + " 'src/20-Native.ps1'", " 'src/10-Bootstrap.ps1'") +} +Write-Host 'PASS Duplicate manifest entries are rejected transactionally' -ForegroundColor Green + +Assert-SnipFixtureFailure -MessageLike '*XAML embed marker must occur exactly once*' -Arrange { + param($root) + $path = Join-Path $root 'src/20-Native.ps1' + Set-SnipFixtureText -Path $path -Text ( + ([IO.File]::ReadAllText($path)).TrimEnd() + "`n# `n") +} +Assert-SnipFixtureFailure -MessageLike '*Unresolved XAML embed marker remains*' -Arrange { + param($root) + $path = Join-Path $root 'xaml/AboutWindow.xaml' + Set-SnipFixtureText -Path $path -Text ( + ([IO.File]::ReadAllText($path)).TrimEnd() + "`n`n") +} +Write-Host 'PASS Duplicate and unresolved synthetic embed markers are rejected' -ForegroundColor Green + +Assert-SnipFixtureFailure -MessageLike "*reserved here-string delimiter line*" -Arrange { + param($root) + $path = Join-Path $root 'xaml/AboutWindow.xaml' + Set-SnipFixtureText -Path $path -Text "`n'@`n`n" +} +Write-Host 'PASS Unsafe here-string delimiter content is rejected transactionally' -ForegroundColor Green + +Assert-SnipFixtureFailure -MessageLike '*Invalid UTF-8 SnipIT build source:*src/30-Capture.ps1*' -Arrange { + param($root) + [IO.File]::WriteAllBytes( + (Join-Path $root 'src/30-Capture.ps1'), + [byte[]](0x66, 0x6F, 0x80, 0x6F)) +} +Write-Host 'PASS Invalid UTF-8 source is rejected transactionally' -ForegroundColor Green diff --git a/Test-SnipIT-Interactive.ps1 b/Test-SnipIT-Interactive.ps1 index a6c7cd1..58bcdc9 100644 --- a/Test-SnipIT-Interactive.ps1 +++ b/Test-SnipIT-Interactive.ps1 @@ -11,6 +11,15 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +$scriptUnderTest = if ([string]::IsNullOrWhiteSpace($env:SNIPIT_SCRIPT_UNDER_TEST)) { + Join-Path $PSScriptRoot 'SnipIT.ps1' +} else { + [IO.Path]::GetFullPath($env:SNIPIT_SCRIPT_UNDER_TEST) +} +if (-not (Test-Path -LiteralPath $scriptUnderTest -PathType Leaf)) { + throw "SNIPIT_SCRIPT_UNDER_TEST does not exist: $scriptUnderTest" +} + $env:SNIPIT_TEST_MODE = '1' # STA required by WPF. If we got launched from bash MTA, relaunch self. @@ -22,17 +31,66 @@ if ([System.Threading.Thread]::CurrentThread.GetApartmentState() -ne 'STA') { # Dot-source SnipIT.ps1 to get Show-PreviewWindow and helpers. The test-mode # guards inside SnipIT.ps1 short-circuit side-effect sections. -. (Join-Path $PSScriptRoot 'SnipIT.ps1') +. $scriptUnderTest Add-Type -AssemblyName PresentationFramework Add-Type -AssemblyName PresentationCore Add-Type -AssemblyName WindowsBase Add-Type -AssemblyName System.Drawing +if (-not ('SnipTestMouseButtonEventArgs' -as [type])) { + $positionedMouseReferences = @( + [System.Windows.Input.MouseButtonEventArgs].Assembly.Location, + [System.Windows.Point].Assembly.Location, + [object].Assembly.Location) | Sort-Object -Unique + Add-Type -ReferencedAssemblies $positionedMouseReferences -TypeDefinition @' +using System.Windows; +using System.Windows.Input; + +public sealed class SnipTestMouseButtonEventArgs : MouseButtonEventArgs +{ + private readonly Point position; + + public SnipTestMouseButtonEventArgs( + MouseDevice mouseDevice, + int timestamp, + MouseButton changedButton, + Point position) : base(mouseDevice, timestamp, changedButton) + { + this.position = position; + } + + public new Point GetPosition(IInputElement relativeTo) + { + return position; + } +} + +public sealed class SnipTestMouseEventArgs : MouseEventArgs +{ + private readonly Point position; + + public SnipTestMouseEventArgs( + MouseDevice mouseDevice, + int timestamp, + Point position) : base(mouseDevice, timestamp) + { + this.position = position; + } + + public new Point GetPosition(IInputElement relativeTo) + { + return position; + } +} +'@ +} # ---- Test framework ---- $script:Results = [System.Collections.ArrayList]::new() $script:CurrentGroup = '' function Describe { param([string]$Name, [scriptblock]$Body) + if (-not [string]::IsNullOrWhiteSpace($env:SNIPIT_TEST_GROUP) -and + $Name -notlike $env:SNIPIT_TEST_GROUP) { return } $script:CurrentGroup = $Name Write-Host "`n$Name" -ForegroundColor Cyan & $Body @@ -46,6 +104,10 @@ function It { param([string]$Name, [scriptblock]$Body) [void]$script:Results.Add([pscustomobject]@{ Group=$script:CurrentGroup; Name=$Name; Pass=$false; Err=$_.Exception.Message }) Write-Host " FAIL $Name" -ForegroundColor Red Write-Host " $($_.Exception.Message)" -ForegroundColor DarkRed + if (-not [string]::IsNullOrWhiteSpace($_.ScriptStackTrace)) { + Write-Host " $($_.ScriptStackTrace -replace "`n","`n ")" ` + -ForegroundColor DarkRed + } } } function Should-Be { param($Actual, $Expected, [double]$Tol=0) @@ -59,24 +121,6014 @@ function Should-Be { param($Actual, $Expected, [double]$Tol=0) } function Should-BeTrue { param($Actual) if (-not $Actual) { throw "Expected truthy, got <$Actual>" } } function Should-BeFalse { param($Actual) if ($Actual) { throw "Expected falsy, got <$Actual>" } } + +function New-RoutedKeyEvent { + param( + [Parameter(Mandatory)] [System.Windows.IInputElement]$Target, + [Parameter(Mandatory)] [System.Windows.Input.Key]$Key, + [System.Windows.Input.Key]$SystemKey = [System.Windows.Input.Key]::None, + [System.Windows.RoutedEvent]$RoutedEvent = [System.Windows.Input.Keyboard]::PreviewKeyDownEvent + ) + $source = [System.Windows.PresentationSource]::FromVisual($Target) + $eventKey = if ($SystemKey -ne [System.Windows.Input.Key]::None) { + [System.Windows.Input.Key]::System + } else { $Key } + $eventArgs = [System.Windows.Input.KeyEventArgs]::new( + [System.Windows.Input.Keyboard]::PrimaryDevice, $source, + [Environment]::TickCount, $eventKey) + if ($SystemKey -ne [System.Windows.Input.Key]::None) { + $realKeyField = [System.Windows.Input.KeyEventArgs].GetField( + '_realKey', [System.Reflection.BindingFlags]'Instance,NonPublic') + $realKeyField.SetValue($eventArgs, $SystemKey) + } + $eventArgs.RoutedEvent = $RoutedEvent + $eventArgs +} + +function New-RoutedMouseDoubleClick { + param([Parameter(Mandatory)] [System.Windows.IInputElement]$Target) + $eventArgs = [System.Windows.Input.MouseButtonEventArgs]::new( + [System.Windows.Input.Mouse]::PrimaryDevice, + [Environment]::TickCount, + [System.Windows.Input.MouseButton]::Left) + $eventArgs.RoutedEvent = [System.Windows.UIElement]::MouseLeftButtonDownEvent + $countField = [System.Windows.Input.MouseButtonEventArgs].GetField( + '_count', [System.Reflection.BindingFlags]'Instance,NonPublic') + $countField.SetValue($eventArgs, 2) + $eventArgs +} +function New-RoutedMouseRightClick { + param( + [Parameter(Mandatory)] [System.Windows.IInputElement]$Target, + [System.Windows.Point]$Position = [System.Windows.Point]::new(40,40) + ) + $eventArgs = [SnipTestMouseButtonEventArgs]::new( + [System.Windows.Input.Mouse]::PrimaryDevice, + [Environment]::TickCount, + [System.Windows.Input.MouseButton]::Right, + $Position) + $eventArgs.RoutedEvent = [System.Windows.UIElement]::MouseRightButtonDownEvent + $eventArgs +} +function New-RoutedMouseLeftEvent { + param( + [Parameter(Mandatory)] [System.Windows.IInputElement]$Target, + [Parameter(Mandatory)] [System.Windows.Point]$Position, + [Parameter(Mandatory)] [System.Windows.RoutedEvent]$RoutedEvent + ) + $eventArgs = [SnipTestMouseButtonEventArgs]::new( + [System.Windows.Input.Mouse]::PrimaryDevice, + [Environment]::TickCount, + [System.Windows.Input.MouseButton]::Left, + $Position) + $eventArgs.RoutedEvent = $RoutedEvent + $eventArgs +} +function New-RoutedMouseMoveEvent { + param( + [Parameter(Mandatory)] [System.Windows.IInputElement]$Target, + [Parameter(Mandatory)] [System.Windows.Point]$Position + ) + $eventArgs = [SnipTestMouseEventArgs]::new( + [System.Windows.Input.Mouse]::PrimaryDevice, + [Environment]::TickCount, + $Position) + $eventArgs.RoutedEvent = [System.Windows.UIElement]::MouseMoveEvent + $eventArgs +} +function Find-VisualDescendant { + param( + [Parameter(Mandatory)] [System.Windows.DependencyObject]$Root, + [Parameter(Mandatory)] [type]$Type + ) + if ($Root -is $Type) { return $Root } + $count = [System.Windows.Media.VisualTreeHelper]::GetChildrenCount($Root) + for ($index = 0; $index -lt $count; $index++) { + $match = Find-VisualDescendant ` + -Root ([System.Windows.Media.VisualTreeHelper]::GetChild($Root,$index)) ` + -Type $Type + if ($null -ne $match) { return $match } + } + $null +} function Should-BeGreaterThan { param($Actual, $Min) if ([double]$Actual -le [double]$Min) { throw "Expected > $Min, got $Actual" } } -# ---- Build synthetic bitmap ---- -$bmp = New-Object System.Drawing.Bitmap 1200, 800 -$g = [System.Drawing.Graphics]::FromImage($bmp) -$g.Clear([System.Drawing.Color]::SlateBlue) -$g.FillRectangle([System.Drawing.Brushes]::Orange, 100, 100, 400, 300) -$g.FillRectangle([System.Drawing.Brushes]::Lime, 700, 500, 300, 200) -$g.Dispose() +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 -# --- Kick off tests via -TestAction ---- -# The test body runs inside Show-PreviewWindow's scope (during Loaded, -# while ShowDialog is blocking) so the event handlers can find all the -# function-local variables they reference. -Show-PreviewWindow -Bitmap $bmp -TestAction { - param($kit) + 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' { + 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) + 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 + 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 '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 'preserves an unsaved candidate when native cleanup cannot confirm it was removed' { + $lockedSettingsPath = Join-Path $root 'locked-settings.json' + $settings = Get-SnipDefaultSettings -PicturesDir $pictures + Save-SnipSettings -Settings $settings -Path $lockedSettingsPath + $originalFile = Get-Content -Raw -LiteralPath $lockedSettingsPath + $context = [pscustomobject]@{ + Settings = $settings + SettingsPath = $lockedSettingsPath + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + } + $state = @{ UnregisterCount = 0 } + $unregister = { + param($hwnd,$id) + $state.UnregisterCount++ + return $state.UnregisterCount -eq 1 + }.GetNewClosure() + $fileLock = [IO.File]::Open( + $lockedSettingsPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + $result = Set-SnipHotkeyBinding -Context $context ` + -Candidate @{ Modifiers = 0x4007; VirtualKey = 0x54 } ` + -Register { param($hwnd,$id,$mods,$vk) $true } -Unregister $unregister + + Should-BeFalse $result.Success + Should-Be $result.ActiveBinding.VirtualKey 0x54 + Should-Be $context.RegisteredHotkey.VirtualKey 0x54 + Should-Be $context.Settings.Hotkey.VirtualKey 0x51 + Should-BeTrue $result.RollbackError.Contains('may remain active') + Should-Be $state.UnregisterCount 2 + Should-Be (Get-Content -Raw -LiteralPath $lockedSettingsPath) $originalFile + Should-Be @(Get-ChildItem -LiteralPath $root -Filter '*.tmp').Count 0 + } finally { + $fileLock.Dispose() + } + } + + It 'unregisters a preserved possibly-active candidate before a later replacement' { + $followupSettingsPath = Join-Path $root 'followup-settings.json' + $settings = Get-SnipDefaultSettings -PicturesDir $pictures + Save-SnipSettings -Settings $settings -Path $followupSettingsPath + $context = [pscustomobject]@{ + Settings = $settings + SettingsPath = $followupSettingsPath + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x54 } + } + $events = [System.Collections.ArrayList]::new() + $unregister = { + param($hwnd,$id) + [void]$events.Add("Unregister:$id") + $true + }.GetNewClosure() + $register = { + param($hwnd,$id,$mods,$vk) + [void]$events.Add("Register:$vk") + $true + }.GetNewClosure() + + $result = Set-SnipHotkeyBinding -Context $context ` + -Candidate @{ Modifiers = 0x4007; VirtualKey = 0x55 } ` + -Register $register -Unregister $unregister + + Should-BeTrue $result.Success + Should-Be ($events -join ',') 'Unregister:1,Register:85' + Should-Be $context.RegisteredHotkey.VirtualKey 0x55 + Should-Be $context.Settings.Hotkey.VirtualKey 0x55 + Should-Be (Read-SnipSettings -Path $followupSettingsPath -PicturesDir $pictures).Hotkey.VirtualKey 0x55 + } + + 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 +} + +function New-OwnershipProbe { + param([string]$Id = ([guid]::NewGuid().ToString())) + $probe = [pscustomobject]@{ + Id = $Id + DisposeCount = 0 + Disposed = $false + TouchCount = 0 + } + $probe | Add-Member -MemberType ScriptMethod -Name Touch -Value { + if ($this.Disposed) { throw [ObjectDisposedException]::new($this.Id) } + $this.TouchCount++ + } + $probe | Add-Member -MemberType ScriptMethod -Name Dispose -Value { + if ($this.Disposed) { throw [InvalidOperationException]::new("Double dispose: $($this.Id)") } + $this.Disposed = $true + $this.DisposeCount++ + } + $probe +} + +Describe 'Screen bitmap construction ownership' { + It 'disposes the allocated bitmap when Graphics creation fails' { + $parameters = (Get-Command New-ScreenBitmap).Parameters + Should-BeTrue $parameters.ContainsKey('BitmapFactory') + Should-BeTrue $parameters.ContainsKey('GraphicsFactory') + Should-BeTrue $parameters.ContainsKey('CopyPixels') + $bitmap = New-OwnershipProbe -Id graphics-factory-bitmap + $threw = $false + $state = @{ GraphicsCalls = 0; CopyCalls = 0; ErrorMessage = $null } + try { + New-ScreenBitmap -X 0 -Y 0 -Width 8 -Height 8 ` + -BitmapFactory { param($width,$height,$pixelFormat) $bitmap }.GetNewClosure() ` + -GraphicsFactory { + param($ownedBitmap) + $state.GraphicsCalls++ + throw 'graphics creation failed' + }.GetNewClosure() ` + -CopyPixels { + param($graphics,$x,$y,$width,$height) + $state.CopyCalls++ + throw 'copy should not run' + }.GetNewClosure() | + Out-Null + } catch { + $threw = $true + $state.ErrorMessage = $_.Exception.Message + } + + Should-BeTrue $threw + Should-Be $state.GraphicsCalls 1 + Should-Be $state.CopyCalls 0 + Should-Be $state.ErrorMessage 'graphics creation failed' + Should-Be $bitmap.DisposeCount 1 + } + + It 'disposes Graphics and the bitmap when screen copy fails' { + $parameters = (Get-Command New-ScreenBitmap).Parameters + Should-BeTrue $parameters.ContainsKey('BitmapFactory') + Should-BeTrue $parameters.ContainsKey('GraphicsFactory') + Should-BeTrue $parameters.ContainsKey('CopyPixels') + $bitmap = New-OwnershipProbe -Id copy-failure-bitmap + $graphics = New-OwnershipProbe -Id copy-failure-graphics + $threw = $false + try { + New-ScreenBitmap -X 0 -Y 0 -Width 8 -Height 8 ` + -BitmapFactory { param($width,$height,$pixelFormat) $bitmap }.GetNewClosure() ` + -GraphicsFactory { param($ownedBitmap) $graphics }.GetNewClosure() ` + -CopyPixels { param($ownedGraphics,$x,$y,$width,$height) throw 'copy failed' } | + Out-Null + } catch { + $threw = $true + } + + Should-BeTrue $threw + Should-Be $graphics.DisposeCount 1 + Should-Be $bitmap.DisposeCount 1 + } + + It 'returns the bitmap and disposes only Graphics after a successful copy' { + $parameters = (Get-Command New-ScreenBitmap).Parameters + Should-BeTrue $parameters.ContainsKey('BitmapFactory') + Should-BeTrue $parameters.ContainsKey('GraphicsFactory') + Should-BeTrue $parameters.ContainsKey('CopyPixels') + $bitmap = New-OwnershipProbe -Id successful-screen-bitmap + $graphics = New-OwnershipProbe -Id successful-screen-graphics + $copyState = @{ Count = 0 } + $result = New-ScreenBitmap -X -10 -Y 20 -Width 8 -Height 8 ` + -BitmapFactory { param($width,$height,$pixelFormat) $bitmap }.GetNewClosure() ` + -GraphicsFactory { param($ownedBitmap) $graphics }.GetNewClosure() ` + -CopyPixels { + param($ownedGraphics,$x,$y,$width,$height) + $copyState.Count++ + Should-Be $x -10 + Should-Be $y 20 + Should-Be $width 8 + Should-Be $height 8 + }.GetNewClosure() + + Should-BeTrue ([object]::ReferenceEquals($result, $bitmap)) + Should-Be $copyState.Count 1 + Should-Be $graphics.DisposeCount 1 + Should-Be $bitmap.DisposeCount 0 + $bitmap.Dispose() + } +} + +Describe 'Smart overlay snapshot ownership' { + It 'restores hidden windows when snapshot allocation fails' { + $state = @{ Hidden = 0; Restored = 0 } + $result = Show-SmartOverlay ` + -HideWindows { $state.Hidden++; @('own-window') }.GetNewClosure() ` + -RestoreWindows { param($hidden) $state.Restored++; Should-Be @($hidden)[0] 'own-window' }.GetNewClosure() ` + -CaptureFactory { param($bounds) throw 'snapshot failed' } + + Should-Be $result.Result 'Failed' + Should-Be $state.Hidden 1 + Should-Be $state.Restored 1 + } + + It 'disposes the virtual snapshot once when BitmapSource conversion fails' { + $snapshot = New-OwnershipProbe -Id virtual-snapshot + $result = Show-SmartOverlay ` + -HideWindows { @() } ` + -RestoreWindows { param($hidden) } ` + -CaptureFactory { param($bounds) $snapshot }.GetNewClosure() ` + -BitmapSourceFactory { param($bitmap) throw 'conversion failed' } + + Should-Be $result.Result 'Failed' + Should-Be $snapshot.DisposeCount 1 + } + + It 'skips the modal overlay when surface readiness declines it' { + $snapshot = New-OwnershipProbe -Id declined-smart-surface + $state = @{ ReadyCount = 0 } + $result = Show-SmartOverlay ` + -HideWindows { @() } ` + -RestoreWindows { param($hidden) } ` + -CaptureFactory { param($bounds) $snapshot }.GetNewClosure() ` + -BitmapSourceFactory { param($bitmap) $null } ` + -OnSurfaceReady { + param($surface) + $state.ReadyCount++ + $close = $surface.Close + & $close 'Preempted' + $false + }.GetNewClosure() + + Should-Be $state.ReadyCount 1 + Should-Be $result.Result 'Preempted' + Should-Be $snapshot.DisposeCount 1 + } +} + +function New-SnipOverlayTestFixture { + param([object[]]$MonitorDescriptors = @( + [pscustomobject][ordered]@{ + Id = 'left-125'; X = -1600; Y = 0; Width = 1600; Height = 900 + DpiX = 120; DpiY = 120; IsPrimary = $false + }, + [pscustomobject][ordered]@{ + Id = 'primary-100'; X = 0; Y = 0; Width = 1920; Height = 1080 + DpiX = 96; DpiY = 96; IsPrimary = $true + } + )) + + $snapshot = New-OwnershipProbe -Id overlay-snapshot + $snapshotSource = [System.Windows.Media.Imaging.WriteableBitmap]::new( + 3520, 1080, 96, 96, + [System.Windows.Media.PixelFormats]::Bgra32, + $null) + $snapshotSource.Freeze() + $state = @{ + MonitorDescriptors = $MonitorDescriptors + Snapshot = $snapshot + SnapshotSource = $snapshotSource + HideCount = 0 + RestoreCount = 0 + SnapshotCount = 0 + ConvertCount = 0 + RegisterHandles = [System.Collections.ArrayList]::new() + UnregisterHandles = [System.Collections.ArrayList]::new() + RenderingAdds = [System.Collections.ArrayList]::new() + RenderingRemoves = [System.Collections.ArrayList]::new() + CursorX = -500 + CursorY = 100 + CursorCount = 0 + WindowAtPointCount = 0 + WindowBoundsCount = 0 + HoverWidth = 400 + OwnRegistryHandles = @() + OwnRegistryReads = 0 + PositionCalls = 0 + } + $resources = New-SnipThemeResources -HighContrast:$false -AnimationsEnabled:$false + $services = [pscustomobject][ordered]@{ + GetMonitorDescriptors = { + @($state.MonitorDescriptors) + }.GetNewClosure() + GetVirtualBounds = { + [pscustomobject][ordered]@{ X=-1600; Y=0; Width=3520; Height=1080 } + } + HideWindows = { + $state.HideCount++ + @('utility-window') + }.GetNewClosure() + RestoreWindows = { + param($hidden) + $state.RestoreCount++ + Should-Be @($hidden)[0] 'utility-window' + }.GetNewClosure() + CaptureSnapshot = { + param($bounds) + $state.SnapshotCount++ + Should-Be $bounds.X -1600 + Should-Be $bounds.Width 3520 + $state.Snapshot + }.GetNewClosure() + ConvertSnapshotSource = { + param($bitmap) + $state.ConvertCount++ + Should-BeTrue ([object]::ReferenceEquals($bitmap, $state.Snapshot)) + $state.SnapshotSource + }.GetNewClosure() + GetCursorPosition = { + $state.CursorCount++ + [pscustomobject][ordered]@{ X=$state.CursorX; Y=$state.CursorY } + }.GetNewClosure() + GetWindowAtPoint = { + param($point,$ownHandles) + $state.WindowAtPointCount++ + [IntPtr]4242 + }.GetNewClosure() + GetWindowBounds = { + param($hwnd) + $state.WindowBoundsCount++ + [pscustomobject][ordered]@{ + X=-200; Y=40; Width=$state.HoverWidth; Height=300 + } + }.GetNewClosure() + GetOwnWindowHandles = { + $state.OwnRegistryReads++ + @($state.OwnRegistryHandles) + }.GetNewClosure() + PositionWindow = { + param($hwnd,$layout) + $state.PositionCalls++ + $true + }.GetNewClosure() + AddRenderingHandler = { + param($handler) + [void]$state.RenderingAdds.Add($handler) + }.GetNewClosure() + RemoveRenderingHandler = { + param($handler) + [void]$state.RenderingRemoves.Add($handler) + }.GetNewClosure() + RegisterWindow = { + param($hwnd) + [void]$state.RegisterHandles.Add([Int64]$hwnd) + }.GetNewClosure() + UnregisterWindow = { + param($hwnd) + [void]$state.UnregisterHandles.Add([Int64]$hwnd) + }.GetNewClosure() + Resources = $resources + } + + [pscustomobject][ordered]@{ + State = $state + Services = $services + } +} + +Describe 'Per-monitor Smart overlay set' { + It 'exposes the required overlay interfaces and test seams' { + foreach ($name in 'Get-SnipMonitorLayouts','Get-SnipOverlayIntersections', + 'New-SnipOverlayContext','New-SnipOverlayWindow','Invoke-SnipOverlayRenderTick') { + Should-BeTrue ($null -ne (Get-Command $name -ErrorAction SilentlyContinue)) + } + $parameters = (Get-Command Show-SmartOverlay).Parameters + Should-BeTrue $parameters.ContainsKey('Services') + Should-BeTrue $parameters.ContainsKey('TestAction') + } + + It 'creates one registered HWND per monitor around one shared frozen session' { + $fixture = New-SnipOverlayTestFixture + $observed = @{ Context=$null; Overlays=$null } + $result = Show-SmartOverlay -Services $fixture.Services -TestAction { + param($kit) + $observed.Context = $kit.Context + $observed.Overlays = @($kit.Overlays) + Should-Be $observed.Overlays.Count 2 + foreach ($overlay in $observed.Overlays) { + Should-BeTrue ([object]::ReferenceEquals($overlay.Context, $kit.Context)) + Should-BeTrue $overlay.Lifecycle.Connected + } + & $kit.Cancel + }.GetNewClosure() + + Should-Be $result.Result 'UserCancelled' + Should-Be $result.Selection $null + Should-Be $result.Bitmap $null + Should-Be $fixture.State.SnapshotCount 1 + Should-Be $fixture.State.ConvertCount 1 + Should-Be $fixture.State.Snapshot.DisposeCount 1 + Should-Be $fixture.State.RegisterHandles.Count 2 + Should-Be $fixture.State.UnregisterHandles.Count 2 + Should-Be $fixture.State.RenderingAdds.Count 1 + Should-Be $fixture.State.RenderingRemoves.Count 1 + Should-BeFalse $observed.Context.RenderingAttached + } + + It 'coalesces raw pointer events to one render tick and refreshes the hovered HWND bounds' { + $fixture = New-SnipOverlayTestFixture + $fixture.State.CursorX = 100 + $observed = @{ Context=$null; Overlay=$null; FirstWidth=0; SecondWidth=0 } + $result = Show-SmartOverlay -Services $fixture.Services -TestAction { + param($kit) + $observed.Context = $kit.Context + $observed.Overlay = @($kit.Overlays | Where-Object { $_.Layout.Id -eq 'primary-100' })[0] + & $kit.QueuePointer $observed.Overlay + & $kit.QueuePointer $observed.Overlay + & $kit.QueuePointer $observed.Overlay + Should-Be $kit.Context.RenderCount 0 + & $kit.RenderTick + $observed.FirstWidth = $observed.Overlay.RenderedHover.PhysicalWidth + & $kit.RenderTick + Should-Be $kit.Context.RenderCount 1 + $fixture.State.HoverWidth = 650 + & $kit.QueuePointer $observed.Overlay + & $kit.RenderTick + $observed.SecondWidth = $observed.Overlay.RenderedHover.PhysicalWidth + & $kit.Cancel + }.GetNewClosure() + + Should-Be $result.Result 'UserCancelled' + Should-Be $fixture.State.RenderingAdds.Count 1 + Should-Be $observed.Context.RenderCount 2 + # The hovered window starts on the 125% monitor, so this overlay owns + # only the half-open primary-monitor intersection of each refreshed bound. + Should-Be $observed.FirstWidth 200 + Should-Be $observed.SecondWidth 450 + Should-Be $fixture.State.WindowAtPointCount 2 + Should-Be $fixture.State.WindowBoundsCount 2 + } + + It 'unions the live own-window registry with overlay HWNDs on every hover resolution' { + $fixture = New-SnipOverlayTestFixture + $fixture.State.CursorX = 100 + $fixture.State.CursorY = 100 + $fixture.State.ExternalHwnd = [IntPtr]4242 + $seenOwnSets = [System.Collections.ArrayList]::new() + $fixture.Services.GetWindowAtPoint = { + param($point,$ownHandles) + $ownValues = @($ownHandles | ForEach-Object { ([IntPtr]$_).ToInt64() }) + [void]$seenOwnSets.Add(@($ownValues)) + foreach ($candidate in @( + [IntPtr]$fixture.State.RegisterHandles[0], + [IntPtr]$fixture.State.RegistryTopHwnd, + [IntPtr]$fixture.State.ExternalHwnd + )) { + if ($candidate.ToInt64() -notin $ownValues) { return $candidate } + } + [IntPtr]::Zero + }.GetNewClosure() + $observed = @{ First=[IntPtr]::Zero; Second=[IntPtr]::Zero } + $result = Show-SmartOverlay -Services $fixture.Services -TestAction { + param($kit) + $primary = @($kit.Overlays | Where-Object { + $_.Layout.Id -eq 'primary-100' + })[0] + $fixture.State.OwnRegistryHandles = @( + [IntPtr]$fixture.State.RegisterHandles[0], + [IntPtr]777, + [IntPtr]777 + ) + $fixture.State.RegistryTopHwnd = [IntPtr]777 + & $kit.QueuePointer $primary + & $kit.RenderTick + $observed.First = $kit.Context.HoverHwnd + + # A scalar replacement proves the registry is read live rather + # than captured once, and remains one handle in the union. + $fixture.State.OwnRegistryHandles = [IntPtr]888 + $fixture.State.RegistryTopHwnd = [IntPtr]888 + $fixture.State.ExternalHwnd = [IntPtr]5252 + & $kit.QueuePointer $primary + & $kit.RenderTick + $observed.Second = $kit.Context.HoverHwnd + & $kit.Cancel + }.GetNewClosure() + + Should-Be $result.Result 'UserCancelled' + Should-Be $observed.First ([IntPtr]4242) + Should-Be $observed.Second ([IntPtr]5252) + Should-Be $fixture.State.OwnRegistryReads 2 + Should-Be $seenOwnSets.Count 2 + Should-Be @($seenOwnSets[0] | Sort-Object -Unique).Count 3 + Should-BeTrue ($seenOwnSets[0] -contains 777) + Should-BeFalse ($seenOwnSets[1] -contains 777) + Should-BeTrue ($seenOwnSets[1] -contains 888) + foreach ($registered in $fixture.State.RegisterHandles) { + Should-BeTrue ($seenOwnSets[0] -contains $registered) + Should-BeTrue ($seenOwnSets[1] -contains $registered) + } + } + + It 'commits the last refreshed hovered window on a physical click' { + $fixture = New-SnipOverlayTestFixture + $fixture.State.CursorX = 100 + $fixture.State.CursorY = 100 + $result = Show-SmartOverlay -Services $fixture.Services -TestAction { + param($kit) + $primary = @($kit.Overlays | Where-Object { + $_.Layout.Id -eq 'primary-100' + })[0] + & $kit.QueuePointer $primary + & $kit.RenderTick + & $kit.BeginDrag $primary + & $kit.CompleteDrag + }.GetNewClosure() + + Should-Be $result.Result 'Completed' + Should-Be $result.Selection.X -200 + Should-Be $result.Selection.Y 40 + Should-Be $result.Selection.Width 400 + Should-Be $result.Selection.Height 300 + } + + It 'flushes a queued hover change synchronously before a fast click commits' { + $fixture = New-SnipOverlayTestFixture + $fixture.State.CursorX = 100 + $fixture.State.CursorY = 100 + $target = @{ + Hwnd = [IntPtr]4100 + Bounds = [pscustomobject]@{ X=-200; Y=40; Width=400; Height=300 } + } + $fixture.Services.GetWindowAtPoint = { + param($point,$ownHandles) + $target.Hwnd + }.GetNewClosure() + $fixture.Services.GetWindowBounds = { + param($hwnd) + $target.Bounds + }.GetNewClosure() + $result = Show-SmartOverlay -Services $fixture.Services -TestAction { + param($kit) + $primary = @($kit.Overlays | Where-Object { + $_.Layout.Id -eq 'primary-100' + })[0] + & $kit.QueuePointer $primary + & $kit.RenderTick + + $target.Hwnd = [IntPtr]4200 + $target.Bounds = [pscustomobject]@{ + X=40; Y=60; Width=640; Height=360 + } + & $kit.QueuePointer $primary + # Mouse-down/up happens before CompositionTarget.Rendering. + & $kit.BeginDrag $primary + & $kit.CompleteDrag + }.GetNewClosure() + + Should-Be $result.Result 'Completed' + Should-Be $result.Selection.X 40 + Should-Be $result.Selection.Y 60 + Should-Be $result.Selection.Width 640 + Should-Be $result.Selection.Height 360 + } + + It 'shares one physical drag across windows and uses cursor position while captured' { + $fixture = New-SnipOverlayTestFixture + $observed = @{ Parts=$null; Selection=$null } + $result = Show-SmartOverlay -Services $fixture.Services -TestAction { + param($kit) + $left = @($kit.Overlays | Where-Object { $_.Layout.Id -eq 'left-125' })[0] + $primary = @($kit.Overlays | Where-Object { $_.Layout.Id -eq 'primary-100' })[0] + $fixture.State.CursorX = -500 + $fixture.State.CursorY = 100 + & $kit.BeginDrag $left + $fixture.State.CursorX = 500 + $fixture.State.CursorY = 600 + & $kit.QueuePointer $primary + & $kit.QueuePointer $primary + & $kit.RenderTick + $observed.Selection = $kit.Context.Selection + $observed.Parts = @($kit.Overlays | ForEach-Object RenderedSelection | + Where-Object { $null -ne $_ }) + & $kit.CompleteDrag + }.GetNewClosure() + + Should-Be $result.Result 'Completed' + Should-Be $result.Bitmap $null + Should-Be $result.Selection.X -500 + Should-Be $result.Selection.Y 100 + Should-Be $result.Selection.Width 1000 + Should-Be $result.Selection.Height 500 + Should-Be $observed.Selection.Width 1000 + Should-Be $observed.Parts.Count 2 + Should-Be (($observed.Parts | Measure-Object PhysicalWidth -Sum).Sum) 1000 + Should-BeTrue ($fixture.State.CursorCount -ge 3) + } + + It 'clamps the loupe inside every monitor at all four physical edges' { + $fixture = New-SnipOverlayTestFixture + $checks = [System.Collections.ArrayList]::new() + $result = Show-SmartOverlay -Services $fixture.Services -TestAction { + param($kit) + foreach ($overlay in $kit.Overlays) { + $layout = $overlay.Layout + foreach ($point in @( + @($layout.PhysicalX, $layout.PhysicalY), + @(($layout.PhysicalX + $layout.PhysicalWidth - 1), $layout.PhysicalY), + @($layout.PhysicalX, ($layout.PhysicalY + $layout.PhysicalHeight - 1)), + @(($layout.PhysicalX + $layout.PhysicalWidth - 1), + ($layout.PhysicalY + $layout.PhysicalHeight - 1)) + )) { + $fixture.State.CursorX = $point[0] + $fixture.State.CursorY = $point[1] + & $kit.QueuePointer $overlay + & $kit.RenderTick + $bounds = $overlay.LoupeBounds + [void]$checks.Add([pscustomobject]@{ + LeftOk = $bounds.X -ge $layout.PhysicalX + TopOk = $bounds.Y -ge $layout.PhysicalY + RightOk = ($bounds.X + $bounds.Width) -le + ($layout.PhysicalX + $layout.PhysicalWidth) + BottomOk = ($bounds.Y + $bounds.Height) -le + ($layout.PhysicalY + $layout.PhysicalHeight) + }) + } + } + & $kit.Cancel + }.GetNewClosure() + + Should-Be $result.Result 'UserCancelled' + Should-Be $checks.Count 8 + foreach ($check in $checks) { + Should-BeTrue $check.LeftOk + Should-BeTrue $check.TopOk + Should-BeTrue $check.RightOk + Should-BeTrue $check.BottomOk + } + } + + It 'moves the loupe away from the active physical selection when space exists' { + $fixture = New-SnipOverlayTestFixture + $observed = @{ Selection=$null; Loupe=$null } + $result = Show-SmartOverlay -Services $fixture.Services -TestAction { + param($kit) + $primary = @($kit.Overlays | Where-Object { + $_.Layout.Id -eq 'primary-100' + })[0] + $fixture.State.CursorX = 1000 + $fixture.State.CursorY = 800 + & $kit.BeginDrag $primary + $fixture.State.CursorX = 500 + $fixture.State.CursorY = 500 + & $kit.QueuePointer $primary + & $kit.RenderTick + $observed.Selection = $kit.Context.Selection + $observed.Loupe = $primary.LoupeBounds + & $kit.Cancel + }.GetNewClosure() + + $overlapWidth = [math]::Min( + $observed.Selection.X + $observed.Selection.Width, + $observed.Loupe.X + $observed.Loupe.Width) - [math]::Max( + $observed.Selection.X, $observed.Loupe.X) + $overlapHeight = [math]::Min( + $observed.Selection.Y + $observed.Selection.Height, + $observed.Loupe.Y + $observed.Loupe.Height) - [math]::Max( + $observed.Selection.Y, $observed.Loupe.Y) + Should-Be $result.Result 'UserCancelled' + Should-BeTrue ($overlapWidth -le 0 -or $overlapHeight -le 0) + } + + It 'uses shared Optical Bench resources with a forty percent dimmer and no cyan chrome' { + $fixture = New-SnipOverlayTestFixture + $observed = @{ Overlay=$null } + Show-SmartOverlay -Services $fixture.Services -TestAction { + param($kit) + $observed.Overlay = $kit.Overlays[0] + & $kit.Cancel + }.GetNewClosure() | Out-Null + + $overlay = $observed.Overlay + Should-Be $overlay.Dimmer.Fill.Color.A 102 + Should-BeTrue ([object]::ReferenceEquals( + $overlay.HoverRect.Stroke, $fixture.Services.Resources['SnipAccentBrush'])) + Should-BeTrue ([object]::ReferenceEquals( + $overlay.DragRect.Stroke, $fixture.Services.Resources['SnipMintBrush'])) + Should-BeTrue ($overlay.HintBorder.CornerRadius.TopLeft -gt 0) + Should-BeTrue ($overlay.LoupeBorder.CornerRadius.TopLeft -gt 0) + $body = (Get-Command New-SnipOverlayWindow).ScriptBlock.ToString() + Should-BeFalse ($body -match '(?i)#(?:00)?78D4|cyan') + } + + It 'uses system overlay fills and no shadow in High Contrast' { + $fixture = New-SnipOverlayTestFixture + $fixture.Services.Resources = New-SnipThemeResources ` + -HighContrast:$true -AnimationsEnabled:$false + $observed = @{ Overlay=$null } + Show-SmartOverlay -Services $fixture.Services -TestAction { + param($kit) + $observed.Overlay = $kit.Overlays[0] + & $kit.Cancel + }.GetNewClosure() | Out-Null + + Should-BeTrue ([object]::ReferenceEquals( + $observed.Overlay.Dimmer.Fill, + [System.Windows.SystemColors]::WindowBrush)) + Should-BeTrue ([object]::ReferenceEquals( + $observed.Overlay.DragRect.Fill, + [System.Windows.SystemColors]::HighlightBrush)) + Should-Be $observed.Overlay.HintBorder.Effect $null + Should-Be $observed.Overlay.LoupeBorder.Effect $null + } + + It 'detaches rendering closes windows and disposes the snapshot for every surface result' { + foreach ($surfaceResult in 'Completed','UserCancelled','Preempted','Failed','Shutdown') { + $fixture = New-SnipOverlayTestFixture + $observed = @{ Context=$null; Overlays=$null } + $result = Show-SmartOverlay -Services $fixture.Services -TestAction { + param($kit) + $observed.Context = $kit.Context + $observed.Overlays = @($kit.Overlays) + if ($surfaceResult -eq 'Completed') { + $kit.Context.Selection = [pscustomobject][ordered]@{ + X=-20; Y=10; Width=40; Height=30 + } + } + & $kit.Close $surfaceResult + }.GetNewClosure() + + Should-Be $result.Result $surfaceResult + Should-Be $fixture.State.RenderingAdds.Count 1 + Should-Be $fixture.State.RenderingRemoves.Count 1 + Should-Be $fixture.State.RegisterHandles.Count 2 + Should-Be $fixture.State.UnregisterHandles.Count 2 + Should-Be $fixture.State.Snapshot.DisposeCount 1 + Should-BeFalse $observed.Context.RenderingAttached + foreach ($overlay in $observed.Overlays) { + Should-BeFalse $overlay.Window.IsVisible + Should-BeFalse $overlay.EventsAttached + Should-BeFalse $overlay.Lifecycle.Connected + } + } + } + + It 'closes and detaches a readiness-declined overlay set before any HWND is shown' { + $fixture = New-SnipOverlayTestFixture + $observed = @{ Context=$null; Overlays=$null } + $result = Show-SmartOverlay -Services $fixture.Services -OnSurfaceReady { + param($surface) + $observed.Context = $surface.Context + $observed.Overlays = @($surface.Context.Overlays) + & $surface.Close 'Preempted' + $false + }.GetNewClosure() + + Should-Be $result.Result 'Preempted' + Should-Be $fixture.State.RenderingAdds.Count 0 + Should-Be $fixture.State.RenderingRemoves.Count 0 + Should-Be $fixture.State.RegisterHandles.Count 0 + Should-Be $fixture.State.UnregisterHandles.Count 0 + Should-Be $fixture.State.Snapshot.DisposeCount 1 + foreach ($overlay in $observed.Overlays) { + Should-BeTrue $overlay.Closed + Should-BeFalse $overlay.EventsAttached + Should-BeFalse $overlay.Lifecycle.Connected + } + } + + It 'detaches a partially attached render delegate when subscription reports failure' { + $fixture = New-SnipOverlayTestFixture + $fixture.Services.AddRenderingHandler = { + param($handler) + [void]$fixture.State.RenderingAdds.Add($handler) + throw 'render subscription failed after attach' + }.GetNewClosure() + $result = Show-SmartOverlay -Services $fixture.Services -TestAction { + throw 'test action must not run' + } + + Should-Be $result.Result 'Failed' + Should-Be $fixture.State.RenderingAdds.Count 1 + Should-Be $fixture.State.RenderingRemoves.Count 1 + Should-Be $fixture.State.Snapshot.DisposeCount 1 + } + + It 'fails and cleans the complete overlay set when physical HWND positioning fails' { + foreach ($failureMode in 'False','Throw') { + $fixture = New-SnipOverlayTestFixture + $fixture.State.TestActionRuns = 0 + $observed = @{ Context=$null; Overlays=$null } + $fixture.Services.PositionWindow = { + param($hwnd,$layout) + $fixture.State.PositionCalls++ + if ($failureMode -eq 'Throw') { throw 'native position exploded' } + $false + }.GetNewClosure() + $result = Show-SmartOverlay -Services $fixture.Services ` + -OnSurfaceReady { + param($surface) + $observed.Context = $surface.Context + $observed.Overlays = @($surface.Context.Overlays) + $true + }.GetNewClosure() ` + -TestAction { + param($kit) + $fixture.State.TestActionRuns++ + & $kit.Cancel + }.GetNewClosure() + + Should-Be $result.Result 'Failed' + Should-Be $fixture.State.PositionCalls 1 + Should-Be $fixture.State.TestActionRuns 0 + Should-BeTrue ($result.ErrorRecord.Exception.Message -match + 'SetWindowPos.*left-125') + Should-Be $fixture.State.RenderingAdds.Count 1 + Should-Be $fixture.State.RenderingRemoves.Count 1 + Should-Be $fixture.State.Snapshot.DisposeCount 1 + Should-BeGreaterThan $fixture.State.RegisterHandles.Count 0 + Should-Be $fixture.State.UnregisterHandles.Count ` + $fixture.State.RegisterHandles.Count + foreach ($overlay in $observed.Overlays) { + Should-BeTrue $overlay.Closed + Should-BeFalse $overlay.EventsAttached + Should-BeFalse $overlay.Lifecycle.Connected + } + } + } + + It 'lets the runtime coordinator create the Smart crop from returned geometry' { + $selection = [pscustomobject][ordered]@{ X=-500; Y=100; Width=1000; Height=500 } + $state = @{ Seen=$null; Hide=0; Restore=0; Crop=$null } + $overlay = { + param($ready,$coordinator,$request) + & $ready ([pscustomobject]@{ Close={ param($result) } }) | Out-Null + [pscustomobject][ordered]@{ + Result='Completed'; Selection=$selection; Bitmap=$null; ErrorRecord=$null + } + }.GetNewClosure() + $services = New-SnipRuntimeCaptureServices -SmartOverlay $overlay ` + -HideWindows { $state.Hide++; @() }.GetNewClosure() ` + -RestoreWindows { param($hidden) $state.Restore++ }.GetNewClosure() ` + -CaptureRectangle { + param($bounds) + $state.Seen = $bounds + $state.Crop = New-OwnershipProbe -Id smart-crop + $state.Crop + }.GetNewClosure() + $request = New-SnipCaptureRequest -Mode Smart -Source GeometryOnly + $coordinator = New-SnipCaptureCoordinator -Services $services + $coordinator.ActiveRequest = $request + $coordinator.Phase = 'CaptureStarting' + + $result = & $services.SmartCapture $coordinator $request + + Should-BeTrue ([object]::ReferenceEquals($result, $state.Crop)) + Should-Be $state.Seen.X -500 + Should-Be $state.Seen.Y 100 + Should-Be $state.Seen.Width 1000 + Should-Be $state.Seen.Height 500 + Should-Be $state.Hide 1 + Should-Be $state.Restore 1 + $state.Crop.Dispose() + } + + It 'rejects malformed or zero-area Smart geometry without creating a crop' { + foreach ($selection in @( + [pscustomobject]@{ X=0; Y=0; Width=0; Height=20 }, + [pscustomobject]@{ X=0; Y=0; Width=20 } + )) { + $state = @{ Crop=0 } + $overlay = { + param($ready,$coordinator,$request) + & $ready ([pscustomobject]@{ Close={ param($result) } }) | Out-Null + [pscustomobject]@{ Result='Completed'; Selection=$selection; Bitmap=$null } + }.GetNewClosure() + $services = New-SnipRuntimeCaptureServices -SmartOverlay $overlay ` + -CaptureRectangle { param($bounds) $state.Crop++; throw 'must not crop' }.GetNewClosure() + $request = New-SnipCaptureRequest -Mode Smart -Source InvalidGeometry + $coordinator = New-SnipCaptureCoordinator -Services $services + $coordinator.ActiveRequest = $request + $coordinator.Phase = 'CaptureStarting' + + $result = & $services.SmartCapture $coordinator $request + + Should-Be $result.Result 'UserCancelled' + Should-Be $state.Crop 0 + } + } +} + +Describe 'Preview ownership transfer' { + It 'transfers only after Preview accepts and leaves disposal to Preview' { + $capture = New-OwnershipProbe -Id accepted + $result = Invoke-SnipPreviewTransfer -Bitmap $capture -Preview { + param($bitmap,$accept) + $bitmap.Touch() + & $accept + $bitmap.Touch() + 'Completed' + } + + Should-BeTrue $result.OwnershipTransferred + Should-Be $result.Result 'Completed' + Should-Be $capture.DisposeCount 0 + Should-Be $capture.TouchCount 2 + } + + It 'disposes once when Preview fails before acceptance' { + $capture = New-OwnershipProbe -Id before-accept + $result = Invoke-SnipPreviewTransfer -Bitmap $capture -Preview { + param($bitmap,$accept) + $bitmap.Touch() + throw 'construction failed' + } + + Should-BeFalse $result.OwnershipTransferred + Should-Be $result.Result 'Failed' + Should-Be $capture.DisposeCount 1 + } + + It 'does not coordinator-dispose when Preview fails after acceptance' { + $capture = New-OwnershipProbe -Id after-accept + $result = Invoke-SnipPreviewTransfer -Bitmap $capture -Preview { + param($bitmap,$accept) + & $accept + try { + $bitmap.Touch() + throw 'opening failed' + } finally { + $bitmap.Dispose() + } + } + + Should-BeTrue $result.OwnershipTransferred + Should-Be $result.Result 'Failed' + Should-Be $capture.DisposeCount 1 + Should-Be $capture.TouchCount 1 + } + + It 'disposes every unaccepted taxonomy result exactly once' { + foreach ($surfaceResult in 'Completed','UserCancelled','Preempted','Failed','Shutdown') { + $capture = New-OwnershipProbe -Id $surfaceResult + $result = Invoke-SnipPreviewTransfer -Bitmap $capture -Preview { + param($bitmap,$accept) + $bitmap.Touch() + $surfaceResult + }.GetNewClosure() + Should-Be $result.Result $surfaceResult + Should-BeFalse $result.OwnershipTransferred + Should-Be $capture.DisposeCount 1 + } + } + + It 'survives 100 accepted real bitmaps and strict probes without reuse or double disposal' { + $realBitmaps = [System.Collections.ArrayList]::new() + $probes = [System.Collections.ArrayList]::new() + for ($iteration = 1; $iteration -le 100; $iteration++) { + $bitmap = [System.Drawing.Bitmap]::new(8, 8) + foreach ($prior in $realBitmaps) { + Should-BeFalse ([object]::ReferenceEquals($bitmap, $prior)) + } + [void]$realBitmaps.Add($bitmap) + $realResult = Invoke-SnipPreviewTransfer -Bitmap $bitmap -Preview { + param($owned,$accept) + & $accept + try { + $owned.SetPixel(0, 0, [System.Drawing.Color]::FromArgb(255, 1, 2, 3)) + $pixel = $owned.GetPixel(0, 0) + Should-Be $pixel.R 1 + } finally { + $owned.Dispose() + } + 'UserCancelled' + } + Should-BeTrue $realResult.OwnershipTransferred + Should-Be $realResult.Result 'UserCancelled' + + $probe = New-OwnershipProbe -Id "iteration-$iteration" + [void]$probes.Add($probe) + $probeResult = Invoke-SnipPreviewTransfer -Bitmap $probe -Preview { + param($owned,$accept) + & $accept + try { $owned.Touch() } finally { $owned.Dispose() } + 'Completed' + } + Should-BeTrue $probeResult.OwnershipTransferred + Should-Be $probe.DisposeCount 1 + } + Should-Be $realBitmaps.Count 100 + Should-Be $probes.Count 100 + Should-Be (@($probes | Where-Object DisposeCount -ne 1).Count) 0 + } + + It 'calls ownership acceptance only after Preview installs Closed cleanup' { + $bitmap = [System.Drawing.Bitmap]::new(16, 16) + $acceptance = @{ Accepted = $false } + $result = Show-PreviewWindow -Bitmap $bitmap -OnOwnershipAccepted { + param($ownershipState) + Should-BeTrue $ownershipState.CleanupInstalled + $acceptance.Accepted = $true + }.GetNewClosure() -TestAction { + param($kit) + Should-BeTrue $acceptance.Accepted + }.GetNewClosure() + + Should-BeTrue $acceptance.Accepted + Should-Be $result 'UserCancelled' + } +} + +Describe 'Injected coordinator surface integration' { + It 'relinquishes coordinator ownership before Stop can race accepted Preview cleanup' { + $state = @{ + Capture = $null + TouchAfterStop = $false + Error = $null + OwnedAfterAccept = $null + } + $services = [pscustomobject]@{ + FullCapture = { + param($coordinator,$request) + $state.Capture = New-OwnershipProbe -Id shutdown-race + $state.Capture + }.GetNewClosure() + Preview = { + param($bitmap,$accept,$coordinator,$request) + & $accept + $state.OwnedAfterAccept = $coordinator.OwnedBitmap + Stop-SnipCaptureCoordinator -Coordinator $coordinator + try { + $bitmap.Touch() + $state.TouchAfterStop = $true + } catch { + $state.Error = $_ + } finally { + if (-not $bitmap.Disposed) { $bitmap.Dispose() } + } + 'Shutdown' + }.GetNewClosure() + } + $coordinator = New-SnipCaptureCoordinator -Services $services -Post { param($work) & $work } + + Request-SnipCapture -Coordinator $coordinator -Mode Full -Source ShutdownRace | Out-Null + + if (-not $state.TouchAfterStop) { + throw "Touch after Stop failed; ownedAfterAcceptNull=$($null -eq $state.OwnedAfterAccept); dispose=$($state.Capture.DisposeCount); error=$($state.Error.Exception.Message); phase=$($coordinator.Phase); ownedNull=$($null -eq $coordinator.OwnedBitmap)" + } + Should-Be $state.Error $null + Should-Be $state.OwnedAfterAccept $null + Should-Be $state.Capture.DisposeCount 1 + Should-Be $coordinator.OwnedBitmap $null + Should-Be $coordinator.Phase 'ShuttingDown' + } + + It 'runs 100 captures through coordinator acceptance and real Preview Closed cleanup' { + $state = @{ + Captures = [System.Collections.ArrayList]::new() + PreviewCount = 0 + } + $services = [pscustomobject]@{ + FullCapture = { + param($coordinator,$request) + $capture = [System.Drawing.Bitmap]::new(8, 8) + foreach ($prior in $state.Captures) { + Should-BeFalse ([object]::ReferenceEquals($capture, $prior)) + } + [void]$state.Captures.Add($capture) + $capture + }.GetNewClosure() + Preview = { + param($bitmap,$accept,$coordinator,$request) + $previewContext = [pscustomobject]@{ + Bitmap = $bitmap + State = $state + } + $testAction = { + param($kit) + $previewContext.Bitmap.SetPixel( + 0, 0, [System.Drawing.Color]::FromArgb(255, 7, 8, 9)) + $pixel = $previewContext.Bitmap.GetPixel(0, 0) + Should-Be $pixel.R 7 + $previewContext.State.PreviewCount++ + }.GetNewClosure() + Show-PreviewWindow -Bitmap $bitmap -OnOwnershipAccepted $accept ` + -TestAction $testAction + }.GetNewClosure() + } + $coordinator = New-SnipCaptureCoordinator -Services $services -Post { param($work) & $work } + + for ($iteration = 1; $iteration -le 100; $iteration++) { + Request-SnipCapture -Coordinator $coordinator -Mode Full ` + -Source "coordinator-$iteration" | Out-Null + } + + Should-Be $state.Captures.Count 100 + Should-Be $state.PreviewCount 100 + foreach ($capture in $state.Captures) { + $disposed = $false + try { $capture.GetPixel(0, 0) | Out-Null } catch { $disposed = $true } + Should-BeTrue $disposed + } + Should-Be $coordinator.OwnedBitmap $null + Should-Be $coordinator.MaxPumpDepth 1 + Should-Be $coordinator.Phase 'Idle' + } + + It 'keeps CaptureStarting until the Smart service publishes a closable surface' { + $observed = @{ CaptureStarting = $false; Installed = $false } + $services = [pscustomobject]@{ + SmartCapture = { + param($coordinator,$request) + $observed.CaptureStarting = ($coordinator.Phase -eq 'CaptureStarting') + $coordinator.ActiveSurface = [pscustomobject]@{ Close = { param($result) } } + $coordinator.Phase = 'Selecting' + $observed.Installed = ($null -ne $coordinator.ActiveSurface) + [pscustomobject]@{ Result = 'UserCancelled'; Bitmap = $null } + }.GetNewClosure() + } + $coordinator = New-SnipCaptureCoordinator -Services $services -Post { param($work) & $work } + + Request-SnipCapture -Coordinator $coordinator -Mode Smart -Source Hotkey | Out-Null + Should-BeTrue $observed.CaptureStarting + Should-BeTrue $observed.Installed + Should-Be $coordinator.Phase 'Idle' + } + + It 'publishes the normal Smart surface through the production readiness callback' { + $state = @{ + PhaseBefore = $null + PhaseAfter = $null + Accepted = $null + Installed = $false + Surface = [pscustomobject]@{ + Id = [guid]::NewGuid() + Close = { param($result) } + } + } + $overlay = { + param($ready,$coordinator,$request) + $state.PhaseBefore = $coordinator.Phase + $state.Accepted = & $ready $state.Surface + $state.Installed = ($coordinator.ActiveSurface.Id -eq $state.Surface.Id) + $state.PhaseAfter = $coordinator.Phase + [pscustomobject]@{ Result = 'UserCancelled'; Bitmap = $null } + }.GetNewClosure() + $services = New-SnipRuntimeCaptureServices -SmartOverlay $overlay + $coordinator = New-SnipCaptureCoordinator -Services $services ` + -Post { param($work) & $work } + + Request-SnipCapture -Coordinator $coordinator -Mode Smart -Source NormalReady | Out-Null + + Should-Be $state.PhaseBefore 'CaptureStarting' + Should-BeTrue $state.Accepted + Should-BeTrue $state.Installed + Should-Be $state.PhaseAfter 'Selecting' + Should-Be $coordinator.Phase 'Idle' + } + + It 'preempts Smart overlays reentrantly at hide, snapshot, and readiness boundaries' { + Should-BeTrue (Get-Command New-SnipRuntimeCaptureServices).Parameters.ContainsKey('SmartOverlay') + + foreach ($boundary in 'Hide','Snapshot','Readiness') { + $state = @{ + Boundary = $boundary + ObservedPhases = [System.Collections.ArrayList]::new() + PhaseAfterReadiness = $null + OverlayResult = $null + Snapshot = New-OwnershipProbe -Id "preempt-$boundary" + FullCount = 0 + PreviewCount = 0 + } + $overlay = { + param($ready,$coordinator,$request) + $context = [pscustomobject]@{ + Ready = $ready + Coordinator = $coordinator + State = $state + } + $hide = { + if ($context.State.Boundary -eq 'Hide') { + [void]$context.State.ObservedPhases.Add($context.Coordinator.Phase) + Request-SnipCapture -Coordinator $context.Coordinator ` + -Mode Full -Source "pending-$($context.State.Boundary)" | Out-Null + } + @() + }.GetNewClosure() + $capture = { + param($bounds) + if ($context.State.Boundary -eq 'Snapshot') { + [void]$context.State.ObservedPhases.Add($context.Coordinator.Phase) + Request-SnipCapture -Coordinator $context.Coordinator ` + -Mode Full -Source "pending-$($context.State.Boundary)" | Out-Null + } + $context.State.Snapshot + }.GetNewClosure() + $readiness = { + param($surface) + if ($context.State.Boundary -eq 'Readiness') { + [void]$context.State.ObservedPhases.Add($context.Coordinator.Phase) + Request-SnipCapture -Coordinator $context.Coordinator ` + -Mode Full -Source "pending-$($context.State.Boundary)" | Out-Null + } + $accepted = & ([scriptblock]$context.Ready) $surface + $context.State.PhaseAfterReadiness = $context.Coordinator.Phase + $accepted + }.GetNewClosure() + $overlayResult = Show-SmartOverlay -OnSurfaceReady $readiness ` + -HideWindows $hide -RestoreWindows { param($hidden) } ` + -CaptureFactory $capture -BitmapSourceFactory { param($bitmap) $null } + $context.State.OverlayResult = $overlayResult.Result + $overlayResult + }.GetNewClosure() + $services = New-SnipRuntimeCaptureServices -SmartOverlay $overlay + $services.FullCapture = { + param($coordinator,$request) + $state.FullCount++ + New-OwnershipProbe -Id "full-$boundary" + }.GetNewClosure() + $services.Preview = { + param($bitmap,$accept,$coordinator,$request) + & $accept + try { $state.PreviewCount++ } finally { $bitmap.Dispose() } + 'UserCancelled' + }.GetNewClosure() + $coordinator = New-SnipCaptureCoordinator -Services $services ` + -Post { param($work) & $work } + + Request-SnipCapture -Coordinator $coordinator -Mode Smart ` + -Source "smart-$boundary" | Out-Null + + Should-Be @($state.ObservedPhases).Count 1 + Should-Be @($state.ObservedPhases)[0] 'CaptureStarting' + Should-Be $state.PhaseAfterReadiness 'CaptureStarting' + Should-Be $state.OverlayResult 'Preempted' + Should-Be $state.Snapshot.DisposeCount 1 + Should-Be $state.FullCount 1 + Should-Be $state.PreviewCount 1 + Should-Be $coordinator.Phase 'Idle' + } + } + + It 'shuts down Smart overlays reentrantly at hide, snapshot, and readiness boundaries' { + Should-BeTrue (Get-Command New-SnipRuntimeCaptureServices).Parameters.ContainsKey('SmartOverlay') + + foreach ($boundary in 'Hide','Snapshot','Readiness') { + $state = @{ + Boundary = $boundary + ObservedPhases = [System.Collections.ArrayList]::new() + OverlayResult = $null + Snapshot = New-OwnershipProbe -Id "shutdown-$boundary" + } + $overlay = { + param($ready,$coordinator,$request) + $context = [pscustomobject]@{ + Ready = $ready + Coordinator = $coordinator + State = $state + } + $stop = { + [void]$context.State.ObservedPhases.Add($context.Coordinator.Phase) + Stop-SnipCaptureCoordinator -Coordinator $context.Coordinator + }.GetNewClosure() + $hide = { + if ($context.State.Boundary -eq 'Hide') { & $stop } + @() + }.GetNewClosure() + $capture = { + param($bounds) + if ($context.State.Boundary -eq 'Snapshot') { & $stop } + $context.State.Snapshot + }.GetNewClosure() + $readiness = { + param($surface) + if ($context.State.Boundary -eq 'Readiness') { & $stop } + & ([scriptblock]$context.Ready) $surface + }.GetNewClosure() + $overlayResult = Show-SmartOverlay -OnSurfaceReady $readiness ` + -HideWindows $hide -RestoreWindows { param($hidden) } ` + -CaptureFactory $capture -BitmapSourceFactory { param($bitmap) $null } + $context.State.OverlayResult = $overlayResult.Result + $overlayResult + }.GetNewClosure() + $services = New-SnipRuntimeCaptureServices -SmartOverlay $overlay + $coordinator = New-SnipCaptureCoordinator -Services $services ` + -Post { param($work) & $work } + + Request-SnipCapture -Coordinator $coordinator -Mode Smart ` + -Source "smart-$boundary" | Out-Null + + Should-Be @($state.ObservedPhases).Count 1 + Should-Be @($state.ObservedPhases)[0] 'CaptureStarting' + Should-Be $state.OverlayResult 'Shutdown' + Should-Be $state.Snapshot.DisposeCount 1 + Should-BeTrue $coordinator.ShutdownRequested + Should-Be $coordinator.Phase 'ShuttingDown' + Should-Be $coordinator.ActiveSurface $null + } + } + + It 'maps every Preview surface result deterministically' { + foreach ($surfaceResult in 'Completed','UserCancelled','Preempted','Failed','Shutdown') { + $state = @{ Capture = $null } + $services = [pscustomobject]@{ + FullCapture = { + param($coordinator,$request) + $state.Capture = New-OwnershipProbe -Id $surfaceResult + $state.Capture + }.GetNewClosure() + Preview = { + param($bitmap,$accept,$coordinator,$request) + & $accept + try { $bitmap.Touch() } finally { $bitmap.Dispose() } + $surfaceResult + }.GetNewClosure() + } + $coordinator = New-SnipCaptureCoordinator -Services $services -Post { param($work) & $work } + Request-SnipCapture -Coordinator $coordinator -Mode Full -Source Tray | Out-Null + + Should-Be $coordinator.LastResult $surfaceResult + Should-Be $state.Capture.DisposeCount 1 + if ($surfaceResult -eq 'Shutdown') { + Should-Be $coordinator.Phase 'ShuttingDown' + } else { + Should-Be $coordinator.Phase 'Idle' + } + } + } +} + +Describe 'Runtime Window capture services' { + It 'resolves fresh HWND bounds for every Window transaction' { + $parameters = (Get-Command New-SnipRuntimeCaptureServices).Parameters + foreach ($name in 'GetVirtualBounds','ResolveWindow','HideWindows','RestoreWindows','CaptureRectangle') { + Should-BeTrue $parameters.ContainsKey($name) + } + $state = @{ + ResolveCount = 0 + HideCount = 0 + RestoreCount = 0 + Seen = [System.Collections.ArrayList]::new() + Descriptors = @( + [pscustomobject]@{ Hwnd=[IntPtr]11; X=-500; Y=20; Width=300; Height=200 }, + [pscustomobject]@{ Hwnd=[IntPtr]22; X=100; Y=-40; Width=800; Height=600 } + ) + } + $services = New-SnipRuntimeCaptureServices ` + -GetVirtualBounds { throw 'full fallback must not run' } ` + -ResolveWindow { + $descriptor = $state.Descriptors[$state.ResolveCount] + $state.ResolveCount++ + $descriptor + }.GetNewClosure() ` + -HideWindows { + $state.HideCount++ + [pscustomobject]@{ Token = $state.HideCount } + }.GetNewClosure() ` + -RestoreWindows { + param($hidden) + $state.RestoreCount++ + }.GetNewClosure() ` + -CaptureRectangle { + param($bounds) + [void]$state.Seen.Add( + "$($bounds.Hwnd):$($bounds.X),$($bounds.Y),$($bounds.Width),$($bounds.Height)") + New-OwnershipProbe -Id "window-$($bounds.Hwnd)" + }.GetNewClosure() + + $first = & $services.WindowCapture $null ` + (New-SnipCaptureRequest -Mode Window -Source First) + $second = & $services.WindowCapture $null ` + (New-SnipCaptureRequest -Mode Window -Source Second) + + Should-Be $state.ResolveCount 2 + Should-Be ($state.Seen -join ';') '11:-500,20,300,200;22:100,-40,800,600' + Should-Be $state.HideCount 2 + Should-Be $state.RestoreCount 2 + $first.Dispose() + $second.Dispose() + } + + It 'falls back to fresh virtual bounds for null, degenerate, and malformed Window targets' { + $parameters = (Get-Command New-SnipRuntimeCaptureServices).Parameters + foreach ($name in 'GetVirtualBounds','ResolveWindow','HideWindows','RestoreWindows','CaptureRectangle') { + Should-BeTrue $parameters.ContainsKey($name) + } + $state = @{ + ResolveCount = 0 + VirtualCount = 0 + Seen = [System.Collections.ArrayList]::new() + } + $services = New-SnipRuntimeCaptureServices ` + -ResolveWindow { + $state.ResolveCount++ + if ($state.ResolveCount -eq 1) { return $null } + if ($state.ResolveCount -eq 2) { + return [pscustomobject]@{ Hwnd=[IntPtr]33; X=1; Y=2; Width=0; Height=20 } + } + [pscustomobject]@{ Hwnd=[IntPtr]44; X='invalid'; Y=2; Width=20; Height=20 } + }.GetNewClosure() ` + -GetVirtualBounds { + $state.VirtualCount++ + [pscustomobject]@{ + X = -1920 + $state.VirtualCount + Y = -100 + Width = 3840 + Height = 1200 + } + }.GetNewClosure() ` + -HideWindows { @() } ` + -RestoreWindows { param($hidden) } ` + -CaptureRectangle { + param($bounds) + [void]$state.Seen.Add( + "$($bounds.X),$($bounds.Y),$($bounds.Width),$($bounds.Height)") + New-OwnershipProbe -Id "fallback-$($state.VirtualCount)" + }.GetNewClosure() + + $first = & $services.WindowCapture $null ` + (New-SnipCaptureRequest -Mode Window -Source NullTarget) + $second = & $services.WindowCapture $null ` + (New-SnipCaptureRequest -Mode Window -Source InvalidTarget) + $third = & $services.WindowCapture $null ` + (New-SnipCaptureRequest -Mode Window -Source MalformedTarget) + + Should-Be $state.ResolveCount 3 + Should-Be $state.VirtualCount 3 + Should-Be ($state.Seen -join ';') '-1919,-100,3840,1200;-1918,-100,3840,1200;-1917,-100,3840,1200' + $first.Dispose() + $second.Dispose() + $third.Dispose() + } +} + +Describe 'Shared Optical Bench theme resources' { + It 'builds every locked island resource from the approved tokens' { + $resources = New-SnipThemeResources -HighContrast:$false -AnimationsEnabled:$true + + Should-Be $resources['SnipIslandRadius'] 16 + Should-Be $resources['SnipControlRadius'] 10 + Should-Be $resources['SnipCornerHeight'] 42 + Should-Be $resources['SnipHairlineThickness'] 1 + Should-Be $resources['SnipAccentBrush'].Color.ToString() '#FFD8C8A5' + Should-Be $resources['SnipGlassScrimBrush'].Color.ToString() '#CC000000' + Should-Be $resources['SnipGlassGradientBrush'].GradientStops[0].Color.ToString() '#260E1012' + Should-Be $resources['SnipGlassGradientBrush'].GradientStops[1].Color.ToString() '#14050608' + } + + It 'publishes the exact layered glass contract with restrained depth' { + $resources = New-SnipThemeResources -HighContrast:$false -AnimationsEnabled:$true + + Should-Be ($resources['SnipGlassLayers'] -join ',') 'ContrastScrim,GlassGradient,Hairline,InnerHighlight,Shadow' + Should-BeTrue ($resources['SnipIslandShadow'] -is [System.Windows.Media.Effects.DropShadowEffect]) + Should-Be $resources['SnipIslandShadow'].BlurRadius 18 + Should-Be $resources['SnipIslandShadow'].ShadowDepth 5 + Should-Be $resources['SnipDisplayFont'].Source 'Segoe UI Variable Display' + Should-Be $resources['SnipTextFont'].Source 'Segoe UI Variable Text' + Should-Be $resources['SnipCodeFont'].Source 'Cascadia Code' + } + + It 'switches high contrast resources to system colors and removes shadows' { + $resources = New-SnipThemeResources -HighContrast:$true -AnimationsEnabled:$true + + Should-BeTrue $resources['SnipHighContrast'] + Should-Be $resources['SnipPageBrush'].Color.ToString() ([System.Windows.SystemColors]::WindowColor.ToString()) + Should-Be $resources['SnipPrimaryTextBrush'].Color.ToString() ([System.Windows.SystemColors]::WindowTextColor.ToString()) + Should-Be $resources['SnipAccentBrush'].Color.ToString() ([System.Windows.SystemColors]::HighlightColor.ToString()) + Should-BeFalse $resources.Contains('SnipIslandShadow') + Should-Be ($resources['SnipGlassLayers'] -join ',') 'SystemBackground,SystemBorder' + } + + It 'renders a distinct system-color focus ring on High Contrast primary controls' { + $resources = New-SnipThemeResources -HighContrast:$true -AnimationsEnabled:$false + Should-BeFalse ($resources['SnipFocusBrush'].Color.ToString() -eq + $resources['SnipAccentBrush'].Color.ToString()) + + $window = [System.Windows.Window]::new() + $window.ShowInTaskbar = $false + $window.WindowStartupLocation = 'Manual' + $window.Left = -10000 + $window.Top = -10000 + $button = [System.Windows.Controls.Button]::new() + $button.Content = 'Focus probe' + Add-SnipThemeResources -Root $window -Resources $resources | Out-Null + $button.Style = $resources['SnipPrimaryButtonStyle'] + $window.Content = $button + try { + $window.Show() + $window.Activate() | Out-Null + [System.Windows.Input.Keyboard]::Focus($button) | Out-Null + $button.UpdateLayout() + $focusRing = $button.Template.FindName('FocusRing', $button) + Should-Be $focusRing.Visibility ([System.Windows.Visibility]::Visible) + Should-Be $focusRing.BorderBrush.Color.ToString() $resources['SnipFocusBrush'].Color.ToString() + Should-BeFalse ($focusRing.BorderBrush.Color.ToString() -eq + $button.Background.Color.ToString()) + } finally { + $window.Close() + } + } + + It 'uses immediate state changes when client animations are disabled' { + $reduced = New-SnipThemeResources -HighContrast:$false -AnimationsEnabled:$false + $animated = New-SnipThemeResources -HighContrast:$false -AnimationsEnabled:$true + + Should-BeFalse $reduced['SnipAnimationsEnabled'] + Should-Be $reduced['SnipMotionDuration'].TotalMilliseconds 0 + Should-BeTrue $animated['SnipAnimationsEnabled'] + Should-Be $animated['SnipMotionDuration'].TotalMilliseconds 140 + } + + It 'merges one shared dictionary into a WPF root' { + $root = [System.Windows.Controls.Border]::new() + $resources = New-SnipThemeResources -HighContrast:$false -AnimationsEnabled:$false + + $result = Add-SnipThemeResources -Root $root -Resources $resources + + Should-Be $result $root + Should-Be $root.Resources.MergedDictionaries.Count 1 + Should-Be $root.Resources['SnipIslandRadius'] 16 + } + + It 'is idempotent on one root and shares the same dictionary across roots' { + $firstRoot = [System.Windows.Controls.Border]::new() + $secondRoot = [System.Windows.Controls.Grid]::new() + $resources = New-SnipThemeResources -HighContrast:$false -AnimationsEnabled:$false + + Add-SnipThemeResources -Root $firstRoot -Resources $resources | Out-Null + Add-SnipThemeResources -Root $firstRoot -Resources $resources | Out-Null + Add-SnipThemeResources -Root $secondRoot -Resources $resources | Out-Null + + Should-Be $firstRoot.Resources.MergedDictionaries.Count 1 + Should-Be $secondRoot.Resources.MergedDictionaries.Count 1 + Should-BeTrue ([object]::ReferenceEquals( + $firstRoot.Resources.MergedDictionaries[0], $resources)) + Should-BeTrue ([object]::ReferenceEquals( + $secondRoot.Resources.MergedDictionaries[0], $resources)) + Should-Be $secondRoot.Resources['SnipControlRadius'] 10 + } +} + +Describe 'Utility window capture-exclusion lifecycle' { + It 'registers a real HWND on SourceInitialized and unregisters it on Closed' { + $registered = [System.Collections.ArrayList]::new() + $unregistered = [System.Collections.ArrayList]::new() + $window = [System.Windows.Window]::new() + $window.ShowActivated = $false + $window.ShowInTaskbar = $false + $window.WindowStartupLocation = 'Manual' + $window.Left = -10000 + $window.Top = -10000 + + $lifecycle = Connect-SnipWindowLifecycle -Window $window ` + -Register { param($hwnd) [void]$registered.Add($hwnd) }.GetNewClosure() ` + -Unregister { param($hwnd) [void]$unregistered.Add($hwnd) }.GetNewClosure() + $window.Show() + $window.Close() + + Should-Be $registered.Count 1 + Should-BeTrue ($registered[0] -ne [IntPtr]::Zero) + Should-Be $unregistered.Count 1 + Should-Be $unregistered[0] $registered[0] + Should-BeFalse $lifecycle.Connected + } + + It 'registers and unregisters the real Settings HWND exactly once' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-settings-lifecycle-' + [guid]::NewGuid()) + $registered = [System.Collections.ArrayList]::new() + $unregistered = [System.Collections.ArrayList]::new() + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings -PicturesDir $root + SettingsPath = (Join-Path $root 'settings.json') + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + Hwnd = [IntPtr]123 + RegisterHotkey = { param($hwnd,$id,$mods,$vk) $true } + UnregisterHotkey = { param($hwnd,$id) $true } + RegisterWindow = { param($hwnd) [void]$registered.Add($hwnd) }.GetNewClosure() + UnregisterWindow = { param($hwnd) [void]$unregistered.Add($hwnd) }.GetNewClosure() + } + + Show-SettingsWindow -Context $context -TestAction { + param($kit) + & $kit.Close 'UserCancelled' + } | Out-Null + + Should-Be $registered.Count 1 + Should-BeTrue ($registered[0] -ne [IntPtr]::Zero) + Should-Be $unregistered.Count 1 + Should-Be $unregistered[0] $registered[0] + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'registers and unregisters the real About HWND exactly once' { + $registered = [System.Collections.ArrayList]::new() + $unregistered = [System.Collections.ArrayList]::new() + $context = [pscustomobject]@{ + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + RegisterWindow = { param($hwnd) [void]$registered.Add($hwnd) }.GetNewClosure() + UnregisterWindow = { param($hwnd) [void]$unregistered.Add($hwnd) }.GetNewClosure() + } + + Show-AboutWindow -Context $context -TestAction { + param($kit) + & $kit.Close 'UserCancelled' + } | Out-Null + + Should-Be $registered.Count 1 + Should-BeTrue ($registered[0] -ne [IntPtr]::Zero) + Should-Be $unregistered.Count 1 + Should-Be $unregistered[0] $registered[0] + } + + It 'closes the real widget lifecycle without residual window state' { + $registered = [System.Collections.ArrayList]::new() + $unregistered = [System.Collections.ArrayList]::new() + $settings = Get-SnipDefaultSettings + $settings.WidgetVisible = $true + $observed = @{ State = $null; Lifecycle = $null } + $context = [pscustomobject]@{ + Settings = $settings + SubmitRequest = { param($mode,$delay,$source) } + OpenSettings = { } + AnimationsEnabled = $false + RegisterWindow = { param($hwnd) [void]$registered.Add($hwnd) }.GetNewClosure() + UnregisterWindow = { param($hwnd) [void]$unregistered.Add($hwnd) }.GetNewClosure() + } + + Show-FloatingWidget -Context $context -TestAction { + param($kit) + $observed.State = $kit.State + $observed.Lifecycle = $kit.Lifecycle + $kit.Window.Close() + }.GetNewClosure() | Out-Null + + Should-Be $registered.Count 1 + Should-BeTrue ($registered[0] -ne [IntPtr]::Zero) + Should-Be $unregistered.Count 1 + Should-Be $unregistered[0] $registered[0] + Should-BeTrue $observed.State.Closed + Should-BeFalse $observed.Lifecycle.Connected + Should-Be $script:WidgetWindow $null + } +} + +Describe 'Auxiliary surface coordinator identity' { + It 'rejects a racing utility window without completing an unrelated active surface' { + $closedAs = [System.Collections.ArrayList]::new() + $unrelated = [pscustomobject]@{ Name = 'Preview'; Close = { param($result) } } + $candidate = [pscustomobject]@{ + Name = 'Settings' + Close = { param($result) [void]$closedAs.Add($result) }.GetNewClosure() + } + $coordinator = New-SnipCaptureCoordinator + $coordinator.Phase = 'Previewing' + $coordinator.ActiveSurface = $unrelated + + Should-BeFalse (Set-SnipAuxiliarySurface -Coordinator $coordinator -Surface $candidate) + Should-Be ($closedAs -join ',') 'Preempted' + Should-BeFalse (Complete-SnipAuxiliarySurface -Coordinator $coordinator ` + -Surface $candidate -Result Preempted) + Should-Be $coordinator.Phase 'Previewing' + Should-BeTrue ([object]::ReferenceEquals($coordinator.ActiveSurface, $unrelated)) + } +} + +Describe 'Settings utility surface' { + It 'records one allowed key chord and displays its canonical active shortcut' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-settings-ui-' + [guid]::NewGuid()) + $settingsPath = Join-Path $root 'settings.json' + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings -PicturesDir $root + SettingsPath = $settingsPath + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + Hwnd = [IntPtr]123 + RegisterHotkey = { param($hwnd,$id,$mods,$vk) $true } + UnregisterHotkey = { param($hwnd,$id) $true } + } + + $null = Show-SettingsWindow -Context $context -TestAction { + param($kit) + $result = & $kit.RecordShortcut 0x4007 0x52 + Should-BeTrue $result.Success + Should-Be $kit.ShortcutText.Text 'Ctrl+Alt+Shift+R' + Should-Be $kit.ErrorText.Text '' + Should-BeFalse ([string]::IsNullOrWhiteSpace([string]$kit.ShortcutRecorder.ToolTip)) + Should-BeFalse ([string]::IsNullOrWhiteSpace([string]$kit.Window.FindName('SaveBtn').ToolTip)) + } + + Should-Be $context.RegisteredHotkey.VirtualKey 0x52 + Should-Be (Read-SnipSettings -Path $settingsPath -PicturesDir $root).Hotkey.VirtualKey 0x52 + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'ignores modifier-only input resolves Alt system keys and owns Escape while recording' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-settings-keyboard-' + [guid]::NewGuid()) + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings -PicturesDir $root + SettingsPath = (Join-Path $root 'settings.json') + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + Hwnd = [IntPtr]123 + RegisterHotkey = { param($hwnd,$id,$mods,$vk) $true } + UnregisterHotkey = { param($hwnd,$id) $true } + } + $allModifiers = [System.Windows.Input.ModifierKeys]::Control -bor + [System.Windows.Input.ModifierKeys]::Alt -bor + [System.Windows.Input.ModifierKeys]::Shift + $windowsModifiers = [System.Windows.Input.ModifierKeys]::Control -bor + [System.Windows.Input.ModifierKeys]::Alt -bor + [System.Windows.Input.ModifierKeys]::Windows + + $null = Show-SettingsWindow -Context $context -TestAction { + param($kit) + $tab = & $kit.RecordKey ([System.Windows.Input.Key]::Tab) ` + ([System.Windows.Input.Key]::None) ([System.Windows.Input.ModifierKeys]::None) + Should-Be $null $tab + Should-BeFalse $kit.RecorderState.LastHandled + Should-Be $kit.ShortcutText.Text 'Ctrl+Alt+Shift+Q' + + $shiftTab = & $kit.RecordKey ([System.Windows.Input.Key]::Tab) ` + ([System.Windows.Input.Key]::None) ([System.Windows.Input.ModifierKeys]::Shift) + Should-Be $null $shiftTab + Should-BeFalse $kit.RecorderState.LastHandled + + $modifierOnly = & $kit.RecordKey ([System.Windows.Input.Key]::LeftCtrl) ` + ([System.Windows.Input.Key]::None) ([System.Windows.Input.ModifierKeys]::Control) + Should-Be $null $modifierOnly + Should-Be $kit.ShortcutText.Text 'Ctrl+Alt+Shift+Q' + Should-BeTrue $kit.RecorderState.LastHandled + + $escape = & $kit.RecordKey ([System.Windows.Input.Key]::Escape) ` + ([System.Windows.Input.Key]::None) ([System.Windows.Input.ModifierKeys]::None) + Should-BeFalse $escape.Success + Should-BeTrue $kit.Window.IsVisible + Should-BeTrue $kit.RecorderState.LastHandled + + $windowsResult = & $kit.RecordKey ([System.Windows.Input.Key]::R) ` + ([System.Windows.Input.Key]::None) $windowsModifiers + Should-BeFalse $windowsResult.Success + Should-Be $kit.ShortcutText.Text 'Ctrl+Alt+Shift+Q' + Should-BeTrue $kit.ErrorText.Text.Contains('not supported') + + $result = & $kit.RecordKey ([System.Windows.Input.Key]::System) ` + ([System.Windows.Input.Key]::R) $allModifiers + Should-BeTrue $result.Success + Should-Be $kit.ShortcutText.Text 'Ctrl+Alt+Shift+R' + Should-BeTrue $kit.RecorderState.LastHandled + } + + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'keeps the previous shortcut active and presents the candidate error inline' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-settings-error-' + [guid]::NewGuid()) + $calls = [System.Collections.ArrayList]::new() + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings -PicturesDir $root + SettingsPath = (Join-Path $root 'settings.json') + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + Hwnd = [IntPtr]123 + RegisterHotkey = { + param($hwnd,$id,$mods,$vk) + [void]$calls.Add($vk) + return $vk -eq 0x51 + }.GetNewClosure() + UnregisterHotkey = { param($hwnd,$id) $true } + } + + $null = Show-SettingsWindow -Context $context -TestAction { + param($kit) + $result = & $kit.RecordShortcut 0x4007 0x52 + Should-BeFalse $result.Success + Should-Be $kit.ShortcutText.Text 'Ctrl+Alt+Shift+Q' + Should-BeTrue $kit.ErrorText.Text.Contains('RegisterHotKey rejected Ctrl+Alt+Shift+R') + Should-BeTrue $kit.ErrorText.Text.Contains('previous shortcut remains active') + } + + Should-Be ($calls -join ',') '82,81' + Should-Be $context.RegisteredHotkey.VirtualKey 0x51 + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'reports candidate and rollback failures without claiming a shortcut is active' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-settings-rollback-' + [guid]::NewGuid()) + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings -PicturesDir $root + SettingsPath = (Join-Path $root 'settings.json') + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + Hwnd = [IntPtr]123 + RegisterHotkey = { param($hwnd,$id,$mods,$vk) $false } + UnregisterHotkey = { param($hwnd,$id) $true } + } + + $null = Show-SettingsWindow -Context $context -TestAction { + param($kit) + $result = & $kit.RecordShortcut 0x4007 0x52 + Should-BeFalse $result.Success + Should-Be $kit.ShortcutText.Text 'Unavailable' + Should-BeTrue $kit.ErrorText.Text.Contains('Could not restore the previous hotkey') + Should-BeTrue $kit.ErrorText.Text.Contains('capture remains available from the tray') + } + + Should-Be $null $context.RegisteredHotkey + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'warns that an unsaved candidate may remain active when native cleanup fails' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-settings-uncertain-hotkey-' + [guid]::NewGuid()) + $settingsPath = Join-Path $root 'settings.json' + $settings = Get-SnipDefaultSettings -PicturesDir $root + Save-SnipSettings -Settings $settings -Path $settingsPath + $state = @{ UnregisterCount = 0 } + $context = [pscustomobject]@{ + Settings = $settings + SettingsPath = $settingsPath + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + Hwnd = [IntPtr]123 + RegisterHotkey = { param($hwnd,$id,$mods,$vk) $true } + UnregisterHotkey = { + param($hwnd,$id) + $state.UnregisterCount++ + return $state.UnregisterCount -eq 1 + }.GetNewClosure() + } + $fileLock = [IO.File]::Open( + $settingsPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) + try { + Show-SettingsWindow -Context $context -TestAction { + param($kit) + $result = & $kit.RecordShortcut 0x4007 0x52 + Should-BeFalse $result.Success + Should-Be $kit.ShortcutText.Text 'Ctrl+Alt+Shift+R' + Should-BeTrue $kit.ErrorText.Text.Contains('may remain active') + Should-BeFalse $kit.ErrorText.Text.Contains('No global shortcut is active') + } | Out-Null + } finally { + $fileLock.Dispose() + } + Should-Be $context.RegisteredHotkey.VirtualKey 0x52 + Should-Be $context.Settings.Hotkey.VirtualKey 0x51 + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'persists save folder launch-at-sign-in and widget visibility together' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-settings-fields-' + [guid]::NewGuid()) + $settingsPath = Join-Path $root 'settings.json' + $syncCalls = [System.Collections.ArrayList]::new() + $widgetCalls = [System.Collections.ArrayList]::new() + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings -PicturesDir $root + SettingsPath = $settingsPath + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + Hwnd = [IntPtr]123 + RegisterHotkey = { param($hwnd,$id,$mods,$vk) $true } + UnregisterHotkey = { param($hwnd,$id) $true } + SyncStartup = { param($settings) [void]$syncCalls.Add($settings.LaunchAtSignIn) }.GetNewClosure() + SetWidgetVisible = { param($visible) [void]$widgetCalls.Add($visible) }.GetNewClosure() + } + $customFolder = Join-Path $root 'Bench captures' + + $null = Show-SettingsWindow -Context $context -TestAction { + param($kit) + $kit.SaveFolderBox.Text = $customFolder + $kit.LaunchCheck.IsChecked = $false + $kit.WidgetCheck.IsChecked = $true + Should-BeTrue (& $kit.SaveChanges) + Should-Be $kit.ErrorText.Text 'Settings saved.' + } + + $saved = Read-SnipSettings -Path $settingsPath -PicturesDir $root + Should-Be $saved.SaveFolder $customFolder + Should-BeFalse $saved.LaunchAtSignIn + Should-BeTrue $saved.WidgetVisible + Should-Be ($syncCalls -join ',') 'False' + Should-Be ($widgetCalls -join ',') 'True' + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'restores persisted and in-memory settings when a runtime side effect fails' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('snipit-settings-rollback-fields-' + [guid]::NewGuid()) + $settingsPath = Join-Path $root 'settings.json' + $settings = Get-SnipDefaultSettings -PicturesDir $root + Save-SnipSettings -Settings $settings -Path $settingsPath + $context = [pscustomobject]@{ + Settings = $settings + SettingsPath = $settingsPath + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + Hwnd = [IntPtr]123 + RegisterHotkey = { param($hwnd,$id,$mods,$vk) $true } + UnregisterHotkey = { param($hwnd,$id) $true } + SyncStartup = { + param($candidate) + if (-not $candidate.LaunchAtSignIn) { throw 'startup shortcut denied' } + } + SetWidgetVisible = { param($visible) } + } + $originalFolder = $settings.SaveFolder + + $null = Show-SettingsWindow -Context $context -TestAction { + param($kit) + $kit.SaveFolderBox.Text = (Join-Path $root 'Changed') + $kit.LaunchCheck.IsChecked = $false + $kit.WidgetCheck.IsChecked = $true + Should-BeFalse (& $kit.SaveChanges) + Should-BeTrue $kit.ErrorText.Text.Contains('Previous settings were restored') + } + + $saved = Read-SnipSettings -Path $settingsPath -PicturesDir $root + Should-Be $context.Settings.SaveFolder $originalFolder + Should-BeTrue $context.Settings.LaunchAtSignIn + Should-BeFalse $context.Settings.WidgetVisible + Should-Be $saved.SaveFolder $originalFolder + Should-BeTrue $saved.LaunchAtSignIn + Should-BeFalse $saved.WidgetVisible + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Describe 'About utility surface' { + It 'exposes product version runtimes repository MIT license and active shortcut' { + $parameters = (Get-Command Show-AboutWindow -CommandType Function).Parameters + Should-BeTrue $parameters.ContainsKey('Context') + Should-BeTrue $parameters.ContainsKey('TestAction') + $context = [pscustomobject]@{ + AppVersion = '0.1.1' + Repository = 'https://github.com/RandomCodeSpace/snipIT' + License = 'MIT License' + RegisteredHotkey = [pscustomobject]@{ Modifiers = 0x4007; VirtualKey = 0x51 } + } + + $null = Show-AboutWindow -Context $context -TestAction { + param($kit) + Should-Be $kit.Metadata.Version '0.1.1' + Should-Be $kit.Metadata.PowerShell $PSVersionTable.PSVersion.ToString() + Should-Be $kit.Metadata.DotNet ([Environment]::Version.ToString()) + Should-Be $kit.Metadata.Repository 'https://github.com/RandomCodeSpace/snipIT' + Should-Be $kit.Metadata.License 'MIT License' + Should-Be $kit.Metadata.ActiveShortcut 'Ctrl+Alt+Shift+Q' + Should-Be $kit.Window.Title 'About SnipIT' + Should-BeFalse ([string]::IsNullOrWhiteSpace([string]$kit.Window.FindName('CloseBtn').ToolTip)) + } + } +} + +Describe 'Optional floating capture widget' { + It 'does not create a window while WidgetVisible is false' { + $parameters = (Get-Command Show-FloatingWidget -CommandType Function).Parameters + Should-BeTrue $parameters.ContainsKey('Context') + Should-BeTrue $parameters.ContainsKey('TestAction') + $testActionRan = $false + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings + SubmitRequest = { param($mode) } + OpenSettings = { } + } + + $result = Show-FloatingWidget -Context $context -TestAction { + param($kit) + $testActionRan = $true + }.GetNewClosure() + + Should-Be $null $result + Should-BeFalse $testActionRan + Should-Be $null $script:WidgetWindow + } + + It 'orders Smart Full Window Settings and routes only through supplied services' { + $parameters = (Get-Command Show-FloatingWidget -CommandType Function).Parameters + Should-BeTrue $parameters.ContainsKey('Context') + Should-BeTrue $parameters.ContainsKey('TestAction') + $requests = [System.Collections.ArrayList]::new() + $settingsOpens = [System.Collections.ArrayList]::new() + $settings = Get-SnipDefaultSettings + $settings.WidgetVisible = $true + $context = [pscustomobject]@{ + Settings = $settings + SubmitRequest = { param($mode) [void]$requests.Add($mode) }.GetNewClosure() + OpenSettings = { [void]$settingsOpens.Add('Settings') }.GetNewClosure() + AnimationsEnabled = $false + } + + Show-FloatingWidget -Context $context -TestAction { + param($kit) + Should-Be ($kit.Order -join ',') 'Smart,Full,Window,Settings' + foreach ($button in $kit.Buttons.Values) { + Should-BeFalse ([string]::IsNullOrWhiteSpace([string]$button.ToolTip)) + } + & $kit.Click 'SmartBtn' + & $kit.Click 'FullBtn' + & $kit.Click 'WindowBtn' + & $kit.Click 'SettingsBtn' + } + + Should-Be ($requests -join ',') 'Smart,Full,Window' + Should-Be $settingsOpens.Count 1 + } +} + +Describe 'Owner-drawn tray presentation' { + It 'uses the locked neutral palette and exact primary action order' { + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings + ActiveShortcut = 'Ctrl+Alt+Shift+Q' + SubmitRequest = { param($mode,$delay) } + OpenSettings = { } + OpenAbout = { } + Exit = { } + } + $menu = New-SnipTrayMenu -Context $context + try { + Should-BeTrue ($menu -is [System.Windows.Forms.ContextMenuStrip]) + Should-BeTrue ($menu.Renderer -is [System.Windows.Forms.ToolStripProfessionalRenderer]) + Should-Be $menu.Tag.Palette.PageBlack '#030405' + Should-Be $menu.Tag.Palette.PrimaryText '#F2F5F7' + Should-Be $menu.Tag.Palette.Accent '#D8C8A5' + Should-Be ($menu.Tag.PrimaryOrder -join ',') 'Smart,Full,Window,Settings,About,Exit' + Should-Be ($menu.Tag.Order -join ',') 'Smart,Full,Window,Separator1,Delay,Settings,Widget,OpenFolder,Separator2,About,Uninstall,Exit' + } finally { + $menu.Dispose() + } + } + + It 'uses system palette colors in High Contrast while retaining owner drawing' { + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings + ActiveShortcut = 'Ctrl+Alt+Shift+Q' + HighContrast = $true + SubmitRequest = { param($mode,$delay) } + OpenSettings = { } + OpenAbout = { } + Exit = { } + } + $menu = New-SnipTrayMenu -Context $context + try { + Should-BeTrue $menu.Tag.HighContrast + Should-BeTrue ($menu.Renderer -is [System.Windows.Forms.ToolStripProfessionalRenderer]) + Should-Be $menu.Tag.Palette.PageBlack ([System.Drawing.ColorTranslator]::ToHtml([System.Drawing.SystemColors]::Menu)) + Should-Be $menu.Tag.Palette.PrimaryText ([System.Drawing.ColorTranslator]::ToHtml([System.Drawing.SystemColors]::MenuText)) + Should-Be $menu.Tag.Palette.Accent ([System.Drawing.ColorTranslator]::ToHtml([System.Drawing.SystemColors]::Highlight)) + Should-Be $menu.Tag.CheckPalette.Fill.ToArgb() ([System.Drawing.SystemColors]::Highlight.ToArgb()) + Should-Be $menu.Tag.CheckPalette.Glyph.ToArgb() ([System.Drawing.SystemColors]::HighlightText.ToArgb()) + } finally { + $menu.Dispose() + } + } + + It 'applies the root visual policy to every nested tray dropdown' { + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings + ActiveShortcut = 'Ctrl+Alt+Shift+Q' + SubmitRequest = { param($mode,$delay) } + OpenSettings = { } + OpenAbout = { } + Exit = { } + } + $menu = New-SnipTrayMenu -Context $context + try { + Should-Be $menu.Tag.DropDownLifecycles.Count 2 + foreach ($lifecycle in $menu.Tag.DropDownLifecycles) { + $dropDown = $lifecycle.DropDown + Should-BeTrue ([object]::ReferenceEquals($dropDown.Renderer, $menu.Renderer)) + Should-Be $dropDown.BackColor.ToArgb() $menu.BackColor.ToArgb() + Should-Be $dropDown.ForeColor.ToArgb() $menu.ForeColor.ToArgb() + Should-Be $dropDown.Font.Name $menu.Font.Name + Should-Be $dropDown.Font.Size $menu.Font.Size + Should-Be $dropDown.Font.Style $menu.Font.Style + Should-Be $dropDown.Margin $menu.Margin + Should-Be $dropDown.Padding $menu.Padding + if ($dropDown -is [System.Windows.Forms.ToolStripDropDownMenu]) { + Should-Be $dropDown.ShowImageMargin $menu.ShowImageMargin + Should-Be $dropDown.ShowCheckMargin $menu.ShowCheckMargin + } + } + } finally { + $menu.Dispose() + } + } + + It 'renders Delay submenu text with the readable light-on-black policy' { + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings + ActiveShortcut = 'Ctrl+Alt+Shift+Q' + SubmitRequest = { param($mode,$delay) } + OpenSettings = { } + OpenAbout = { } + Exit = { } + } + $menu = New-SnipTrayMenu -Context $context + $bitmap = $null + try { + $menu.Show([System.Drawing.Point]::new(-10000, -10000)) + [System.Windows.Forms.Application]::DoEvents() + $menu.Tag.Items.Delay.ShowDropDown() + [System.Windows.Forms.Application]::DoEvents() + $dropDown = $menu.Tag.Items.Delay.DropDown + $bitmap = [System.Drawing.Bitmap]::new($dropDown.Width, $dropDown.Height) + $dropDown.DrawToBitmap($bitmap, $dropDown.ClientRectangle) + + $lightPixels = 0 + for ($y = 2; $y -lt $bitmap.Height - 2; $y++) { + for ($x = 30; $x -lt $bitmap.Width - 2; $x++) { + $pixel = $bitmap.GetPixel($x, $y) + if ($pixel.R -ge 170 -and $pixel.G -ge 170 -and $pixel.B -ge 170) { + $lightPixels++ + } + } + } + Should-BeGreaterThan $lightPixels 20 + Should-BeGreaterThan (Get-SnipContrastRatio ` + -Foreground $menu.Tag.Palette.PrimaryText -Background $menu.Tag.Palette.PageBlack) 4.5 + } finally { + if ($null -ne $bitmap) { $bitmap.Dispose() } + $menu.Dispose() + } + } + + It 'draws the checked-item glyph with the explicit champagne palette' { + $settings = Get-SnipDefaultSettings + $settings.WidgetVisible = $true + $context = [pscustomobject]@{ + Settings = $settings + ActiveShortcut = 'Ctrl+Alt+Shift+Q' + SubmitRequest = { param($mode,$delay) } + OpenSettings = { } + OpenAbout = { } + Exit = { } + } + $menu = New-SnipTrayMenu -Context $context + $bitmap = [System.Drawing.Bitmap]::new(28, 28) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + Should-BeTrue $menu.Tag.CheckHandlerAttached + Should-BeTrue ($menu.Tag.CheckRenderHandler -is ` + [System.Windows.Forms.ToolStripItemImageRenderEventHandler]) + Should-Be $menu.Tag.CheckPalette.Fill.ToArgb() ` + ([System.Drawing.ColorTranslator]::FromHtml('#D8C8A5').ToArgb()) + Should-Be $menu.Tag.CheckPalette.Glyph.ToArgb() ` + ([System.Drawing.ColorTranslator]::FromHtml('#030405').ToArgb()) + Should-BeFalse ($menu.Tag.CheckPalette.Fill.ToArgb() -eq ` + [System.Drawing.SystemColors]::Highlight.ToArgb()) + + $graphics.Clear([System.Drawing.Color]::Magenta) + $renderArgs = [System.Windows.Forms.ToolStripItemImageRenderEventArgs]::new( + $graphics, $menu.Tag.Items.Widget, [System.Drawing.Rectangle]::new(4, 4, 20, 20)) + $menu.Renderer.DrawItemCheck($renderArgs) + Should-Be $bitmap.GetPixel(5, 5).ToArgb() $menu.Tag.CheckPalette.Fill.ToArgb() + } finally { + $graphics.Dispose() + $bitmap.Dispose() + $menu.Dispose() + } + } + + It 'overpaints all stock blue chrome across the checked-item margin' { + $settings = Get-SnipDefaultSettings + $settings.WidgetVisible = $true + $context = [pscustomobject]@{ + Settings = $settings + ActiveShortcut = 'Ctrl+Alt+Shift+Q' + SubmitRequest = { param($mode,$delay) } + OpenSettings = { } + OpenAbout = { } + Exit = { } + } + $menu = New-SnipTrayMenu -Context $context + $bitmap = $null + try { + $menu.Show([System.Drawing.Point]::new(-10000, -10000)) + [System.Windows.Forms.Application]::DoEvents() + $bitmap = [System.Drawing.Bitmap]::new($menu.Width, $menu.Height) + $menu.DrawToBitmap($bitmap, $menu.ClientRectangle) + + $widgetBounds = $menu.Tag.Items.Widget.Bounds + $accentArgb = $menu.Tag.CheckPalette.Fill.ToArgb() + $systemHighlightArgb = [System.Drawing.SystemColors]::Highlight.ToArgb() + $accentPixels = 0 + $systemHighlightPixels = 0 + $blueVariantPixels = 0 + for ($y = $widgetBounds.Top; $y -lt $widgetBounds.Bottom; $y++) { + for ($x = 0; $x -lt [math]::Min(32, $bitmap.Width); $x++) { + $pixel = $bitmap.GetPixel($x, $y) + if ($pixel.ToArgb() -eq $accentArgb) { $accentPixels++ } + if ($pixel.ToArgb() -eq $systemHighlightArgb) { $systemHighlightPixels++ } + if ($pixel.B -ge 110 -and $pixel.B -gt $pixel.R + 25 -and + $pixel.B -gt $pixel.G + 15) { + $blueVariantPixels++ + } + } + } + + Should-BeGreaterThan $accentPixels 100 + Should-Be $systemHighlightPixels 0 + Should-Be $blueVariantPixels 0 + } finally { + if ($null -ne $bitmap) { $bitmap.Dispose() } + $menu.Dispose() + } + } + + It 'detaches the explicit checked-item renderer when the tray menu is disposed' { + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings + ActiveShortcut = 'Ctrl+Alt+Shift+Q' + SubmitRequest = { param($mode,$delay) } + OpenSettings = { } + OpenAbout = { } + Exit = { } + } + $menu = New-SnipTrayMenu -Context $context + $state = $menu.Tag + Should-BeTrue $state.CheckHandlerAttached + + $menu.Dispose() + + Should-BeFalse $state.CheckHandlerAttached + } + + It 'routes capture settings about and exit actions to the supplied services' { + $requests = [System.Collections.ArrayList]::new() + $actions = [System.Collections.ArrayList]::new() + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings + ActiveShortcut = 'Ctrl+Alt+Shift+Q' + SubmitRequest = { param($mode,$delay) [void]$requests.Add($mode) }.GetNewClosure() + OpenSettings = { [void]$actions.Add('Settings') }.GetNewClosure() + OpenAbout = { [void]$actions.Add('About') }.GetNewClosure() + Exit = { [void]$actions.Add('Exit') }.GetNewClosure() + } + $menu = New-SnipTrayMenu -Context $context + try { + foreach ($name in 'Smart','Full','Window','Settings','About','Exit') { + $menu.Tag.Items[$name].PerformClick() + } + Should-Be ($requests -join ',') 'Smart,Full,Window' + Should-Be ($actions -join ',') 'Settings,About,Exit' + } finally { + $menu.Dispose() + } + } + + It 'registers every open tray dropdown HWND and unregisters each exactly once on close' { + $registered = [System.Collections.ArrayList]::new() + $unregistered = [System.Collections.ArrayList]::new() + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings + ActiveShortcut = 'Ctrl+Alt+Shift+Q' + SubmitRequest = { param($mode,$delay) } + OpenSettings = { } + OpenAbout = { } + Exit = { } + RegisterWindow = { param($hwnd) [void]$registered.Add($hwnd) }.GetNewClosure() + UnregisterWindow = { param($hwnd) [void]$unregistered.Add($hwnd) }.GetNewClosure() + } + $menu = New-SnipTrayMenu -Context $context + try { + $menu.Show([System.Drawing.Point]::new(-10000, -10000)) + [System.Windows.Forms.Application]::DoEvents() + $menu.Tag.Items.Delay.ShowDropDown() + [System.Windows.Forms.Application]::DoEvents() + Should-Be $registered.Count 2 + Should-BeTrue ($registered[0] -ne [IntPtr]::Zero) + Should-BeTrue ($registered[1] -ne [IntPtr]::Zero) + Should-BeFalse ($registered[0] -eq $registered[1]) + $menu.Close() + [System.Windows.Forms.Application]::DoEvents() + Should-Be $unregistered.Count 2 + Should-Be (@($unregistered | Sort-Object) -join ',') ` + (@($registered | Sort-Object) -join ',') + } finally { + $menu.Dispose() + } + Should-Be $unregistered.Count 2 + } + + It 'uses disposal fallback for every still-open tray dropdown without double unregistering' { + $registered = [System.Collections.ArrayList]::new() + $unregistered = [System.Collections.ArrayList]::new() + $context = [pscustomobject]@{ + Settings = Get-SnipDefaultSettings + ActiveShortcut = 'Ctrl+Alt+Shift+Q' + SubmitRequest = { param($mode,$delay) } + OpenSettings = { } + OpenAbout = { } + Exit = { } + RegisterWindow = { param($hwnd) [void]$registered.Add($hwnd) }.GetNewClosure() + UnregisterWindow = { param($hwnd) [void]$unregistered.Add($hwnd) }.GetNewClosure() + } + $menu = New-SnipTrayMenu -Context $context + $menu.Show([System.Drawing.Point]::new(-10000, -10000)) + [System.Windows.Forms.Application]::DoEvents() + $menu.Tag.Items.Delay.ShowDropDown() + [System.Windows.Forms.Application]::DoEvents() + Should-Be $registered.Count 2 + + $menu.Dispose() + [System.Windows.Forms.Application]::DoEvents() + + Should-Be $unregistered.Count 2 + Should-Be (@($unregistered | Sort-Object) -join ',') ` + (@($registered | Sort-Object) -join ',') + } + + It 'keeps utility surfaces free of direct capture calls' { + $forbidden = @( + 'Invoke-SmartCapture','Invoke-FullScreenCapture','Invoke-WindowCapture', + 'Show-SmartOverlay','Show-PreviewWindow','New-ScreenBitmap','Request-SnipCapture' + ) + foreach ($functionName in 'Show-SettingsWindow','Show-AboutWindow','Show-FloatingWidget','New-SnipTrayMenu') { + $ast = (Get-Command $functionName -CommandType Function).ScriptBlock.Ast + $commands = @($ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true) | + ForEach-Object { $_.GetCommandName() }) + foreach ($commandName in $forbidden) { + Should-BeFalse ($commands -contains $commandName) + } + } + } +} + +Describe 'Floating Studio monitor placement review fixes' { + It 'publishes each monitor physical working area' { + $descriptors = @(Get-SnipMonitorDescriptors) + Should-BeTrue ($descriptors.Count -gt 0) + foreach ($descriptor in $descriptors) { + foreach ($propertyName in 'WorkX','WorkY','WorkWidth','WorkHeight') { + Should-BeTrue ($null -ne $descriptor.PSObject.Properties[$propertyName]) + } + Should-BeTrue ($descriptor.WorkWidth -gt 0) + Should-BeTrue ($descriptor.WorkHeight -gt 0) + Should-BeTrue ($descriptor.WorkX -ge $descriptor.X) + Should-BeTrue ($descriptor.WorkY -ge $descriptor.Y) + Should-BeTrue (($descriptor.WorkX + $descriptor.WorkWidth) -le + ($descriptor.X + $descriptor.Width)) + Should-BeTrue (($descriptor.WorkY + $descriptor.WorkHeight) -le + ($descriptor.Y + $descriptor.Height)) + } + } + + It 'chooses the largest cross-monitor capture intersection' { + $bitmap = [System.Drawing.Bitmap]::new(32, 32) + try { + $monitors = @( + [pscustomobject]@{ Id='Left'; X=-1000; Y=0; Width=1000; Height=1000; WorkX=-1000; WorkY=0; WorkWidth=1000; WorkHeight=960; DpiX=96; DpiY=96; IsPrimary=$true }, + [pscustomobject]@{ Id='Right'; X=0; Y=650; Width=1000; Height=100; WorkX=0; WorkY=650; WorkWidth=1000; WorkHeight=100; DpiX=96; DpiY=96; IsPrimary=$false } + ) + # The capture center is on Right, but Left owns twice the captured area. + $context = New-SnipPreviewContext -Bitmap $bitmap ` + -CaptureBounds ([pscustomobject]@{ X=-200; Y=500; Width=600; Height=400 }) ` + -MonitorDescriptors $monitors + Should-Be $context.CaptureMonitor.Id 'Left' + } finally { $bitmap.Dispose() } + } + + It 'converts 150-percent negative-origin placement and applies exact physical work-area bounds' { + $bitmap = [System.Drawing.Bitmap]::new(32, 32) + $calls = [System.Collections.ArrayList]::new() + $setWindowPosition = { + param($hwnd,$bounds) + $calls.Add([pscustomobject]@{ Hwnd=$hwnd; Bounds=$bounds }) | Out-Null + $true + }.GetNewClosure() + $context = $null + $shell = $null + try { + $monitor = [pscustomobject]@{ + Id='ScaledLeft'; X=-2560; Y=-200; Width=2560; Height=1600 + WorkX=-2560; WorkY=-160; WorkWidth=2560; WorkHeight=1520 + DpiX=144; DpiY=144; IsPrimary=$false + } + $context = New-SnipPreviewContext -Bitmap $bitmap ` + -CaptureBounds ([pscustomobject]@{ X=-2400; Y=-100; Width=800; Height=600 }) ` + -MonitorDescriptors @($monitor) -SetWindowPosition $setWindowPosition + Should-Be $context.InitialBounds.Width 1180 + Should-Be $context.InitialBounds.Height 760 + Should-Be $context.InitialPhysicalBounds.X -2560 + Should-Be $context.InitialPhysicalBounds.Y -160 + Should-Be $context.InitialPhysicalBounds.Width 1770 + Should-Be $context.InitialPhysicalBounds.Height 1140 + + $shell = New-SnipPreviewWindow -Context $context + $shell.Window.Show() + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + Should-Be $calls.Count 1 + Should-BeTrue ($calls[0].Hwnd -ne [IntPtr]::Zero) + Should-Be $calls[0].Bounds.X -2560 + Should-Be $calls[0].Bounds.Y -160 + Should-Be $calls[0].Bounds.Width 1770 + Should-Be $calls[0].Bounds.Height 1140 + Should-BeTrue $context.PlacementState.HandlersAttached + $shell.Window.Close() + Should-BeFalse $context.PlacementState.HandlersAttached + foreach ($split in $context.SplitControls.Values) { + Should-BeFalse $split.State.HandlersAttached + } + } finally { + if ($null -ne $shell -and $shell.Window.IsVisible) { $shell.Window.Close() } + $bitmap.Dispose() + } + } +} + +Describe 'Floating Studio routed chrome review fixes' { + It 'routes actual Alt Space through the single command router' { + $bitmap = [System.Drawing.Bitmap]::new(16,16) + $systemMenuCalls = @{ Count=0; ResolveCount=0 } + $result = Show-PreviewWindow -Bitmap $bitmap -TestAction { + param($kit) + $originalResolver = $kit.CommandRouter.Resolve + $kit.CommandRouter.Resolve = { + param($focusedRole,$editorState,$key,$modifiers) + $systemMenuCalls.ResolveCount++ + & $originalResolver $focusedRole $editorState $key $modifiers + }.GetNewClosure() + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::Alt + } + $kit.CommandRouter.SystemMenuAction = { + $systemMenuCalls.Count++ + }.GetNewClosure() + Should-BeFalse ($kit.CommandRouter.PSObject.Properties.Name -contains + 'RouteWindowChord') + $eventArgs = New-RoutedKeyEvent -Target $kit.Win -Key Space -SystemKey Space + $kit.Win.RaiseEvent($eventArgs) + Should-BeTrue $eventArgs.Handled + Should-Be $systemMenuCalls.Count 1 + Should-Be $systemMenuCalls.ResolveCount 1 + Should-Be $kit.CommandRouter.LastCommand 'ShowSystemMenu' + }.GetNewClosure() + Should-Be $result 'UserCancelled' + } + + It 'routes actual Alt F4 unconditionally while an editor and popup claim focus' { + $bitmap = [System.Drawing.Bitmap]::new(16,16) + $closedByRoute = @{ + Context=$null; Event=$null; Key=$null; SystemKey=$null + } + $result = Show-PreviewWindow -Bitmap $bitmap -TestAction { + param($kit) + $kit.State.EditingText = $true + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::Alt + } + Should-BeFalse ($kit.CommandRouter.PSObject.Properties.Name -contains + 'RouteWindowChord') + # WPF's Window class handler consumes a synthetic F4 raised through + # RaiseEvent before instance handlers run. Drive the exact published + # PreviewKeyDown handler with a real SystemKey-shaped KeyEventArgs. + $eventArgs = New-RoutedKeyEvent -Target $kit.Win -Key F4 -SystemKey F4 + $closedByRoute.Event = $eventArgs + $closedByRoute.Context = $kit.Context + $closedByRoute.Key = $eventArgs.Key + $closedByRoute.SystemKey = $eventArgs.SystemKey + $kit.HandlePreviewKeyDown.Invoke($eventArgs) | Out-Null + }.GetNewClosure() + Should-Be $closedByRoute.Key ([System.Windows.Input.Key]::System) + Should-Be $closedByRoute.SystemKey ([System.Windows.Input.Key]::F4) + Should-Be $closedByRoute.Context.CommandRouter.ResolveCount 1 + Should-Be $closedByRoute.Context.CommandRouter.LastCommand 'ClosePreview' + Should-BeTrue $closedByRoute.Event.Handled + Should-Be $result 'UserCancelled' + } +} + +Describe 'Floating Studio copy completion contract' { + It 'retries a busy clipboard after 50 100 and 200 milliseconds then closes on success' { + $bitmap = [System.Drawing.Bitmap]::new(16, 16) + $events = [System.Collections.ArrayList]::new() + $delays = [System.Collections.ArrayList]::new() + $attempts = @{ Count = 0 } + $clipboardSetter = { + param($image) + $attempts.Count++ + if ($attempts.Count -lt 4) { throw 'clipboard busy' } + }.GetNewClosure() + $retryDelay = { + param($milliseconds) + $delays.Add($milliseconds) | Out-Null + }.GetNewClosure() + $result = Show-PreviewWindow -Bitmap $bitmap ` + -OnOutputStarting { param($kind) $events.Add("start:$kind") | Out-Null }.GetNewClosure() ` + -OnOutputCompleted { param($kind,$success) $events.Add("done:${kind}:$success") | Out-Null }.GetNewClosure() ` + -TestAction { + param($kit) + $kit.Context.ClipboardSetter = $clipboardSetter + $kit.Context.RetryDelay = $retryDelay + $kit.SplitControls.Copy.PrimaryButton.RaiseEvent( + [System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) + }.GetNewClosure() + Should-Be $result 'Completed' + Should-Be $attempts.Count 4 + Should-Be (($delays) -join ',') '50,100,200' + Should-Be (($events) -join ',') 'start:CopyAndClose,done:CopyAndClose:True' + $disposed = $false + try { $bitmap.GetPixel(0,0) | Out-Null } catch { $disposed = $true } + Should-BeTrue $disposed + } + + It 'copies and keeps Preview open from the split option' { + $bitmap = [System.Drawing.Bitmap]::new(16, 16) + $events = [System.Collections.ArrayList]::new() + $wasOpenAfterCopy = @{ Value = $false } + $result = Show-PreviewWindow -Bitmap $bitmap ` + -OnOutputStarting { param($kind) $events.Add("start:$kind") | Out-Null }.GetNewClosure() ` + -OnOutputCompleted { param($kind,$success) $events.Add("done:${kind}:$success") | Out-Null }.GetNewClosure() ` + -TestAction { + param($kit) + $kit.Context.ClipboardSetter = { param($image) }.GetNewClosure() + $kit.Context.RetryDelay = { param($milliseconds) }.GetNewClosure() + $kit.SplitControls.Copy.MenuItems.CopyKeepOpen.RaiseEvent( + [System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.MenuItem]::ClickEvent)) + $wasOpenAfterCopy.Value = $kit.Win.IsVisible + }.GetNewClosure() + Should-BeTrue $wasOpenAfterCopy.Value + Should-Be $result 'UserCancelled' + Should-Be (($events) -join ',') 'start:CopyKeepOpen,done:CopyKeepOpen:True' + } + + It 'keeps Preview open with recoverable status after clipboard retries are exhausted' { + $bitmap = [System.Drawing.Bitmap]::new(16, 16) + $events = [System.Collections.ArrayList]::new() + $delays = [System.Collections.ArrayList]::new() + $attempts = @{ Count = 0 } + $wasOpen = @{ Value=$false; Status=$null } + $clipboardSetter = { + param($image) + $attempts.Count++ + throw 'clipboard remains busy' + }.GetNewClosure() + $retryDelay = { + param($milliseconds) + $delays.Add($milliseconds) | Out-Null + }.GetNewClosure() + $result = Show-PreviewWindow -Bitmap $bitmap ` + -OnOutputStarting { param($kind) $events.Add("start:$kind") | Out-Null }.GetNewClosure() ` + -OnOutputCompleted { param($kind,$success) $events.Add("done:${kind}:$success") | Out-Null }.GetNewClosure() ` + -TestAction { + param($kit) + $kit.Context.ClipboardSetter = $clipboardSetter + $kit.Context.RetryDelay = $retryDelay + $kit.SplitControls.Copy.PrimaryButton.RaiseEvent( + [System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) + $wasOpen.Value = $kit.Win.IsVisible + $wasOpen.Status = $kit.StatusIsland.Child.Children[1].Text + }.GetNewClosure() + Should-Be $result 'UserCancelled' + Should-BeTrue $wasOpen.Value + Should-Be $attempts.Count 4 + Should-Be (($delays) -join ',') '50,100,200' + Should-Be $wasOpen.Status 'Clipboard is unavailable' + Should-Be (($events) -join ',') 'start:CopyAndClose,done:CopyAndClose:False' + } +} + +Describe 'Floating Studio popup policy edge cases' { + It 'binds checked and submenu glyphs to live High Contrast item foregrounds' { + $bitmap = [System.Drawing.Bitmap]::new(24,24) + $shell = $null + $failures = [System.Collections.ArrayList]::new() + try { + $resources = New-SnipThemeResources -HighContrast $true -AnimationsEnabled $false + $context = New-SnipPreviewContext -Bitmap $bitmap -Resources $resources ` + -SetWindowPosition { param($hwnd,$bounds) $true } + $shell = New-SnipPreviewWindow -Context $context + $shell.Window.Show() + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + Set-SnipPreviewResponsiveMode -Context $context -Width 760 -Height 540 | Out-Null + + $highlightItem = $context.Shell.MoreMenuItems.Highlight + $colorParent = $context.Shell.MoreMenuItems.Color + $highlightItem.IsCheckable = $true + $highlightItem.IsChecked = $true + $context.Shell.MoreButton.RaiseEvent([System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Render) + + $colorParent.ApplyTemplate() | Out-Null + $normalArrow = $colorParent.Template.FindName('SubmenuArrow',$colorParent) + if ($normalArrow.Foreground -ne $colorParent.Foreground) { + $failures.Add( + "normal arrow foreground $($normalArrow.Foreground) did not follow item $($colorParent.Foreground)") | Out-Null + } + + $colorParent.IsEnabled = $false + $colorParent.UpdateLayout() + if ($colorParent.Foreground -ne $resources['SnipMutedTextBrush']) { + $failures.Add('disabled submenu parent did not use SnipMutedTextBrush') | Out-Null + } + if ($normalArrow.Foreground -ne $colorParent.Foreground) { + $failures.Add( + "disabled arrow foreground $($normalArrow.Foreground) did not follow item $($colorParent.Foreground)") | Out-Null + } + $colorParent.IsEnabled = $true + + $highlightItem.Focus() | Out-Null + $highlightItem.UpdateLayout() + $checkMark = $highlightItem.Template.FindName('CheckMark',$highlightItem) + if ($checkMark.Visibility -ne [System.Windows.Visibility]::Visible) { + $failures.Add('checked High Contrast item did not expose its live checkmark') | Out-Null + } + if ($highlightItem.Foreground -ne [System.Windows.SystemColors]::HighlightTextBrush) { + $failures.Add('selected checked item did not use system HighlightText') | Out-Null + } + if ($checkMark.Foreground -ne $highlightItem.Foreground) { + $failures.Add( + "selected checkmark foreground $($checkMark.Foreground) did not follow item $($highlightItem.Foreground)") | Out-Null + } + + $colorParent.IsSubmenuOpen = $true + $colorParent.Focus() | Out-Null + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Render) + $colorParent.UpdateLayout() + $selectedArrow = $colorParent.Template.FindName('SubmenuArrow',$colorParent) + if ($colorParent.Foreground -ne [System.Windows.SystemColors]::HighlightTextBrush) { + $failures.Add('selected submenu parent did not use system HighlightText') | Out-Null + } + if ($selectedArrow.Foreground -ne $colorParent.Foreground) { + $failures.Add( + "selected arrow foreground $($selectedArrow.Foreground) did not follow item $($colorParent.Foreground)") | Out-Null + } + + if ($failures.Count -gt 0) { throw ($failures -join [Environment]::NewLine) } + } finally { + if ($null -ne $colorParent) { $colorParent.IsEnabled = $true } + if ($null -ne $highlightItem) { + $highlightItem.IsChecked = $false + $highlightItem.IsCheckable = $false + } + if ($null -ne $shell -and $shell.Window.IsVisible) { $shell.Window.Close() } + $bitmap.Dispose() + } + } + + It 'uses HighlightText for actual High Contrast root nested and Close focus states' { + $bitmap = [System.Drawing.Bitmap]::new(24,24) + $shell = $null + try { + $resources = New-SnipThemeResources -HighContrast $true -AnimationsEnabled $false + $context = New-SnipPreviewContext -Bitmap $bitmap -Resources $resources ` + -SetWindowPosition { param($hwnd,$bounds) $true } + $shell = New-SnipPreviewWindow -Context $context + $shell.Window.Show() + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + Set-SnipPreviewResponsiveMode -Context $context -Width 760 -Height 540 | Out-Null + $context.Shell.MoreButton.RaiseEvent([System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + + $rootItem = $context.Shell.MoreMenuItems.Highlight + $rootItem.Focus() | Out-Null + $rootItem.UpdateLayout() + Should-Be $rootItem.Foreground $resources['SnipAccentTextBrush'] + Should-Be $rootItem.Foreground ([System.Windows.SystemColors]::HighlightTextBrush) + Should-BeFalse ([object]::ReferenceEquals( + $rootItem.Foreground,[System.Windows.SystemColors]::WindowTextBrush)) + + $colorParent = $context.Shell.MoreMenuItems.Color + $colorParent.IsSubmenuOpen = $true + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Render) + $nestedItem = $context.Shell.MoreColorMenuItems.Red + $nestedItem.Focus() | Out-Null + $nestedItem.UpdateLayout() + Should-Be $nestedItem.Foreground $resources['SnipAccentTextBrush'] + Should-Be $nestedItem.Foreground ([System.Windows.SystemColors]::HighlightTextBrush) + + & $context.CommandRouter.CloseTransientMenus + $close = $shell.Window.FindName('CloseBtn') + $close.Focus() | Out-Null + $close.UpdateLayout() + Should-Be $close.Foreground $resources['SnipAccentTextBrush'] + } finally { + if ($null -ne $shell -and $shell.Window.IsVisible) { $shell.Window.Close() } + $bitmap.Dispose() + } + } + + It 'maps reduced motion to live nested popup animation and detaches popup lifecycle handlers' { + foreach ($case in @( + [pscustomobject]@{ Enabled=$false; Expected=[System.Windows.Controls.Primitives.PopupAnimation]::None }, + [pscustomobject]@{ Enabled=$true; Expected=[System.Windows.Controls.Primitives.PopupAnimation]::Fade })) { + $bitmap = [System.Drawing.Bitmap]::new(24,24) + $shell = $null + $popup = $null + try { + $resources = New-SnipThemeResources -HighContrast $false ` + -AnimationsEnabled $case.Enabled + Should-Be $resources['SnipPopupAnimation'] $case.Expected + $context = New-SnipPreviewContext -Bitmap $bitmap -Resources $resources ` + -SetWindowPosition { param($hwnd,$bounds) $true } + $shell = New-SnipPreviewWindow -Context $context + $shell.Window.Show() + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + Set-SnipPreviewResponsiveMode -Context $context -Width 760 -Height 540 | Out-Null + $context.Shell.MoreButton.RaiseEvent([System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) + $colorParent = $context.Shell.MoreMenuItems.Color + $colorParent.IsSubmenuOpen = $true + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Render) + $popup = $colorParent.Template.FindName('PART_Popup',$colorParent) + Should-BeTrue ($null -ne $popup) + Should-Be $popup.PopupAnimation $case.Expected + Should-BeTrue $popup.Tag.HandlersAttached + $shell.Window.Close() + Should-BeFalse $popup.Tag.HandlersAttached + Should-BeFalse $context.Shell.MoreMenuState.HandlersAttached + Should-BeFalse $context.Shell.MoreMenu.Tag.HandlersAttached + } finally { + if ($null -ne $shell -and $shell.Window.IsVisible) { $shell.Window.Close() } + $bitmap.Dispose() + } + } + } +} + +Describe 'Task 7 routed Preview close and crop ownership' { + It 'the final real Escape closes Preview through the single resolver level' { + $bitmap = [System.Drawing.Bitmap]::new(32,24) + $observed = @{ Command=$null; Handled=$false; Closed=$false } + $result = Show-PreviewWindow -Bitmap $bitmap -TestAction { + param($kit) + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::None + } + $kit.HighlightLayer.Focus() | Out-Null + $escape = New-RoutedKeyEvent -Target $kit.HighlightLayer -Key Escape + $kit.HighlightLayer.RaiseEvent($escape) + $observed.Command = $kit.CommandRouter.LastCommand + $observed.Handled = $escape.Handled + $observed.Closed = -not $kit.Win.IsVisible + }.GetNewClosure() + Should-Be $result 'UserCancelled' + Should-Be $observed.Command 'ClosePreview' + Should-BeTrue $observed.Handled + Should-BeTrue $observed.Closed + } + + It 'real crop UI preserves sources and Preview close disposes the Bitmap exactly once' { + $bitmap = [System.Drawing.Bitmap]::new(64,48) + $bitmap.SetPixel(0,0,[System.Drawing.Color]::Fuchsia) + $observed = @{ Ownership=$null; Source=$null; Pixel=$null } + $result = Show-PreviewWindow -Bitmap $bitmap -TestAction { + param($kit) + $kit.Context.ToolControls.Crop.RaiseEvent( + [System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) + $start=[System.Windows.Point]::new(5,5) + $finish=[System.Windows.Point]::new(50,35) + $kit.HighlightLayer.RaiseEvent((New-RoutedMouseLeftEvent ` + -Target $kit.HighlightLayer -Position $start ` + -RoutedEvent ([System.Windows.UIElement]::MouseLeftButtonDownEvent))) + $kit.HighlightLayer.RaiseEvent((New-RoutedMouseMoveEvent ` + -Target $kit.HighlightLayer -Position $finish)) + $kit.HighlightLayer.RaiseEvent((New-RoutedMouseLeftEvent ` + -Target $kit.HighlightLayer -Position $finish ` + -RoutedEvent ([System.Windows.UIElement]::MouseLeftButtonUpEvent))) + $applyElement=$kit.Context.PropertyControls.Apply.Element + $applyEvent=if($applyElement -is [System.Windows.Controls.MenuItem]){ + [System.Windows.Controls.MenuItem]::ClickEvent + }else{[System.Windows.Controls.Primitives.ButtonBase]::ClickEvent} + $applyElement.RaiseEvent([System.Windows.RoutedEventArgs]::new($applyEvent)) + Should-BeTrue ([object]::ReferenceEquals($kit.Context.Bitmap,$bitmap)) + Should-BeTrue ([object]::ReferenceEquals( + $kit.Context.BitmapSource,$kit.PreviewImage.Source)) + Should-BeTrue $kit.Context.BitmapSource.IsFrozen + Should-Be $kit.Context.Bitmap.GetPixel(0,0).ToArgb() ` + ([System.Drawing.Color]::Fuchsia.ToArgb()) + $observed.Ownership=$kit.OwnershipState + $observed.Source=$kit.Context.BitmapSource + $observed.Pixel=$kit.Context.Bitmap.GetPixel(0,0).ToArgb() + }.GetNewClosure() + Should-Be $result 'UserCancelled' + Should-BeTrue ($null -ne $observed.Ownership) + Should-BeTrue ($null -ne $observed.Ownership.PSObject.Properties['BitmapDisposed']) + Should-BeTrue $observed.Ownership.BitmapDisposed + Should-BeTrue $observed.Source.IsFrozen + Should-Be $observed.Pixel ([System.Drawing.Color]::Fuchsia.ToArgb()) + $disposed=$false + try { $bitmap.GetPixel(0,0) | Out-Null } catch { $disposed=$true } + Should-BeTrue $disposed + Should-BeTrue $observed.Ownership.BitmapDisposed + } +} + +Describe 'Task 7 authoritative annotation draft close cleanup' { + It 'window close clears one live annotation draft exactly once' { + $bitmap = [System.Drawing.Bitmap]::new(64,48) + $observed = @{ Before=$null; After=$null; Context=$null } + $result = Show-PreviewWindow -Bitmap $bitmap -TestAction { + param($kit) + & $kit.BeginDraw 'rect' ([System.Windows.Point]::new(4,5)) + & $kit.UpdateDraw ([System.Windows.Point]::new(30,28)) + Should-BeTrue ($null -ne $kit.Context.Draft) + $observed.Before = $kit.Context.AnnotationDraftClearCount + $observed.Context = $kit.Context + $kit.Win.Close() + $observed.After = $kit.Context.AnnotationDraftClearCount + }.GetNewClosure() + Should-Be $result 'UserCancelled' + Should-Be $observed.After ($observed.Before + 1) + Should-Be $observed.Context.Draft $null + Should-Be $observed.Context.Interaction $null + Should-BeFalse $observed.Context.EditorState.Drawing + } +} + +# Popup/ContextMenu tests need a fresh top-level Preview. Native WPF popups own +# separate HWND, focus, and dispatcher state that is not fully reset while a +# long-lived Preview fixture remains open. +function Wait-SnipTestDispatcherCondition { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [scriptblock]$Condition, + [Parameter(Mandatory)] [string]$Description, + [int]$TimeoutMilliseconds = 2000, + [System.Windows.Threading.DispatcherPriority]$Priority = + [System.Windows.Threading.DispatcherPriority]::Background + ) + + $timer = [System.Diagnostics.Stopwatch]::StartNew() + while ($true) { + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, $Priority) + if (& $Condition) { return } + if ($timer.ElapsedMilliseconds -ge $TimeoutMilliseconds) { + throw "Timed out waiting for $Description after $TimeoutMilliseconds ms" + } + } +} + +function New-SnipPreviewTestDrivers { + $raiseControlKey = { + param( + [System.Windows.IInputElement]$Target, + [System.Windows.Input.Key]$Key + ) + $preview = New-RoutedKeyEvent -Target $Target -Key $Key + $Target.RaiseEvent($preview) + if ($preview.Handled) { return $preview } + $bubble = New-RoutedKeyEvent -Target $Target -Key $Key ` + -RoutedEvent ([System.Windows.Input.Keyboard]::KeyDownEvent) + $Target.RaiseEvent($bubble) + $bubble + }.GetNewClosure() + $invokeAutomation = { + param([System.Windows.UIElement]$Element) + $peer = if ($Element -is [System.Windows.Controls.MenuItem]) { + [System.Windows.Automation.Peers.MenuItemAutomationPeer]::new($Element) + } else { + [System.Windows.Automation.Peers.ButtonAutomationPeer]::new($Element) + } + $provider = $peer.GetPattern( + [System.Windows.Automation.Peers.PatternInterface]::Invoke) + if ($provider -isnot [System.Windows.Automation.Provider.IInvokeProvider]) { + throw "$($Element.GetType().Name) does not expose InvokeProvider" + } + ([System.Windows.Automation.Provider.IInvokeProvider]$provider).Invoke() + }.GetNewClosure() + $expandMenuItem = { + param([System.Windows.Controls.MenuItem]$Item) + $peer = [System.Windows.Automation.Peers.MenuItemAutomationPeer]::new($Item) + $provider = $peer.GetPattern( + [System.Windows.Automation.Peers.PatternInterface]::ExpandCollapse) + if ($provider -isnot [System.Windows.Automation.Provider.IExpandCollapseProvider]) { + throw 'MenuItem does not expose ExpandCollapseProvider' + } + ([System.Windows.Automation.Provider.IExpandCollapseProvider]$provider).Expand() + }.GetNewClosure() + [pscustomobject]@{ + RaiseControlKey = $raiseControlKey + InvokeAutomation = $invokeAutomation + ExpandMenuItem = $expandMenuItem + } +} + +function Invoke-SnipFreshPreviewFixture { + [CmdletBinding()] + param([Parameter(Mandatory)] [scriptblock]$TestAction) + + $bitmap = [System.Drawing.Bitmap]::new(1200,800) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.Clear([System.Drawing.Color]::SlateBlue) + $graphics.FillRectangle([System.Drawing.Brushes]::Orange,100,100,400,300) + $graphics.FillRectangle([System.Drawing.Brushes]::Lime,700,500,300,200) + } finally { + $graphics.Dispose() + } + + $invoked = @{ Value=$false } + $ownershipAccepted = @{ Value=$false } + try { + $result = Show-PreviewWindow -Bitmap $bitmap -OnOwnershipAccepted { + $ownershipAccepted.Value = $true + }.GetNewClosure() -TestAction { + param($kit) + $invoked.Value = $true + $drivers = New-SnipPreviewTestDrivers + & $TestAction $kit $drivers + }.GetNewClosure() + } finally { + if (-not $ownershipAccepted.Value) { $bitmap.Dispose() } + } + + Should-BeTrue $invoked.Value + Should-BeTrue $ownershipAccepted.Value + Should-Be $result 'UserCancelled' +} + +Describe 'Floating Studio isolated popup fixtures' { + It 'routes Alt Down from each native split region and restores the actual opener focus' { + Invoke-SnipFreshPreviewFixture -TestAction { + param($kit,$drivers) + $split = $kit.SplitControls.Copy + Should-BeTrue ($split.PrimaryButton -is [System.Windows.Controls.Button]) + Should-BeTrue ($split.OptionsButton -is [System.Windows.Controls.Button]) + Should-BeFalse ($split.PrimaryButton -eq $split.OptionsButton) + Should-BeTrue $split.PrimaryButton.IsTabStop + Should-BeTrue $split.OptionsButton.IsTabStop + Should-Be ([System.Windows.Automation.AutomationProperties]::GetName( + $split.PrimaryButton)) 'Copy and close' + Should-Be ([System.Windows.Automation.AutomationProperties]::GetName( + $split.OptionsButton)) 'Copy options' + Should-BeTrue $split.State.HandlersAttached + + foreach ($opener in @($split.PrimaryButton,$split.OptionsButton)) { + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::Alt + } + $opener.Focus() | Out-Null + $openEvent = New-RoutedKeyEvent -Target $opener -Key Down + $opener.RaiseEvent($openEvent) + Wait-SnipTestDispatcherCondition -Description 'split menu expansion' ` + -Condition { $split.State.IsExpanded }.GetNewClosure() + Should-BeTrue $openEvent.Handled + Should-BeTrue ([object]::ReferenceEquals( + $split.State.LastOpener,$opener)) + Should-Be ([System.Windows.Automation.AutomationProperties]::GetItemStatus( + $split.OptionsButton)) 'Expanded' + + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::None + } + $closeEvent = New-RoutedKeyEvent -Target $opener -Key Escape + $opener.RaiseEvent($closeEvent) + Wait-SnipTestDispatcherCondition -Description 'split menu closure and focus restoration' ` + -Condition { + -not $split.State.IsExpanded -and $opener.IsKeyboardFocused + }.GetNewClosure() + Should-BeTrue $closeEvent.Handled + Should-Be ([System.Windows.Automation.AutomationProperties]::GetItemStatus( + $split.OptionsButton)) 'Collapsed' + } + } + } + + It 'registers split menu HWNDs and closes transient menus before capture' { + Invoke-SnipFreshPreviewFixture -TestAction { + param($kit,$drivers) + $split = $kit.SplitControls.Copy + & $split.OpenOptions + Wait-SnipTestDispatcherCondition -Description 'split menu HWND registration' ` + -Condition { + $split.State.IsExpanded -and + $split.State.MenuHandle -ne [IntPtr]::Zero -and + $split.State.IsMenuWindowRegistered + }.GetNewClosure() + & $kit.CommandRouter.CloseTransientMenus + Wait-SnipTestDispatcherCondition -Description 'split menu HWND unregistration' ` + -Condition { + -not $split.State.IsExpanded -and + -not $split.State.IsMenuWindowRegistered + }.GetNewClosure() + } + } + + It 'styles root and nested More popups with readable champagne selection and no icon gutter' { + Invoke-SnipFreshPreviewFixture -TestAction { + param($kit,$drivers) + & $kit.SetResponsiveMode 760 540 + $moreMenu = $kit.Context.Shell.MoreMenu + $menuStyle = $kit.Resources['SnipMenuItemStyle'] + Should-BeTrue ($null -ne $menuStyle) + Should-BeTrue $moreMenu.Resources.MergedDictionaries.Contains($kit.Resources) + Should-Be $moreMenu.Background $kit.Resources['SnipCanvasBrush'] + Should-Be $moreMenu.Foreground $kit.Resources['SnipPrimaryTextBrush'] + foreach ($item in @($moreMenu.Items)) { + Should-BeTrue ([object]::ReferenceEquals($item.Style,$menuStyle)) + Should-Be $item.Background $kit.Resources['SnipCanvasBrush'] + Should-Be $item.Foreground $kit.Resources['SnipPrimaryTextBrush'] + $item.ApplyTemplate() | Out-Null + Should-BeTrue ($null -ne $item.Template.FindName('MenuSurface',$item)) + Should-Be $item.Template.FindName('IconGutter',$item) $null + } + + $colorParent = $kit.Context.Shell.MoreMenuItems.Color + foreach ($colorItem in @($colorParent.Items)) { + Should-BeTrue ([object]::ReferenceEquals($colorItem.Style,$menuStyle)) + Should-Be $colorItem.Background $kit.Resources['SnipCanvasBrush'] + Should-Be $colorItem.Foreground $kit.Resources['SnipPrimaryTextBrush'] + } + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::Alt + } + $moreButton = $kit.Context.Shell.MoreButton + $moreButton.Focus() | Out-Null + $moreButton.RaiseEvent((New-RoutedKeyEvent -Target $moreButton -Key Down)) + Wait-SnipTestDispatcherCondition -Description 'More menu expansion' ` + -Condition { $kit.Context.Shell.MoreMenuState.IsExpanded }.GetNewClosure() + try { + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::None + } + & $drivers.ExpandMenuItem $colorParent + Wait-SnipTestDispatcherCondition -Description 'More Color submenu visual' ` + -Priority Render -Condition { + $popup = $colorParent.Template.FindName('PART_Popup',$colorParent) + $colorParent.IsSubmenuOpen -and $null -ne $popup -and + $null -ne $popup.Child + }.GetNewClosure() + Should-Be $colorParent.Background $kit.Resources['SnipAccentTintBrush'] + Should-Be $colorParent.BorderBrush $kit.Resources['SnipAccentBrush'] + $popup = $colorParent.Template.FindName('PART_Popup',$colorParent) + Should-Be $popup.Child.Background $kit.Resources['SnipCanvasBrush'] + Should-Be $popup.Child.Child.VerticalScrollBarVisibility ` + ([System.Windows.Controls.ScrollBarVisibility]::Auto) + Should-Be $popup.Child.Child.HorizontalScrollBarVisibility ` + ([System.Windows.Controls.ScrollBarVisibility]::Hidden) + $scrollStyle = $kit.Resources['SnipMenuScrollBarStyle'] + Should-BeTrue ($null -ne $scrollStyle) + $implicitScrollStyle = $popup.Child.Child.Resources[ + [System.Windows.Controls.Primitives.ScrollBar]] + Should-BeTrue ([object]::ReferenceEquals( + $implicitScrollStyle.BasedOn,$scrollStyle)) + } finally { + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::None + } + & $kit.CommandRouter.CloseTransientMenus + } + } + } + + It 'registers actual nested popup HWNDs and recursively closes them before capture' { + Invoke-SnipFreshPreviewFixture -TestAction { + param($kit,$drivers) + & $kit.SetResponsiveMode 760 540 + $moreButton = $kit.Context.Shell.MoreButton + & $drivers.InvokeAutomation $moreButton + Wait-SnipTestDispatcherCondition -Description 'More menu HWND registration' ` + -Condition { + $kit.Context.Shell.MoreMenuState.IsExpanded -and + $kit.Context.Shell.MoreMenuState.IsMenuWindowRegistered + }.GetNewClosure() + $colorParent = $kit.Context.Shell.MoreMenuItems.Color + & $drivers.ExpandMenuItem $colorParent + Wait-SnipTestDispatcherCondition -Description 'nested Color popup HWND registration' ` + -Priority Render -Condition { + $popup = $colorParent.Template.FindName('PART_Popup',$colorParent) + if ($null -eq $popup -or $null -eq $popup.Child) { return $false } + $source = [System.Windows.PresentationSource]::FromVisual($popup.Child) + $source -is [System.Windows.Interop.HwndSource] -and + $popup.Tag.IsRegistered + }.GetNewClosure() + $popup = $colorParent.Template.FindName('PART_Popup',$colorParent) + $nestedSource = [System.Windows.PresentationSource]::FromVisual($popup.Child) + $nestedHandle = $nestedSource.Handle + $rootHandle = $kit.Context.Shell.MoreMenuState.MenuHandle + Should-BeTrue ($rootHandle -ne [IntPtr]::Zero) + Should-BeTrue ($nestedHandle -ne [IntPtr]::Zero) + Should-BeTrue $script:SelfWindowHandles.Contains($rootHandle) + Should-BeTrue $script:SelfWindowHandles.Contains($nestedHandle) + + $hidden = Hide-OwnSnipITWindowsForCapture + try { + Wait-SnipTestDispatcherCondition -Description 'recursive popup HWND cleanup' ` + -Condition { + -not $colorParent.IsSubmenuOpen -and + -not $kit.Context.Shell.MoreMenuState.IsExpanded -and + -not $popup.Tag.IsRegistered -and + -not $script:SelfWindowHandles.Contains($rootHandle) -and + -not $script:SelfWindowHandles.Contains($nestedHandle) + }.GetNewClosure() + & $kit.CommandRouter.CloseTransientMenus + Should-BeFalse $colorParent.IsSubmenuOpen + Should-BeFalse $popup.Tag.IsRegistered + } finally { + Show-OwnSnipITWindowsForCapture -Handles $hidden + } + } + } + + It 'owner-draws an edge-constrained root scrollbar and exposes checked disabled menu state' { + Invoke-SnipFreshPreviewFixture -TestAction { + param($kit,$drivers) + & $kit.SetResponsiveMode 760 540 + $menu = $kit.Context.Shell.MoreMenu + $highlightItem = $kit.Context.Shell.MoreMenuItems.Highlight + $redItem = $kit.Context.Shell.MoreColorMenuItems.Red + $savedMaxHeight = $menu.MaxHeight + try { + $highlightItem.IsCheckable = $true + $highlightItem.IsChecked = $true + $redItem.IsEnabled = $false + $menu.MaxHeight = 72 + & $drivers.InvokeAutomation $kit.Context.Shell.MoreButton + Wait-SnipTestDispatcherCondition -Description 'constrained More menu scrollbar' ` + -Priority Render -Condition { + if (-not $menu.IsOpen) { return $false } + $source = [System.Windows.PresentationSource]::FromVisual($menu) + $null -ne $source -and $null -ne (Find-VisualDescendant ` + -Root $source.RootVisual ` + -Type ([System.Windows.Controls.Primitives.ScrollBar])) + }.GetNewClosure() + + $implicitStyle = $menu.Resources[ + [System.Windows.Controls.Primitives.ScrollBar]] + if ($null -eq $implicitStyle) { + throw 'Root menu implicit scrollbar style is missing' + } + if (-not [object]::ReferenceEquals( + $implicitStyle.BasedOn,$kit.Resources['SnipMenuScrollBarStyle'])) { + throw 'Root menu scrollbar is not based on SnipMenuScrollBarStyle' + } + $source = [System.Windows.PresentationSource]::FromVisual($menu) + $scrollBar = Find-VisualDescendant -Root $source.RootVisual ` + -Type ([System.Windows.Controls.Primitives.ScrollBar]) + if (-not [object]::ReferenceEquals($scrollBar.Style,$implicitStyle)) { + throw 'Live root scrollbar did not resolve the implicit owner style' + } + Should-Be $scrollBar.Background $kit.Resources['SnipCanvasBrush'] + Should-Be $scrollBar.Foreground $kit.Resources['SnipHairlineBrush'] + + $highlightItem.ApplyTemplate() | Out-Null + $checkMark = $highlightItem.Template.FindName('CheckMark',$highlightItem) + Should-Be $checkMark.Visibility ([System.Windows.Visibility]::Visible) + Should-Be $checkMark.Foreground $highlightItem.Foreground + $checkedPeer = [System.Windows.Automation.Peers.MenuItemAutomationPeer]::new( + $highlightItem) + $toggle = $checkedPeer.GetPattern( + [System.Windows.Automation.Peers.PatternInterface]::Toggle) + if ($toggle -isnot [System.Windows.Automation.Provider.IToggleProvider]) { + throw 'Checked MenuItem does not expose ToggleProvider' + } + Should-Be $toggle.ToggleState ([System.Windows.Automation.ToggleState]::On) + + $colorParent = $kit.Context.Shell.MoreMenuItems.Color + & $drivers.ExpandMenuItem $colorParent + Wait-SnipTestDispatcherCondition -Description 'disabled Color menu item rendering' ` + -Priority Render -Condition { $colorParent.IsSubmenuOpen }.GetNewClosure() + Should-BeFalse ($redItem.Focus()) + Should-Be $redItem.Foreground $kit.Resources['SnipMutedTextBrush'] + Should-Be $redItem.Opacity 0.55 + $disabledPeer = [System.Windows.Automation.Peers.MenuItemAutomationPeer]::new( + $redItem) + Should-BeFalse $disabledPeer.IsEnabled() + Should-Be $disabledPeer.GetName() 'Red' + } finally { + $highlightItem.IsChecked = $false + $highlightItem.IsCheckable = $false + $redItem.IsEnabled = $true + $menu.MaxHeight = $savedMaxHeight + & $kit.CommandRouter.CloseTransientMenus + } + } + } + + It 'keeps every Narrow secondary tool reachable through a native More menu' { + Invoke-SnipFreshPreviewFixture -TestAction { + param($kit,$drivers) + & $kit.SetResponsiveMode 760 540 + $moreButton = $kit.Context.Shell.MoreButton + $moreMenu = $kit.Context.Shell.MoreMenu + Should-BeTrue ($moreMenu -is [System.Windows.Controls.ContextMenu]) + $visibleHeaders = @($moreMenu.Items | Where-Object Visibility -eq Visible | + ForEach-Object Header) + Should-Be ($visibleHeaders -join ',') ` + 'Highlight,Arrow/Line,Rectangle/Ellipse,Steps,Color' + & $drivers.InvokeAutomation $moreButton + Wait-SnipTestDispatcherCondition -Description 'Narrow More menu registration' ` + -Condition { + $kit.Context.Shell.MoreMenuState.IsExpanded -and + $kit.Context.Shell.MoreMenuState.IsMenuWindowRegistered + }.GetNewClosure() + Should-Be ([System.Windows.Automation.AutomationProperties]::GetItemStatus( + $moreButton)) 'Expanded' + & $kit.CommandRouter.CloseTransientMenus + Wait-SnipTestDispatcherCondition -Description 'Narrow More menu cleanup' ` + -Condition { + -not $kit.Context.Shell.MoreMenuState.IsExpanded -and + -not $kit.Context.Shell.MoreMenuState.IsMenuWindowRegistered + }.GetNewClosure() + Should-Be ([System.Windows.Automation.AutomationProperties]::GetItemStatus( + $moreButton)) 'Collapsed' + } + } + + It 'keeps all annotation colors reachable from the Color tool' { + Invoke-SnipFreshPreviewFixture -TestAction { + param($kit,$drivers) + $colorButton = $kit.Context.ToolControls.Color + $menu = $colorButton.ContextMenu + Should-BeTrue ($menu -is [System.Windows.Controls.ContextMenu]) + Should-Be (($menu.Items | ForEach-Object Header) -join ',') ` + 'Yellow,Green,Pink,Blue,Orange,Red' + & $drivers.InvokeAutomation $colorButton + Wait-SnipTestDispatcherCondition -Description 'Color tool menu opening' ` + -Condition { $menu.IsOpen }.GetNewClosure() + & $drivers.InvokeAutomation $menu.Items[-1] + Wait-SnipTestDispatcherCondition -Description 'Color menu selection and closure' ` + -Condition { $kit.State.ActiveColor -eq 'Red' -and -not $menu.IsOpen }.GetNewClosure() + } + } + + It 'tracks and capture-closes the actual annotation right-click popup idempotently' { + Invoke-SnipFreshPreviewFixture -TestAction { + param($kit,$drivers) + $hidden = $null + try { + $kit.State.Annotations.Clear() + $kit.State.Annotations.Add((New-SnipAnnotation -Kind Highlight ` + -Geometry ([pscustomobject]@{ + Type='Bounds'; X=0; Y=0 + Width=$kit.Bitmap.Width; Height=$kit.Bitmap.Height + }) -Color Yellow -StrokeWidth 4 -Opacity 0.45 ` + -Properties ([ordered]@{}) -Z 0)) | Out-Null + & $kit.Render + $rightClick = New-RoutedMouseRightClick -Target $kit.HighlightLayer ` + -Position ([System.Windows.Point]::new(40,40)) + $kit.HighlightLayer.RaiseEvent($rightClick) + Wait-SnipTestDispatcherCondition -Description 'annotation right-click popup registration' ` + -Condition { + if ($null -eq $kit.Context.PSObject.Properties[ + 'AnnotationMenuControl']) { return $false } + $control = $kit.Context.AnnotationMenuControl + $null -ne $control -and $control.Menu.IsOpen -and + $kit.Context.TransientMenus.Contains($control) -and + $control.State.MenuHandle -ne [IntPtr]::Zero -and + $script:SelfWindowHandles.Contains($control.State.MenuHandle) + }.GetNewClosure() + Should-BeTrue $rightClick.Handled + $control = $kit.Context.AnnotationMenuControl + $annotationHandle = $control.State.MenuHandle + $hidden = Hide-OwnSnipITWindowsForCapture + Wait-SnipTestDispatcherCondition -Description 'annotation popup capture cleanup' ` + -Condition { + -not $control.Menu.IsOpen -and + -not $control.State.IsMenuWindowRegistered -and + -not $script:SelfWindowHandles.Contains($annotationHandle) + }.GetNewClosure() + & $kit.CommandRouter.CloseTransientMenus + Should-BeFalse $control.Menu.IsOpen + Should-BeFalse $control.State.IsMenuWindowRegistered + } finally { + if ($null -ne $hidden) { + Show-OwnSnipITWindowsForCapture -Handles $hidden + } + $kit.State.Annotations.Clear() + & $kit.Render + } + } + } + + It 'opens Narrow More Color by keyboard and returns focus after choosing a color' { + Invoke-SnipFreshPreviewFixture -TestAction { + param($kit,$drivers) + & $kit.SetResponsiveMode 760 540 + $moreButton = $kit.Context.Shell.MoreButton + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::Alt + } + $moreButton.Focus() | Out-Null + $openMore = New-RoutedKeyEvent -Target $moreButton -Key Down + $moreButton.RaiseEvent($openMore) + Wait-SnipTestDispatcherCondition -Description 'keyboard-opened Narrow More menu' ` + -Condition { + $kit.Context.Shell.MoreMenuState.IsExpanded -and + [System.Windows.Input.Keyboard]::FocusedElement -is + [System.Windows.Controls.MenuItem] + }.GetNewClosure() + Should-BeTrue $openMore.Handled + + $colorParent = $kit.Context.Shell.MoreMenuItems.Color + Should-Be $colorParent.Items.Count 6 + Should-Be (($colorParent.Items | ForEach-Object Header) -join ',') ` + 'Yellow,Green,Pink,Blue,Orange,Red' + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::None + } + $rootOrigin = [System.Windows.Input.Keyboard]::FocusedElement + $resolveBeforeEnd = $kit.Context.CommandRouter.ResolveCount + $endRoot = & $drivers.RaiseControlKey $rootOrigin ` + ([System.Windows.Input.Key]::End) + Wait-SnipTestDispatcherCondition -Description 'native focus on More Color item' ` + -Condition { + [object]::ReferenceEquals( + [System.Windows.Input.Keyboard]::FocusedElement,$colorParent) + }.GetNewClosure() + Should-Be $kit.Context.CommandRouter.ResolveCount ($resolveBeforeEnd + 1) + Should-Be $kit.Context.CommandRouter.LastCommand 'PopupNavigation' + Should-BeFalse $endRoot.Handled + + $resolveBeforeColors = $kit.Context.CommandRouter.ResolveCount + $openColors = & $drivers.RaiseControlKey $colorParent ` + ([System.Windows.Input.Key]::Right) + Wait-SnipTestDispatcherCondition -Description 'native More Color submenu navigation' ` + -Priority Render -Condition { + $colorParent.IsSubmenuOpen -and + [System.Windows.Input.Keyboard]::FocusedElement -is + [System.Windows.Controls.MenuItem] -and + $colorParent.Items.Contains( + [System.Windows.Input.Keyboard]::FocusedElement) + }.GetNewClosure() + Should-Be $kit.Context.CommandRouter.ResolveCount ($resolveBeforeColors + 1) + Should-Be $kit.Context.CommandRouter.LastCommand 'PopupNavigation' + Should-BeFalse $openColors.Handled + + $redItem = $colorParent.Items[-1] + $submenuOrigin = [System.Windows.Input.Keyboard]::FocusedElement + $resolveBeforeRedFocus = $kit.Context.CommandRouter.ResolveCount + $endColors = & $drivers.RaiseControlKey $submenuOrigin ` + ([System.Windows.Input.Key]::End) + Wait-SnipTestDispatcherCondition -Description 'native focus on Red menu item' ` + -Condition { + [object]::ReferenceEquals( + [System.Windows.Input.Keyboard]::FocusedElement,$redItem) + }.GetNewClosure() + Should-Be $kit.Context.CommandRouter.ResolveCount ($resolveBeforeRedFocus + 1) + Should-BeFalse $endColors.Handled + + $resolveBeforeRed = $kit.Context.CommandRouter.ResolveCount + $chooseRed = & $drivers.RaiseControlKey $redItem ` + ([System.Windows.Input.Key]::Enter) + Wait-SnipTestDispatcherCondition -Description 'Red selection closure and opener focus' ` + -Condition { + $kit.State.ActiveColor -eq 'Red' -and + -not $kit.Context.Shell.MoreMenuState.IsExpanded -and + $moreButton.IsKeyboardFocused + }.GetNewClosure() + Should-Be $kit.Context.CommandRouter.ResolveCount ($resolveBeforeRed + 1) + Should-Be $kit.Context.CommandRouter.LastCommand 'PopupNavigation' + Should-BeFalse $chooseRed.Handled + } + } + + It 'preserves ordered Arrow and Line properties through More Properties overflow' { + Invoke-SnipFreshPreviewFixture -TestAction { + param($kit,$drivers) + & $kit.SetPropertyIsland 'ArrowLine' + & $kit.SetResponsiveMode 1200 700 + Should-Be (($kit.PropertyState.Visible) -join ',') ` + 'Color,Width,Opacity,StartStyle,EndStyle' + Should-Be (($kit.PropertyState.Overflow) -join ',') '' + & $kit.SetResponsiveMode 760 540 + Should-Be (($kit.PropertyState.Visible) -join ',') 'Color,Width,MoreProperties' + Should-Be (($kit.PropertyState.Overflow) -join ',') 'Opacity,StartStyle,EndStyle' + Should-Be $kit.PropertyState.HorizontalScrollBarVisibility ` + ([System.Windows.Controls.ScrollBarVisibility]::Disabled) + $moreProperties = @($kit.Context.Shell.PropertyPanel.Children | + Where-Object Content -eq 'More properties')[0] + Should-BeTrue ($moreProperties.ContextMenu -is + [System.Windows.Controls.ContextMenu]) + Should-Be (($moreProperties.ContextMenu.Items | + ForEach-Object Header) -join ',') 'Opacity,Start,End' + } + } + + It 'disconnects replaced property menus across repeated responsive rebuilds' { + Invoke-SnipFreshPreviewFixture -TestAction { + param($kit,$drivers) + $replaced = [System.Collections.ArrayList]::new() + $failures = [System.Collections.ArrayList]::new() + try { + & $kit.SetResponsiveMode 1200 700 + & $kit.SetPropertyIsland 'Select' + $baselineCount = $kit.Context.TransientMenus.Count + + foreach ($case in @( + [pscustomobject]@{ Width=760; Height=540; Tool='ArrowLine' }, + [pscustomobject]@{ Width=900; Height=600; Tool='RectangleEllipse' }, + [pscustomobject]@{ Width=1200; Height=700; Tool='Steps' }, + [pscustomobject]@{ Width=760; Height=540; Tool='BlurPixelate' }, + [pscustomobject]@{ Width=900; Height=600; Tool='Text' }, + [pscustomobject]@{ Width=1200; Height=700; Tool='Highlight' })) { + $beforeResponsive = $kit.Context.PropertyMenuControl + & $kit.SetResponsiveMode $case.Width $case.Height + if ($null -ne $beforeResponsive) { + $replaced.Add($beforeResponsive) | Out-Null + } + $expectedCount = $baselineCount + + [int]($null -ne $kit.Context.PropertyMenuControl) + Should-Be $kit.Context.TransientMenus.Count $expectedCount + + $beforeProperty = $kit.Context.PropertyMenuControl + & $kit.SetPropertyIsland $case.Tool + if ($null -ne $beforeProperty) { + $replaced.Add($beforeProperty) | Out-Null + } + $expectedCount = $baselineCount + + [int]($null -ne $kit.Context.PropertyMenuControl) + Should-Be $kit.Context.TransientMenus.Count $expectedCount + Should-Be @($kit.Context.TransientMenus | + Where-Object Name -eq 'MoreProperties').Count ` + ([int]($null -ne $kit.Context.PropertyMenuControl)) + } + + Should-BeTrue ($replaced.Count -ge 6) + for ($index = 0; $index -lt $replaced.Count; $index++) { + $old = $replaced[$index] + if ($old.State.HandlersAttached) { + $failures.Add( + "replacement $index retained controller handlers") | Out-Null + } + if ($null -ne $old.Menu.Tag -and $old.Menu.Tag.HandlersAttached) { + $failures.Add( + "replacement $index retained menu lifecycle handlers") | Out-Null + } + + $old.Button.RaiseEvent([System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) + Wait-SnipTestDispatcherCondition ` + -Description "detached replacement $index button route" ` + -Condition { -not $old.Menu.IsOpen }.GetNewClosure() + if ($old.Menu.IsOpen) { + $failures.Add( + "replacement $index retained its button Click handler") | Out-Null + } + & $old.CloseOptions + + & $old.OpenOptions + Wait-SnipTestDispatcherCondition ` + -Description "detached replacement $index open route" ` + -Condition { -not $old.State.IsExpanded }.GetNewClosure() + if ($old.State.IsExpanded) { + $failures.Add( + "replacement $index retained its menu Opened handler") | Out-Null + } + & $old.CloseOptions + } + + Should-Be $kit.Context.TransientMenus.Count $baselineCount + if ($failures.Count -gt 0) { + throw ($failures -join [Environment]::NewLine) + } + } finally { + foreach ($old in $replaced) { + try { & $old.CloseOptions } catch {} + } + & $kit.SetResponsiveMode 1200 700 + & $kit.SetPropertyIsland 'Select' + } + } + } +} + +# ---- Build synthetic bitmap ---- +$bmp = New-Object System.Drawing.Bitmap 1200, 800 +$g = [System.Drawing.Graphics]::FromImage($bmp) +$g.Clear([System.Drawing.Color]::SlateBlue) +$g.FillRectangle([System.Drawing.Brushes]::Orange, 100, 100, 400, 300) +$g.FillRectangle([System.Drawing.Brushes]::Lime, 700, 500, 300, 200) +$g.Dispose() + +# --- Kick off tests via -TestAction ---- +# The test body runs inside Show-PreviewWindow's scope (during Loaded, +# while ShowDialog is blocking) so the event handlers can find all the +# function-local variables they reference. +$null = Show-PreviewWindow -Bitmap $bmp -TestAction { + param($kit) + + $raiseClick = { + param([System.Windows.Controls.Primitives.ButtonBase]$Button) + $Button.RaiseEvent([System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) + }.GetNewClosure() + $raiseMenuClick = { + param([System.Windows.Controls.MenuItem]$Item) + $Item.RaiseEvent([System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.MenuItem]::ClickEvent)) + }.GetNewClosure() + $raiseCanvasDown = { + param([System.Windows.Point]$Point) + $kit.HighlightLayer.RaiseEvent((New-RoutedMouseLeftEvent ` + -Target $kit.HighlightLayer -Position $Point ` + -RoutedEvent ([System.Windows.UIElement]::MouseLeftButtonDownEvent))) + }.GetNewClosure() + $raiseCanvasMove = { + param([System.Windows.Point]$Point) + $kit.HighlightLayer.RaiseEvent((New-RoutedMouseMoveEvent ` + -Target $kit.HighlightLayer -Position $Point)) + }.GetNewClosure() + $raiseCanvasUp = { + param([System.Windows.Point]$Point) + $kit.HighlightLayer.RaiseEvent((New-RoutedMouseLeftEvent ` + -Target $kit.HighlightLayer -Position $Point ` + -RoutedEvent ([System.Windows.UIElement]::MouseLeftButtonUpEvent))) + }.GetNewClosure() + $raiseCanvasClick = { + param([System.Windows.Point]$Point) + & $raiseCanvasDown $Point + & $raiseCanvasUp $Point + }.GetNewClosure() + $raiseCanvasDrag = { + param( + [System.Windows.Point]$Start, + [System.Windows.Point[]]$Moves, + [System.Windows.Point]$End + ) + & $raiseCanvasDown $Start + foreach ($point in @($Moves)) { & $raiseCanvasMove $point } + & $raiseCanvasUp $End + }.GetNewClosure() + $raiseCanvasKey = { + param([System.Windows.Input.Key]$Key) + $kit.HighlightLayer.Focus() | Out-Null + $eventArgs = New-RoutedKeyEvent -Target $kit.HighlightLayer -Key $Key + $kit.HighlightLayer.RaiseEvent($eventArgs) + $eventArgs + }.GetNewClosure() + $raiseControlKey = { + param( + [System.Windows.IInputElement]$Target, + [System.Windows.Input.Key]$Key + ) + $preview = New-RoutedKeyEvent -Target $Target -Key $Key + $Target.RaiseEvent($preview) + if ($preview.Handled) { return $preview } + $bubble = New-RoutedKeyEvent -Target $Target -Key $Key ` + -RoutedEvent ([System.Windows.Input.Keyboard]::KeyDownEvent) + $Target.RaiseEvent($bubble) + $bubble + }.GetNewClosure() + $activateTool = { + param([string]$Tool) + switch ($Tool) { + 'RectangleEllipse' { & $raiseClick $kit.SplitControls.RectangleEllipse.PrimaryButton } + 'ArrowLine' { & $raiseClick $kit.SplitControls.ArrowLine.PrimaryButton } + default { + $control = $kit.Context.ToolControls[$Tool] + if ($control -is [System.Windows.Controls.Primitives.ToggleButton]) { + $peer = [System.Windows.Automation.Peers.ToggleButtonAutomationPeer]::new( + $control) + $provider = $peer.GetPattern( + [System.Windows.Automation.Peers.PatternInterface]::Toggle) + ([System.Windows.Automation.Provider.IToggleProvider]$provider).Toggle() + } else { + & $raiseClick $control + } + } + } + }.GetNewClosure() + $drawRectangle = { + param([System.Windows.Point]$Start,[System.Windows.Point]$End) + & $activateTool 'RectangleEllipse' + & $raiseCanvasDrag $Start @($End) $End + }.GetNewClosure() + $clearCanvasContent = { + & $kit.SetZoom 1.0 + $kit.Scroller.ScrollToHorizontalOffset(0) + $kit.Scroller.ScrollToVerticalOffset(0) + if ($kit.State.Annotations.Count -gt 0) { + & $raiseClick ($kit.Win.FindName('ClearBtn')) + } + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(1100,740)) + }.GetNewClosure() + $invokePropertyEntry = { + param($Entry) + if ($Entry.Element -is [System.Windows.Controls.MenuItem]) { + & $raiseMenuClick $Entry.Element + } else { + & $raiseClick $Entry.Element + } + }.GetNewClosure() + $invokeAutomation = { + param([System.Windows.UIElement]$Element) + $peer = if ($Element -is [System.Windows.Controls.MenuItem]) { + [System.Windows.Automation.Peers.MenuItemAutomationPeer]::new($Element) + } else { + [System.Windows.Automation.Peers.ButtonAutomationPeer]::new($Element) + } + $provider = $peer.GetPattern( + [System.Windows.Automation.Peers.PatternInterface]::Invoke) + if ($provider -isnot [System.Windows.Automation.Provider.IInvokeProvider]) { + throw "$(($Element.GetType()).Name) does not expose InvokeProvider" + } + ([System.Windows.Automation.Provider.IInvokeProvider]$provider).Invoke() + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + }.GetNewClosure() + + $assertFloatingStudioLayout = { + param( + [double]$Width, + [double]$Height, + [string]$ExpectedMode, + [string[]]$ExpectedTools + ) + + & $kit.SetResponsiveMode $Width $Height + Should-Be $kit.ResponsiveMode.Value $ExpectedMode + Should-Be (($kit.ToolOrder) -join ',') ($ExpectedTools -join ',') + + $bounds = & $kit.GetIslandBounds $Width $Height + $islandNames = @('Brand','Actions','Property','ToolDock','Viewport','Status') + foreach ($name in $islandNames) { + Should-BeTrue $bounds.ContainsKey($name) + if ($name -ne 'Property' -or + $kit.PropertyIsland.Visibility -eq [System.Windows.Visibility]::Visible) { + Should-BeTrue ($bounds[$name].Width -gt 0) + Should-BeTrue ($bounds[$name].Height -gt 0) + } + } + for ($leftIndex = 0; $leftIndex -lt $islandNames.Count; $leftIndex++) { + for ($rightIndex = $leftIndex + 1; $rightIndex -lt $islandNames.Count; $rightIndex++) { + $left = $bounds[$islandNames[$leftIndex]] + $right = $bounds[$islandNames[$rightIndex]] + $intersection = [System.Windows.Rect]::Intersect($left, $right) + if (-not $intersection.IsEmpty -and + $intersection.Width -gt 0 -and $intersection.Height -gt 0) { + throw "Floating Studio islands overlap at ${Width}x${Height}: $($islandNames[$leftIndex]) and $($islandNames[$rightIndex])" + } + } + } + }.GetNewClosure() + + Describe 'Floating Studio preview shell' { + It 'exposes the complete Preview construction and test contract' { + foreach ($key in @( + 'Context','Resources','ResponsiveMode','CommandRouter','StudioRoot', + 'Scroller','ImageHost','PreviewImage','HighlightLayer','BrandIsland', + 'ActionsIsland','PropertyIsland','ToolDock','ViewportIsland','StatusIsland', + 'ActionOrder','SplitControls','SetResponsiveMode','SetPropertyIsland', + 'GetIslandBounds','ResizeHitTest','ToggleMaximize','BeginWindowDrag', + 'ShowSystemMenu')) { + Should-BeTrue $kit.ContainsKey($key) + Should-BeTrue ($null -ne $kit[$key]) + } + Should-Be $kit.Context.Window $kit.Win + Should-Be $kit.Context.Bitmap $kit.Bitmap + Should-Be $kit.Context.Resources $kit.Resources + } + + It 'uses one full-window StudioRoot with the canvas-first layer order' { + Should-Be $kit.StudioRoot.Name 'StudioRoot' + Should-Be $kit.StudioRoot.Background $kit.Resources['SnipPageBrush'] + Should-Be $kit.Scroller.Name 'Scroller' + Should-Be $kit.ImageHost.Name 'ImageHost' + Should-Be $kit.PreviewImage.Name 'PreviewImage' + Should-Be $kit.HighlightLayer.Name 'HighlightLayer' + Should-Be $kit.Scroller.Parent $kit.StudioRoot + Should-Be $kit.Scroller.Content $kit.ImageHost + Should-Be $kit.PreviewImage.Parent $kit.ImageHost + Should-Be $kit.HighlightLayer.Parent $kit.ImageHost + } + + 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 + Should-Be $kit.PropertyIsland.Height 54 + Should-Be $kit.ToolDock.Height 51 + } + + It 'orders the top actions correctly and keeps Copy and close primary' { + Should-Be (($kit.ActionOrder) -join ',') 'Pin,Save,CopyAndClose,Close' + Should-Be $kit.SplitControls.Copy.Name 'CopyAndClose' + Should-Be $kit.SplitControls.Copy.DefaultCommand 'CopyAndClose' + Should-Be (($kit.SplitControls.Copy.OptionOrder) -join ',') 'CopyKeepOpen' + } + + It 'keeps stock scrollbars out of the canvas and uses the locked primary visual language' { + Should-Be $kit.Scroller.HorizontalScrollBarVisibility ` + ([System.Windows.Controls.ScrollBarVisibility]::Hidden) + Should-Be $kit.Scroller.VerticalScrollBarVisibility ` + ([System.Windows.Controls.ScrollBarVisibility]::Hidden) + Should-Be $kit.SplitControls.Copy.PrimaryButton.Background ` + $kit.Resources['SnipAccentTintBrush'] + Should-Be $kit.SplitControls.Copy.PrimaryButton.BorderBrush ` + $kit.Resources['SnipAccentBrush'] + Should-Be $kit.Context.ToolControls.Select.Background ` + $kit.Resources['SnipAccentTintBrush'] + Should-Be $kit.Context.ToolControls.Select.BorderBrush ` + $kit.Resources['SnipAccentBrush'] + Should-Be $kit.Win.FindName('PinBtn').FontFamily.Source 'Segoe Fluent Icons' + Should-Be $kit.Win.FindName('CloseBtn').FontFamily.Source 'Segoe Fluent Icons' + } + + It 'renders an inner top highlight on every floating island' { + foreach ($name in 'Brand','Actions','Property','ToolDock','Viewport','Status') { + $highlight = $kit.Win.FindName("${name}InnerHighlight") + Should-BeTrue ($highlight -is [System.Windows.Controls.Border]) + Should-Be $highlight.Height 1 + Should-Be $highlight.Background $kit.Resources['SnipInnerHighlightBrush'] + Should-Be $highlight.VerticalAlignment ([System.Windows.VerticalAlignment]::Top) + } + } + + It 'publishes shortcuts on dynamic action and split tooltips' { + Should-Be $kit.Win.FindName('SaveBtn').ToolTip 'Save (Ctrl+S)' + Should-Be $kit.Win.FindName('CloseBtn').ToolTip 'Close preview (Alt+F4)' + Should-Be $kit.Win.FindName('UndoBtn').ToolTip 'Undo (Ctrl+Z)' + Should-Be $kit.Win.FindName('RedoBtn').ToolTip 'Redo (Ctrl+Shift+Z)' + Should-Be $kit.SplitControls.Copy.PrimaryButton.ToolTip 'Copy and close (Ctrl+Enter)' + Should-Be $kit.SplitControls.Copy.OptionsButton.ToolTip 'Copy options (Alt+Down)' + Should-Be $kit.SplitControls.ArrowLine.OptionsButton.ToolTip 'Arrow/Line options (Alt+Down)' + } + + It 'keeps Close neutral until hover or keyboard focus adds coral tint' { + $close = $kit.Win.FindName('CloseBtn') + Should-Be $close.Background ([System.Windows.Media.Brushes]::Transparent) + Should-BeTrue ($null -ne $kit.Resources['SnipCoralTintBrush']) + $close.Focus() | Out-Null + $close.UpdateLayout() + Should-Be $close.Background $kit.Resources['SnipCoralTintBrush'] + $kit.StudioRoot.Focus() | Out-Null + } + + It 'routes visible split tools into the preserved annotation engine' { + $kit.SplitControls.ArrowLine.PrimaryButton.RaiseEvent( + [System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) + Should-BeTrue $kit.ArrowBtn.IsChecked + Should-Be $kit.State.ActiveStudioTool 'ArrowLine' + Should-Be $kit.PropertyState.Tool 'ArrowLine' + + $kit.SplitControls.RectangleEllipse.PrimaryButton.RaiseEvent( + [System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) + Should-BeTrue $kit.RectBtn.IsChecked + Should-BeFalse $kit.ArrowBtn.IsChecked + Should-Be $kit.State.ActiveStudioTool 'RectangleEllipse' + Should-Be $kit.PropertyState.Tool 'RectangleEllipse' + $kit.RectBtn.IsChecked = $false + $kit.Context.RecentTools.Clear() + $kit.Context.RecentTools.Add('Highlight') | Out-Null + $kit.Context.RecentTools.Add('ArrowLine') | Out-Null + & $kit.SetStudioTool 'Select' + & $kit.SetPropertyIsland 'Select' + } + + It 'selects Wide at 1200 by 700 without island overlap' { + & $assertFloatingStudioLayout 1200 700 'Wide' @( + 'Select','Crop','Pen','Highlight','ArrowLine','RectangleEllipse', + 'Text','Steps','BlurPixelate','Color','Undo','Redo') + } + + It 'selects Compact at 1199 by 699 without island overlap' { + & $assertFloatingStudioLayout 1199 699 'Compact' @( + 'Select','Crop','Pen','Highlight','ArrowLine','Text','BlurPixelate', + 'Color','More','Undo','Redo') + } + + It 'selects Compact at 900 by 600 without island overlap' { + & $assertFloatingStudioLayout 900 600 'Compact' @( + 'Select','Crop','Pen','Highlight','ArrowLine','Text','BlurPixelate', + 'Color','More','Undo','Redo') + } + + It 'selects Narrow at 899 by 599 without island overlap' { + & $assertFloatingStudioLayout 899 599 'Narrow' @( + 'Select','Crop','Pen','Text','BlurPixelate','More','Undo','Redo') + } + + It 'selects Narrow at the 760 by 540 minimum without island overlap' { + & $assertFloatingStudioLayout 760 540 'Narrow' @( + 'Select','Crop','Pen','Text','BlurPixelate','More','Undo','Redo') + Should-Be $kit.Win.MinWidth 760 + Should-Be $kit.Win.MinHeight 540 + } + + It 'raises Narrow controls and substitutes an active hidden tool into More' { + $kit.Context.ToolControls.Steps.RaiseEvent( + [System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) + & $kit.SetResponsiveMode 760 540 + Should-Be $kit.ToolDock.Margin.Bottom 96 + Should-BeTrue $kit.MoreState.IsActive + Should-Be $kit.MoreState.Tool 'Steps' + Should-Be $kit.MoreState.Name 'Steps' + } + + It 'uses canonical tool metadata for overflow presentation' { + Should-Be $kit.Context.ToolMetadata.ArrowLine.DisplayName 'Arrow/Line' + Should-Be $kit.Context.ToolMetadata.RectangleEllipse.DisplayName 'Rectangle/Ellipse' + Should-Be $kit.Context.ToolMetadata.BlurPixelate.DisplayName 'Blur/Pixelate' + Should-BeTrue (-not [string]::IsNullOrWhiteSpace($kit.Context.ToolMetadata.ArrowLine.Icon)) + + & $kit.SetStudioTool 'Select' + & $kit.SetResponsiveMode 760 540 + $kit.Context.Shell.MoreMenuItems.ArrowLine.RaiseEvent( + [System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.MenuItem]::ClickEvent)) + Should-Be $kit.State.ActiveStudioTool 'ArrowLine' + Should-Be $kit.MoreState.Name 'Arrow/Line' + Should-Be $kit.MoreState.Icon $kit.Context.ToolMetadata.ArrowLine.Icon + Should-Be $kit.Context.Shell.MoreButton.BorderBrush $kit.Resources['SnipAccentBrush'] + Should-Be $kit.Context.Shell.MoreButton.Background $kit.Resources['SnipAccentTintBrush'] + $kit.Context.ToolControls.Select.RaiseEvent( + [System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) + } + + It 'measures every active hidden More label in Narrow and Compact layouts' { + $cases = foreach ($modeCase in @( + [pscustomobject]@{ Width=760; Height=540; Mode='Narrow' }, + [pscustomobject]@{ Width=900; Height=600; Mode='Compact' })) { + foreach ($toolName in @('Highlight','ArrowLine','RectangleEllipse','Steps')) { + [pscustomobject]@{ + Width=$modeCase.Width; Height=$modeCase.Height + Mode=$modeCase.Mode; Tool=$toolName + } + } + } + try { + foreach ($case in $cases) { + $kit.Win.Width = $case.Width + $kit.Win.Height = $case.Height + $kit.Context.RecentTools.Clear() + foreach ($other in @('Highlight','ArrowLine','RectangleEllipse','Steps') | + Where-Object { $_ -ne $case.Tool } | Select-Object -First 2) { + $kit.Context.RecentTools.Add($other) | Out-Null + } + & $kit.SetResponsiveMode $case.Width $case.Height + $kit.Context.Shell.MoreButton.RaiseEvent( + [System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) + $kit.Context.Shell.MoreMenuItems[$case.Tool].RaiseEvent( + [System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.MenuItem]::ClickEvent)) + + # Keep the just-selected tool genuinely hidden in Compact as well. + $kit.Context.RecentTools.Clear() + foreach ($other in @('Highlight','ArrowLine','RectangleEllipse','Steps') | + Where-Object { $_ -ne $case.Tool } | Select-Object -First 2) { + $kit.Context.RecentTools.Add($other) | Out-Null + } + & $kit.SetResponsiveMode $case.Width $case.Height + $kit.Win.UpdateLayout() + Should-Be $kit.ResponsiveMode.Value $case.Mode + if (-not $kit.MoreState.IsActive) { + throw "More $($case.Mode)/$($case.Tool) did not remain active-hidden" + } + Should-Be $kit.MoreState.Tool $case.Tool + Should-Be $kit.MoreState.Name $kit.Context.ToolMetadata[$case.Tool].DisplayName + Should-Be $kit.MoreState.Icon $kit.Context.ToolMetadata[$case.Tool].Icon + Should-Be $kit.Context.Shell.MoreIndicator.Width 12 + Should-Be $kit.Context.Shell.MoreIndicator.Height 2 + + $more = $kit.Context.Shell.MoreButton + $children = @($more.Content.Children) + $rectangles = @() + foreach ($child in $children) { + $origin = $child.TranslatePoint([System.Windows.Point]::new(0,0),$more) + $rect = [System.Windows.Rect]::new( + $origin.X,$origin.Y,$child.ActualWidth,$child.ActualHeight) + if ($rect.Left -lt 0 -or $rect.Top -lt 0 -or + $rect.Right -gt ($more.ActualWidth + 0.5) -or + $rect.Bottom -gt ($more.ActualHeight + 0.5)) { + throw "More $($case.Mode)/$($case.Tool) child $($child.Name) is outside $($more.ActualWidth)x$($more.ActualHeight): $rect" + } + foreach ($previous in $rectangles) { + $intersection = [System.Windows.Rect]::Intersect($previous,$rect) + if (-not $intersection.IsEmpty -and + $intersection.Width -gt 0 -and $intersection.Height -gt 0) { + throw "More $($case.Mode)/$($case.Tool) content overlaps: $intersection" + } + } + $rectangles += $rect + } + + $panel = $kit.Context.Shell.ToolPanel + $moreIndex = $panel.Children.IndexOf($more) + $moreOrigin = $more.TranslatePoint([System.Windows.Point]::new(0,0),$panel) + $moreRect = [System.Windows.Rect]::new( + $moreOrigin.X,$moreOrigin.Y,$more.ActualWidth,$more.ActualHeight) + foreach ($adjacentIndex in @(($moreIndex - 1),($moreIndex + 1))) { + if ($adjacentIndex -ge 0 -and $adjacentIndex -lt $panel.Children.Count) { + $adjacent = $panel.Children[$adjacentIndex] + $adjacentOrigin = $adjacent.TranslatePoint( + [System.Windows.Point]::new(0,0),$panel) + $adjacentRect = [System.Windows.Rect]::new( + $adjacentOrigin.X,$adjacentOrigin.Y, + $adjacent.ActualWidth,$adjacent.ActualHeight) + $intersection = [System.Windows.Rect]::Intersect($moreRect,$adjacentRect) + if (-not $intersection.IsEmpty -and + $intersection.Width -gt 0 -and $intersection.Height -gt 0) { + throw "More $($case.Mode)/$($case.Tool) overlaps adjacent tool: $intersection" + } + } + } + & $kit.CommandRouter.CloseTransientMenus + } + } finally { + & $kit.CommandRouter.CloseTransientMenus + $kit.HighlightBtn.IsChecked = $false + $kit.RectBtn.IsChecked = $false + $kit.ArrowBtn.IsChecked = $false + $kit.TextBtn.IsChecked = $false + & $kit.SetStudioTool 'Select' + $kit.Context.RecentTools.Clear() + $kit.Context.RecentTools.Add('Highlight') | Out-Null + $kit.Context.RecentTools.Add('ArrowLine') | Out-Null + & $kit.SetPropertyIsland 'Select' + & $kit.SetResponsiveMode 1200 700 + } + } + + It 'collapses Narrow idle status and expands it for real messages' { + & $kit.SetResponsiveMode 760 540 + & $kit.ClearStatus + Should-Be $kit.StatusIsland.Width 42 + Should-Be $kit.Context.StatusState.Kind 'Idle' + & $kit.SetStatus 'Saved to disk' 'Success' + Should-BeTrue ($kit.StatusIsland.Width -ge 150) + Should-Be $kit.Context.Shell.StatusText.Text 'Saved to disk' + Should-Be $kit.Context.StatusState.Kind 'Success' + & $kit.ClearStatus + Should-Be $kit.StatusIsland.Width 42 + Should-Be $kit.Context.StatusState.Kind 'Idle' + } + + It 'keeps actual Compact clipboard error and success status collision-safe and fully accessible' { + $savedClipboardSetter = $kit.Context.ClipboardSetter + $savedRetryDelay = $kit.Context.RetryDelay + try { + & $kit.SetResponsiveMode 900 600 + $kit.Context.ClipboardSetter = { param($image) throw 'clipboard busy' } + $kit.Context.RetryDelay = { param($milliseconds) } + $kit.SplitControls.Copy.PrimaryButton.RaiseEvent( + [System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent)) + $errorText = 'Clipboard is unavailable' + Should-Be $kit.Context.StatusState.Kind 'Error' + Should-Be $kit.Context.StatusState.Text $errorText + Should-Be $kit.StatusIsland.Height 42 + Should-Be $kit.StatusIsland.CornerRadius.TopLeft 16 + Should-Be $kit.Context.Shell.StatusText.Text $errorText + Should-Be $kit.Context.Shell.StatusText.TextTrimming ` + ([System.Windows.TextTrimming]::CharacterEllipsis) + Should-Be $kit.Context.Shell.StatusText.ToolTip $errorText + Should-Be ([System.Windows.Automation.AutomationProperties]::GetHelpText( + $kit.Context.Shell.StatusText)) $errorText + $bounds = & $kit.GetIslandBounds 900 600 + $intersection = [System.Windows.Rect]::Intersect( + $bounds.Status,$bounds.ToolDock) + Should-BeTrue ($intersection.IsEmpty -or + $intersection.Width -le 0 -or $intersection.Height -le 0) + + $kit.Context.ClipboardSetter = { param($image) } + $kit.SplitControls.Copy.MenuItems.CopyKeepOpen.RaiseEvent( + [System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.MenuItem]::ClickEvent)) + $successText = 'Copied to clipboard' + Should-Be $kit.Context.StatusState.Kind 'Success' + Should-Be $kit.Context.StatusState.Text $successText + Should-Be $kit.Context.Shell.StatusText.ToolTip $successText + Should-Be ([System.Windows.Automation.AutomationProperties]::GetHelpText( + $kit.Context.Shell.StatusText)) $successText + $bounds = & $kit.GetIslandBounds 900 600 + $intersection = [System.Windows.Rect]::Intersect( + $bounds.Status,$bounds.ToolDock) + Should-BeTrue ($intersection.IsEmpty -or + $intersection.Width -le 0 -or $intersection.Height -le 0) + + & $kit.SetResponsiveMode 1200 700 + Should-Be $kit.Context.Shell.StatusText.Text $successText + Should-Be $kit.Context.Shell.StatusText.TextTrimming ` + ([System.Windows.TextTrimming]::None) + & $kit.SetResponsiveMode 760 540 + Should-Be $kit.Context.Shell.StatusText.Text $successText + Should-Be $kit.Context.Shell.StatusText.ToolTip $successText + } finally { + $kit.Context.ClipboardSetter = $savedClipboardSetter + $kit.Context.RetryDelay = $savedRetryDelay + & $kit.ClearStatus + & $kit.SetResponsiveMode 1200 700 + } + } + + It 'collapses the property island when Select has no annotation selection' { + & $kit.SetStudioTool 'Select' + & $kit.SetPropertyIsland 'Select' + Should-Be $kit.PropertyIsland.Visibility ([System.Windows.Visibility]::Collapsed) + Should-Be (($kit.PropertyState.Visible) -join ',') '' + Should-Be (($kit.PropertyState.Overflow) -join ',') '' + } + + It 'uses attached snap-compatible WindowChrome and routed brand maximize restore' { + Should-BeFalse $kit.Win.AllowsTransparency + Should-Be $kit.Win.WindowStyle ([System.Windows.WindowStyle]::None) + Should-Be $kit.Win.ResizeMode ([System.Windows.ResizeMode]::CanResize) + $attachedChrome = [System.Windows.Shell.WindowChrome]::GetWindowChrome($kit.Win) + Should-BeTrue ([object]::ReferenceEquals($attachedChrome,$kit.Context.Chrome)) + Should-Be $attachedChrome.ResizeBorderThickness.Left 6 + Should-Be $attachedChrome.ResizeBorderThickness.Top 6 + Should-Be $attachedChrome.ResizeBorderThickness.Right 6 + Should-Be $attachedChrome.ResizeBorderThickness.Bottom 6 + $kit.Win.WindowState = [System.Windows.WindowState]::Normal + $brand = $kit.Win.FindName('DragHeader') + $brand.RaiseEvent((New-RoutedMouseDoubleClick -Target $brand)) + Should-Be $kit.Win.WindowState ([System.Windows.WindowState]::Maximized) + $brand.RaiseEvent((New-RoutedMouseDoubleClick -Target $brand)) + Should-Be $kit.Win.WindowState ([System.Windows.WindowState]::Normal) + } + + It 'places Preview inside the work area containing the capture center' { + $monitors = @( + [pscustomobject]@{ Id='Left'; X=-1920; Y=0; Width=1920; Height=1080; WorkX=-1920; WorkY=0; WorkWidth=1920; WorkHeight=1040; DpiX=96; DpiY=96 }, + [pscustomobject]@{ Id='Right'; X=0; Y=0; Width=2560; Height=1440; WorkX=0; WorkY=0; WorkWidth=2560; WorkHeight=1400; DpiX=144; DpiY=144 } + ) + $context = New-SnipPreviewContext -Bitmap $kit.Bitmap ` + -CaptureBounds ([pscustomobject]@{ X=200; Y=100; Width=900; Height=600 }) ` + -MonitorDescriptors $monitors -Resources $kit.Resources + Should-Be $context.CaptureMonitor.Id 'Right' + Should-BeTrue ($context.InitialBounds.X -ge 0) + Should-BeTrue ($context.InitialBounds.Y -ge 0) + Should-BeTrue (($context.InitialBounds.X + $context.InitialBounds.Width) -le 2560) + Should-BeTrue (($context.InitialBounds.Y + $context.InitialBounds.Height) -le 1400) + } + } + + Describe 'Task 7 routed Select contract' { + It 'publishes authoritative context aliases and the complete ordered layer stack' { + Should-BeTrue ([object]::ReferenceEquals( + $kit.Context.Annotations,$kit.State.Annotations)) + Should-BeTrue ([object]::ReferenceEquals( + $kit.Context.UndoStack,$kit.State.UndoStack)) + Should-BeTrue ([object]::ReferenceEquals( + $kit.Context.RedoStack,$kit.State.RedoStack)) + Should-BeTrue ([object]::ReferenceEquals( + $kit.Context.BitmapSource,$kit.PreviewImage.Source)) + Should-BeTrue $kit.Context.BitmapSource.IsFrozen + Should-Be (($kit.ImageHost.Children | ForEach-Object Name) -join ',') ` + 'PreviewImage,AnnotationLayer,InteractionLayer,SelectionLayer,HighlightLayer' + & $activateTool 'Crop' + Should-Be $kit.Context.ActiveTool 'Crop' + Should-Be $kit.State.ActiveStudioTool 'Crop' + & $activateTool 'Select' + Should-Be $kit.Context.ActiveTool 'Select' + Should-Be $kit.State.ActiveStudioTool 'Select' + } + + It 'render and zoom preserve canonical record identity while legacy normalization runs once' { + try { + & $clearCanvasContent + $record = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds'; X=75; Y=60; Width=125; Height=90 + }) -Color Blue -StrokeWidth 3 -Opacity 0.75 ` + -Properties ([ordered]@{ Fill=$false }) -Z 7 + $kit.Context.Annotations.Add($record) | Out-Null + $semanticBefore = $record | ConvertTo-Json -Depth 20 -Compress + + & $kit.Render + Should-BeTrue ([object]::ReferenceEquals( + $record,$kit.Context.Annotations[0])) + & $kit.SetZoom 1.25 + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Render) + Should-BeTrue ([object]::ReferenceEquals( + $record,$kit.Context.Annotations[0])) + Should-Be ($kit.Context.Annotations[0] | + ConvertTo-Json -Depth 20 -Compress) $semanticBefore + + $kit.Context.Annotations.Clear() + $legacyRecord = [pscustomobject]@{ + Type='highlight'; Color='Yellow'; X=4; Y=6; W=30; H=20 + Text=$null; FontSize=0 + } + $kit.Context.Annotations.Add($legacyRecord) | Out-Null + & $kit.Render + $normalized = $kit.Context.Annotations[0] + Should-BeFalse ([string]::IsNullOrWhiteSpace([string]$normalized.Id)) + Should-BeFalse ([object]::ReferenceEquals($legacyRecord,$normalized)) + & $kit.Render + Should-BeTrue ([object]::ReferenceEquals( + $normalized,$kit.Context.Annotations[0])) + Should-Be $kit.Context.Annotations[0].Id $normalized.Id + } finally { + & $clearCanvasContent + } + } + + It 'actual selection and render routes honor Z ahead of conflicting list order' { + try { + & $clearCanvasContent + $high = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds'; X=80; Y=80; Width=140; Height=100 + }) -Color Red -StrokeWidth 3 -Opacity 1 -Properties ([ordered]@{}) -Z 9 + $low = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds'; X=80; Y=80; Width=140; Height=100 + }) -Color Blue -StrokeWidth 3 -Opacity 1 -Properties ([ordered]@{}) -Z 1 + $kit.Context.Annotations.Add($high) | Out-Null + $kit.Context.Annotations.Add($low) | Out-Null + & $kit.Render + $renderedIds = @($kit.AnnotationLayer.Children | ForEach-Object Tag | + Where-Object Role -eq 'Annotation' | ForEach-Object Id) + Should-Be ($renderedIds -join ',') "$($low.Id),$($high.Id)" + + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(120,120)) + Should-Be $kit.Context.SelectedAnnotationId $high.Id + Should-Be $kit.Context.LastHitRoute 'Find-SnipAnnotation' + } finally { + & $clearCanvasContent + } + } + + It 'WPF and flattened output share ascending Z projection without mutating sources or records' { + $flat = $null + try { + & $clearCanvasContent + $high = New-SnipAnnotation -Kind Highlight -Geometry ([pscustomobject]@{ + Type='Bounds'; X=90; Y=90; Width=150; Height=120 + }) -Color Blue -StrokeWidth 1.5 -Opacity 1 ` + -Properties ([ordered]@{}) -Z 20 + $low = New-SnipAnnotation -Kind Highlight -Geometry ([pscustomobject]@{ + Type='Bounds'; X=90; Y=90; Width=150; Height=120 + }) -Color Red -StrokeWidth 1.5 -Opacity 1 ` + -Properties ([ordered]@{}) -Z 2 + $kit.Context.Annotations.Add($high) | Out-Null + $kit.Context.Annotations.Add($low) | Out-Null + $highBefore = $high | ConvertTo-Json -Depth 20 -Compress + $lowBefore = $low | ConvertTo-Json -Depth 20 -Compress + $sourcePixel = $kit.Bitmap.GetPixel(150,150).ToArgb() + + & $kit.Render + $wpfIds = @($kit.AnnotationLayer.Children | ForEach-Object Tag | + Where-Object Role -eq 'Annotation' | ForEach-Object Id) + Should-Be ($wpfIds -join ',') "$($low.Id),$($high.Id)" + + $flat = & $kit.Flatten + Should-BeFalse ([object]::ReferenceEquals($flat,$kit.Bitmap)) + $flattenedPixel = $flat.GetPixel(150,150) + Should-Be $flattenedPixel.ToArgb() ` + ([System.Drawing.Color]::FromArgb(255,80,170,255).ToArgb()) + Should-Be $kit.Bitmap.GetPixel(150,150).ToArgb() $sourcePixel + Should-BeTrue ([object]::ReferenceEquals( + $high,$kit.Context.Annotations[0])) + Should-BeTrue ([object]::ReferenceEquals( + $low,$kit.Context.Annotations[1])) + Should-Be ($high | ConvertTo-Json -Depth 20 -Compress) $highBefore + Should-Be ($low | ConvertTo-Json -Depth 20 -Compress) $lowBefore + } finally { + if ($null -ne $flat -and + -not [object]::ReferenceEquals($flat,$kit.Bitmap)) { + $flat.Dispose() + $disposed = $false + try { $flat.GetPixel(0,0) | Out-Null } catch { $disposed = $true } + Should-BeTrue $disposed + } + & $clearCanvasContent + } + } + + It 'Wide Duplicate automation creates one selected top copy and preserves undo redo identity' { + try { + & $clearCanvasContent + $kit.Win.Width=1240; $kit.Win.Height=760 + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Render) + & $kit.SetResponsiveMode 1240 760 + & $drawRectangle ([System.Windows.Point]::new(100,100)) ` + ([System.Windows.Point]::new(220,180)) + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(150,140)) + $source = @($kit.Context.Annotations)[0] + $sourceBefore = $source | ConvertTo-Json -Depth 20 -Compress + $kit.Context.RedoStack.Push((New-SnipEditorSnapshot ` + -Annotations $kit.Context.Annotations ` + -CropRectangle $kit.Context.CropRectangle)) + $historyBefore = $kit.Context.UndoStack.Count + + $duplicateButton = $kit.Context.PropertyControls.Duplicate.Element + Should-BeTrue ($duplicateButton -is [System.Windows.Controls.Button]) + & $invokeAutomation $duplicateButton + + Should-Be $kit.Context.Annotations.Count 2 + Should-Be $kit.Context.UndoStack.Count ($historyBefore + 1) + Should-Be $kit.Context.RedoStack.Count 0 + Should-BeTrue ([object]::ReferenceEquals( + $source,$kit.Context.Annotations[0])) + Should-Be ($source | ConvertTo-Json -Depth 20 -Compress) $sourceBefore + $duplicate = $kit.Context.Annotations[1] + Should-BeFalse ($duplicate.Id -eq $source.Id) + Should-Be $kit.Context.SelectedAnnotationId $duplicate.Id + Should-Be $duplicate.Kind $source.Kind + Should-Be $duplicate.Color $source.Color + Should-Be $duplicate.StrokeWidth $source.StrokeWidth + Should-Be $duplicate.Opacity $source.Opacity + Should-Be ($duplicate.Properties | ConvertTo-Json -Depth 20 -Compress) ` + ($source.Properties | ConvertTo-Json -Depth 20 -Compress) + $expected = Move-SnipAnnotation -Annotation $source ` + -DeltaX 12 -DeltaY 12 -SourceWidth $kit.Bitmap.Width ` + -SourceHeight $kit.Bitmap.Height + Should-Be ($duplicate.Geometry | ConvertTo-Json -Depth 20 -Compress) ` + ($expected.Geometry | ConvertTo-Json -Depth 20 -Compress) + Should-Be $duplicate.Z ([double]$source.Z + 1.0) + $renderedIds = @($kit.AnnotationLayer.Children | ForEach-Object Tag | + Where-Object Role -eq 'Annotation' | ForEach-Object Id) + Should-Be $renderedIds[-1] $duplicate.Id + + $duplicateId = $duplicate.Id + & $raiseClick ($kit.Win.FindName('UndoBtn')) + Should-Be (@($kit.Context.Annotations | Where-Object Id -eq $duplicateId)).Count 0 + Should-Be (@($kit.Context.Annotations | Where-Object Id -eq $source.Id)).Count 1 + & $raiseClick ($kit.Win.FindName('RedoBtn')) + Should-Be (@($kit.Context.Annotations | Where-Object Id -eq $duplicateId)).Count 1 + Should-Be (@($kit.Context.Annotations | Where-Object Id -eq $source.Id)).Count 1 + } finally { + $kit.Win.Width=1180; $kit.Win.Height=760 + & $kit.SetResponsiveMode 1200 700 + & $clearCanvasContent + } + } + + foreach ($duplicateMode in @( + [pscustomobject]@{ Name='Compact'; Width=1000; Height=650 }, + [pscustomobject]@{ Name='Narrow'; Width=760; Height=540 } + )) { + It "$($duplicateMode.Name) overflow Duplicate automation clamps, edits, and deletes the new record" { + try { + & $clearCanvasContent + $kit.Win.Width=$duplicateMode.Width + $kit.Win.Height=$duplicateMode.Height + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Render) + & $kit.SetResponsiveMode $duplicateMode.Width $duplicateMode.Height + & $drawRectangle ([System.Windows.Point]::new(1120,730)) ` + ([System.Windows.Point]::new(1195,795)) + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(1150,760)) + & $kit.SetResponsiveMode $duplicateMode.Width $duplicateMode.Height + $source = @($kit.Context.Annotations)[0] + $sourceBefore = $source | ConvertTo-Json -Depth 20 -Compress + $kit.Context.RedoStack.Push((New-SnipEditorSnapshot ` + -Annotations $kit.Context.Annotations ` + -CropRectangle $kit.Context.CropRectangle)) + $historyBefore = $kit.Context.UndoStack.Count + $propertyMenu = $kit.Context.PropertyMenuControl + $oldLifecycle = $propertyMenu.Menu.Tag + + & $invokeAutomation $kit.Context.PropertyControls.MoreProperties.Element + Should-BeTrue $propertyMenu.State.IsExpanded + $duplicateItem = $kit.Context.PropertyControls.Duplicate.Element + Should-BeTrue ($duplicateItem -is [System.Windows.Controls.MenuItem]) + & $invokeAutomation $duplicateItem + + Should-BeFalse $oldLifecycle.PopupKeyRouteAttached + Should-Be $kit.Context.Annotations.Count 2 + Should-Be $kit.Context.UndoStack.Count ($historyBefore + 1) + Should-Be $kit.Context.RedoStack.Count 0 + Should-BeTrue ([object]::ReferenceEquals( + $source,$kit.Context.Annotations[0])) + Should-Be ($source | ConvertTo-Json -Depth 20 -Compress) $sourceBefore + $duplicate = $kit.Context.Annotations[1] + $duplicateId = $duplicate.Id + Should-BeFalse ($duplicateId -eq $source.Id) + Should-Be $kit.Context.SelectedAnnotationId $duplicateId + $expected = Move-SnipAnnotation -Annotation $source ` + -DeltaX 12 -DeltaY 12 -SourceWidth $kit.Bitmap.Width ` + -SourceHeight $kit.Bitmap.Height + Should-Be ($duplicate.Geometry | ConvertTo-Json -Depth 20 -Compress) ` + ($expected.Geometry | ConvertTo-Json -Depth 20 -Compress) + Should-Be $duplicate.Z ([double]$source.Z + 1.0) + + $position = $kit.Context.PropertyControls.Position.Element + $position.Text='100, 110' + $positionEnter = New-RoutedKeyEvent -Target $position -Key Enter ` + -RoutedEvent ([System.Windows.Input.Keyboard]::KeyDownEvent) + $position.RaiseEvent($positionEnter) + Should-BeTrue $positionEnter.Handled + $positioned = @($kit.Context.Annotations | + Where-Object Id -eq $duplicateId)[0] + Should-Be $positioned.Geometry.X 100 + Should-Be $positioned.Geometry.Y 110 + + $size = $kit.Context.PropertyControls.Size.Element + $size.Text='90 × 70' + $sizeEnter = New-RoutedKeyEvent -Target $size -Key Enter ` + -RoutedEvent ([System.Windows.Input.Keyboard]::KeyDownEvent) + $size.RaiseEvent($sizeEnter) + Should-BeTrue $sizeEnter.Handled + $sized = @($kit.Context.Annotations | + Where-Object Id -eq $duplicateId)[0] + Should-Be $sized.Geometry.Width 90 + Should-Be $sized.Geometry.Height 70 + + & $invokeAutomation $kit.Context.PropertyControls.MoreProperties.Element + $deleteItem = $kit.Context.PropertyControls.Delete.Element + Should-BeTrue ($deleteItem -is [System.Windows.Controls.MenuItem]) + & $invokeAutomation $deleteItem + Should-Be (@($kit.Context.Annotations | + Where-Object Id -eq $duplicateId)).Count 0 + Should-Be (@($kit.Context.Annotations | + Where-Object Id -eq $source.Id)).Count 1 + } finally { + & $kit.CommandRouter.CloseTransientMenus + $kit.Win.Width=1180; $kit.Win.Height=760 + & $kit.SetResponsiveMode 1200 700 + & $clearCanvasContent + } + } + } + + It 'actual canvas click selects the topmost overlapping record and empty click clears without history' { + try { + & $clearCanvasContent + & $kit.SetZoom 1.0 + & $drawRectangle ([System.Windows.Point]::new(40,40)) ` + ([System.Windows.Point]::new(180,150)) + & $drawRectangle ([System.Windows.Point]::new(70,70)) ` + ([System.Windows.Point]::new(210,175)) + Should-Be $kit.State.Annotations.Count 2 + + & $activateTool 'Select' + $historyBeforeSelection = $kit.State.UndoStack.Count + & $raiseCanvasClick ([System.Windows.Point]::new(100,100)) + + $expectedTopId = $kit.State.Annotations[1].Id + Should-Be $kit.Context.SelectedAnnotationId $expectedTopId + Should-Be $kit.State.SelectionId $expectedTopId + Should-Be $kit.State.UndoStack.Count $historyBeforeSelection + Should-Be $kit.PropertyIsland.Visibility ([System.Windows.Visibility]::Visible) + + & $raiseCanvasClick ([System.Windows.Point]::new(500,400)) + Should-Be $kit.Context.SelectedAnnotationId $null + Should-Be $kit.State.SelectionId $null + Should-Be $kit.State.UndoStack.Count $historyBeforeSelection + } finally { + & $clearCanvasContent + } + } + + It 'mint selection handles render above content at a constant DIP size across zoom' { + try { + & $clearCanvasContent + & $drawRectangle ([System.Windows.Point]::new(100,100)) ` + ([System.Windows.Point]::new(220,180)) + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(150,140)) + + Should-BeTrue ($kit.AnnotationLayer -is [System.Windows.Controls.Canvas]) + Should-BeTrue ($kit.InteractionLayer -is [System.Windows.Controls.Canvas]) + Should-BeTrue ($kit.SelectionLayer -is [System.Windows.Controls.Canvas]) + Should-BeTrue ($kit.ImageHost.Children.IndexOf($kit.SelectionLayer) -gt + $kit.ImageHost.Children.IndexOf($kit.AnnotationLayer)) + Should-BeTrue $kit.HighlightLayer.Focusable + + foreach ($zoom in @(0.5,1.0,2.0)) { + & $kit.SetZoom $zoom + $handles = @($kit.SelectionLayer.Children | Where-Object { + $null -ne $_.Tag -and $_.Tag.Role -eq 'SelectionHandle' + }) + Should-Be $handles.Count 8 + foreach ($handle in $handles) { + Should-Be ($handle.Width * $zoom) 10 0.01 + Should-Be ($handle.Height * $zoom) 10 0.01 + Should-Be $handle.Fill $kit.Resources['SnipMintBrush'] + } + $outline = @($kit.SelectionLayer.Children | Where-Object { + $null -ne $_.Tag -and $_.Tag.Role -eq 'SelectionOutline' + }) | Select-Object -First 1 + Should-BeTrue ($null -ne $outline) + Should-Be ($outline.StrokeThickness * $zoom) 2 0.01 + Should-Be $outline.Stroke $kit.Resources['SnipMintBrush'] + } + } finally { + & $kit.SetZoom 1.0 + & $clearCanvasContent + } + } + + It 'routed move commits exactly one semantic history entry on release' { + try { + & $clearCanvasContent + & $drawRectangle ([System.Windows.Point]::new(100,100)) ` + ([System.Windows.Point]::new(220,180)) + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(150,140)) + $selectedId = $kit.Context.SelectedAnnotationId + $original = @($kit.Context.Annotations | Where-Object Id -eq $selectedId)[0] + $originalX = $original.Geometry.X + $originalY = $original.Geometry.Y + $historyBefore = $kit.Context.UndoStack.Count + + & $raiseCanvasDown ([System.Windows.Point]::new(150,140)) + & $raiseCanvasMove ([System.Windows.Point]::new(160,150)) + & $raiseCanvasMove ([System.Windows.Point]::new(180,160)) + $stillCommitted = @($kit.Context.Annotations | + Where-Object Id -eq $selectedId)[0] + Should-Be $stillCommitted.Geometry.X $originalX + Should-Be $stillCommitted.Geometry.Y $originalY + Should-Be $kit.Context.Interaction.Candidate.Geometry.X ($originalX + 30) + Should-Be $kit.Context.Interaction.Candidate.Geometry.Y ($originalY + 20) + Should-Be $kit.Context.UndoStack.Count $historyBefore + & $raiseCanvasUp ([System.Windows.Point]::new(180,160)) + + $moved = @($kit.Context.Annotations | Where-Object Id -eq $selectedId)[0] + Should-Be $moved.Id $original.Id + Should-Be $moved.Geometry.X ($originalX + 30) + Should-Be $moved.Geometry.Y ($originalY + 20) + Should-Be $kit.Context.UndoStack.Count ($historyBefore + 1) + Should-Be $kit.Context.Interaction $null + } finally { + & $clearCanvasContent + } + } + + It 'routed resize crosses an edge safely and commits one history entry' { + try { + & $clearCanvasContent + & $drawRectangle ([System.Windows.Point]::new(100,100)) ` + ([System.Windows.Point]::new(180,160)) + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(140,130)) + $selectedId = $kit.Context.SelectedAnnotationId + $historyBefore = $kit.Context.UndoStack.Count + $topLeftHandle = @($kit.SelectionLayer.Children | Where-Object { + $null -ne $_.Tag -and $_.Tag.Role -eq 'SelectionHandle' -and + $_.Tag.Handle -eq 'TopLeft' + })[0] + $handleStart = [System.Windows.Point]::new( + [System.Windows.Controls.Canvas]::GetLeft($topLeftHandle) + + $topLeftHandle.Width/2, + [System.Windows.Controls.Canvas]::GetTop($topLeftHandle) + + $topLeftHandle.Height/2) + $original = @($kit.Context.Annotations | Where-Object Id -eq $selectedId)[0] + $originalRight = $original.Geometry.X + $original.Geometry.Width + $originalBottom = $original.Geometry.Y + $original.Geometry.Height + $crossedEnd = [System.Windows.Point]::new( + $originalRight + 30,$originalBottom + 30) + + & $raiseCanvasDown $handleStart + & $raiseCanvasMove ([System.Windows.Point]::new( + $originalRight + 10,$originalBottom + 10)) + & $raiseCanvasMove $crossedEnd + $stillCommitted = @($kit.Context.Annotations | + Where-Object Id -eq $selectedId)[0] + Should-Be $stillCommitted.Geometry.X $original.Geometry.X + Should-Be $stillCommitted.Geometry.Y $original.Geometry.Y + Should-Be $kit.Context.UndoStack.Count $historyBefore + & $raiseCanvasUp $crossedEnd + + $resized = @($kit.Context.Annotations | Where-Object Id -eq $selectedId)[0] + Should-Be $resized.Id $selectedId + Should-Be $resized.Geometry.X $originalRight + Should-Be $resized.Geometry.Y $originalBottom + Should-Be $resized.Geometry.Width 30 + Should-Be $resized.Geometry.Height 30 + Should-Be $kit.Context.UndoStack.Count ($historyBefore + 1) + } finally { + & $clearCanvasContent + } + } + + It 'a routed line-endpoint collapse is rejected as a no-op without history' { + try { + & $clearCanvasContent + $line = New-SnipAnnotation -Kind Line -Geometry ([pscustomobject]@{ + Type='Line' + Start=[pscustomobject]@{ X=100; Y=100 } + End=[pscustomobject]@{ X=220; Y=100 } + }) -Color Green -StrokeWidth 4 -Opacity 1 ` + -Properties ([ordered]@{}) -Z 2 + $kit.Context.Annotations.Add($line) | Out-Null + & $kit.Render + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(160,100)) + $historyBefore = $kit.Context.UndoStack.Count + + & $raiseCanvasDown ([System.Windows.Point]::new(220,100)) + Should-Be $kit.Context.Interaction.Handle 'End' + & $raiseCanvasMove ([System.Windows.Point]::new(100,100)) + Should-Be $kit.Context.Interaction.Candidate.Geometry.End.X 220 + Should-Be $kit.Context.Interaction.Candidate.Geometry.End.Y 100 + & $raiseCanvasUp ([System.Windows.Point]::new(100,100)) + + Should-Be $kit.Context.UndoStack.Count $historyBefore + Should-Be $kit.Context.Annotations[0].Geometry.End.X 220 + Should-Be $kit.Context.Annotations[0].Geometry.End.Y 100 + } finally { + & $clearCanvasContent + } + } + + It 'no-op and Escape-cancelled gestures create no history or committed mutation' { + try { + & $clearCanvasContent + & $drawRectangle ([System.Windows.Point]::new(100,100)) ` + ([System.Windows.Point]::new(220,180)) + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(150,140)) + $selectedId = $kit.Context.SelectedAnnotationId + $original = Copy-SnipAnnotation -Annotation ` + (@($kit.Context.Annotations | Where-Object Id -eq $selectedId)[0]) + $historyBefore = $kit.Context.UndoStack.Count + + & $raiseCanvasClick ([System.Windows.Point]::new(150,140)) + Should-Be $kit.Context.UndoStack.Count $historyBefore + + & $raiseCanvasDown ([System.Windows.Point]::new(150,140)) + & $raiseCanvasMove ([System.Windows.Point]::new(190,175)) + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::None + } + $escape = & $raiseCanvasKey ([System.Windows.Input.Key]::Escape) + Should-BeTrue $escape.Handled + $after = @($kit.Context.Annotations | Where-Object Id -eq $selectedId)[0] + Should-Be $after.Geometry.X $original.Geometry.X + Should-Be $after.Geometry.Y $original.Geometry.Y + Should-Be $kit.Context.UndoStack.Count $historyBefore + Should-Be $kit.Context.Interaction $null + } finally { + & $clearCanvasContent + } + } + + It 'tool switch and real capture loss cancel drafts and a replacement edit clears redo' { + try { + & $clearCanvasContent + & $drawRectangle ([System.Windows.Point]::new(100,100)) ` + ([System.Windows.Point]::new(220,180)) + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(150,140)) + $selectedId = $kit.Context.SelectedAnnotationId + $historyBefore = $kit.Context.UndoStack.Count + $originalX = $kit.Context.Annotations[0].Geometry.X + + & $raiseCanvasDown ([System.Windows.Point]::new(150,140)) + & $raiseCanvasMove ([System.Windows.Point]::new(190,170)) + & $activateTool 'Crop' + Should-Be $kit.Context.Interaction $null + Should-Be $kit.Context.Annotations[0].Geometry.X $originalX + Should-Be $kit.Context.UndoStack.Count $historyBefore + + & $activateTool 'Select' + & $raiseCanvasDown ([System.Windows.Point]::new(150,140)) + & $raiseCanvasMove ([System.Windows.Point]::new(180,160)) + $lostCapture = [SnipTestMouseEventArgs]::new( + [System.Windows.Input.Mouse]::PrimaryDevice, + [Environment]::TickCount, + [System.Windows.Point]::new(180,160)) + $lostCapture.RoutedEvent = [System.Windows.Input.Mouse]::LostMouseCaptureEvent + $kit.HighlightLayer.RaiseEvent($lostCapture) + Should-Be $kit.Context.Interaction $null + Should-Be $kit.Context.Annotations[0].Geometry.X $originalX + Should-Be $kit.Context.UndoStack.Count $historyBefore + + & $raiseCanvasDown ([System.Windows.Point]::new(150,140)) + & $raiseCanvasMove ([System.Windows.Point]::new(180,160)) + & $raiseCanvasUp ([System.Windows.Point]::new(180,160)) + Should-Be $kit.Context.Annotations[0].Id $selectedId + & $raiseClick ($kit.Win.FindName('UndoBtn')) + Should-Be $kit.Context.RedoStack.Count 1 + & $raiseCanvasClick ([System.Windows.Point]::new(150,140)) + & $raiseCanvasDown ([System.Windows.Point]::new(150,140)) + & $raiseCanvasMove ([System.Windows.Point]::new(165,150)) + & $raiseCanvasUp ([System.Windows.Point]::new(165,150)) + Should-Be $kit.Context.RedoStack.Count 0 + } finally { + & $kit.SetZoom 1.0 + $kit.Scroller.ScrollToHorizontalOffset(0) + $kit.Scroller.ScrollToVerticalOffset(0) + & $clearCanvasContent + } + } + + It 'zoomed and panned routed move clamps at source edges and inward handle drag stays normalized' { + try { + & $clearCanvasContent + & $drawRectangle ([System.Windows.Point]::new(10,10)) ` + ([System.Windows.Point]::new(100,80)) + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(50,40)) + $selectedId = $kit.Context.SelectedAnnotationId + & $kit.SetZoom 2.0 + $kit.Scroller.ScrollToHorizontalOffset(70) + $kit.Scroller.ScrollToVerticalOffset(50) + $kit.Win.UpdateLayout() + + & $raiseCanvasDown ([System.Windows.Point]::new(50,40)) + & $raiseCanvasMove ([System.Windows.Point]::new(-500,-500)) + $candidate = $kit.Context.Interaction.Candidate + Should-Be $kit.Context.Annotations[0].Geometry.X 10 + Should-Be $kit.Context.Annotations[0].Geometry.Y 10 + Should-Be $candidate.Geometry.X 0 + Should-Be $candidate.Geometry.Y 0 + & $raiseCanvasUp ([System.Windows.Point]::new(-500,-500)) + $clamped = @($kit.Context.Annotations | Where-Object Id -eq $selectedId)[0] + Should-Be $clamped.Geometry.X 0 + Should-Be $clamped.Geometry.Y 0 + + $rightHandle = @($kit.SelectionLayer.Children | Where-Object { + $null -ne $_.Tag -and $_.Tag.Role -eq 'SelectionHandle' -and + $_.Tag.Handle -eq 'Right' + })[0] + $rightStart = [System.Windows.Point]::new( + [System.Windows.Controls.Canvas]::GetLeft($rightHandle)+$rightHandle.Width/2, + [System.Windows.Controls.Canvas]::GetTop($rightHandle)+$rightHandle.Height/2) + & $raiseCanvasDown $rightStart + & $raiseCanvasMove ([System.Windows.Point]::new(-30,$rightStart.Y)) + & $raiseCanvasUp ([System.Windows.Point]::new(-30,$rightStart.Y)) + $inward = @($kit.Context.Annotations | Where-Object Id -eq $selectedId)[0] + Should-BeTrue ($inward.Geometry.X -ge 0) + Should-BeTrue ($inward.Geometry.Width -ge 1) + Should-BeTrue (($inward.Geometry.X+$inward.Geometry.Width) -le $kit.Bitmap.Width) + } finally { + & $kit.SetZoom 1.0 + $kit.Scroller.ScrollToHorizontalOffset(0) + $kit.Scroller.ScrollToVerticalOffset(0) + & $clearCanvasContent + } + } + + It 'annotation popup actions resolve their stable target when list membership changes' { + try { + & $clearCanvasContent + & $raiseMenuClick $kit.Context.Shell.ColorMenuItems.Blue + Should-Be $kit.State.ActiveColor 'Blue' + & $drawRectangle ([System.Windows.Point]::new(40,40)) ` + ([System.Windows.Point]::new(160,130)) + & $drawRectangle ([System.Windows.Point]::new(300,40)) ` + ([System.Windows.Point]::new(420,130)) + $targetId = $kit.Context.Annotations[1].Id + $rightClick = New-RoutedMouseRightClick -Target $kit.HighlightLayer ` + -Position ([System.Windows.Point]::new(350,80)) + $kit.HighlightLayer.RaiseEvent($rightClick) + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + Should-BeTrue $rightClick.Handled + $capturedRedAction = @($kit.Context.AnnotationMenuControl.Menu.Items | + Where-Object { + $_ -is [System.Windows.Controls.MenuItem] -and $_.Header -eq 'Red' + })[0] + & $kit.Context.AnnotationMenuControl.CloseOptions + + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(80,80)) + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::None + } + & $raiseCanvasKey ([System.Windows.Input.Key]::Delete) | Out-Null + Should-Be $kit.Context.Annotations.Count 1 + Should-Be $kit.Context.Annotations[0].Id $targetId + $historyAfterShift = $kit.Context.UndoStack.Count + & $raiseMenuClick $capturedRedAction + Should-Be $kit.Context.Annotations.Count 1 + Should-Be $kit.Context.Annotations[0].Id $targetId + Should-Be $kit.Context.Annotations[0].Color 'Red' + Should-Be $kit.Context.UndoStack.Count ($historyAfterShift + 1) + } finally { + if ($null -ne $kit.Context.AnnotationMenuControl) { + try { & $kit.Context.AnnotationMenuControl.CloseOptions } catch {} + } + & $raiseMenuClick $kit.Context.Shell.ColorMenuItems.Yellow + & $clearCanvasContent + } + } + } + + Describe 'Task 7 authoritative annotation draft contract' { + foreach ($case in @( + @{ Tool='RectangleEllipse'; SemanticTool='rect' }, + @{ Tool='Highlight'; SemanticTool='highlight' }, + @{ Tool='ArrowLine'; SemanticTool='arrow' } + )) { + It "publishes and cancels the $($case.Tool) draft before changing tools" { + try { + & $clearCanvasContent + & $activateTool $case.Tool + $annotationCount = $kit.Context.Annotations.Count + $historyCount = $kit.Context.UndoStack.Count + + & $raiseCanvasDown ([System.Windows.Point]::new(140,120)) + & $raiseCanvasMove ([System.Windows.Point]::new(260,210)) + + Should-BeTrue ($null -ne $kit.Context.Draft) + Should-Be $kit.Context.Draft.Kind 'Annotation' + Should-Be $kit.Context.Draft.Tool $case.SemanticTool + Should-BeTrue ([object]::ReferenceEquals( + $kit.Context.Draft,$kit.State.Draft)) + Should-BeTrue ($null -ne $kit.Context.Interaction) + Should-Be $kit.Context.Interaction.Kind 'Annotation' + Should-Be $kit.Context.Interaction.Mode 'Create' + Should-BeTrue ([object]::ReferenceEquals( + $kit.Context.Draft,$kit.Context.Interaction.Draft)) + Should-BeTrue $kit.State.Drawing + Should-BeTrue ([object]::ReferenceEquals( + $kit.Context.Draft.Visual,$kit.State.DraftRect)) + + $firstEscape = & $raiseCanvasKey ([System.Windows.Input.Key]::Escape) + Should-BeTrue $firstEscape.Handled + Should-Be $kit.Context.CommandRouter.LastCommand 'CancelDraft' + Should-Be $kit.Context.Draft $null + Should-Be $kit.Context.Interaction $null + Should-Be $kit.State.Draft $null + Should-BeFalse $kit.State.Drawing + Should-Be $kit.State.DraftRect $null + Should-Be $kit.Context.Annotations.Count $annotationCount + Should-Be $kit.Context.UndoStack.Count $historyCount + Should-Be $kit.Context.ActiveTool $case.Tool + + $secondEscape = & $raiseCanvasKey ([System.Windows.Input.Key]::Escape) + Should-BeTrue $secondEscape.Handled + Should-Be $kit.Context.CommandRouter.LastCommand 'ActivateSelect' + Should-Be $kit.Context.ActiveTool 'Select' + Should-Be $kit.Context.Annotations.Count $annotationCount + Should-Be $kit.Context.UndoStack.Count $historyCount + } finally { + try { & $kit.Context.CancelDraft } catch {} + & $activateTool 'Select' + & $clearCanvasContent + } + } + } + + It 'tool switch and capture loss each clear one annotation draft exactly once' { + try { + & $clearCanvasContent + & $activateTool 'RectangleEllipse' + & $raiseCanvasDown ([System.Windows.Point]::new(140,120)) + & $raiseCanvasMove ([System.Windows.Point]::new(260,210)) + $beforeSwitch = $kit.Context.AnnotationDraftClearCount + & $activateTool 'Crop' + Should-Be $kit.Context.AnnotationDraftClearCount ($beforeSwitch + 1) + Should-Be $kit.Context.Draft $null + Should-Be $kit.Context.Interaction $null + & $activateTool 'Select' + Should-Be $kit.Context.AnnotationDraftClearCount ($beforeSwitch + 1) + + & $activateTool 'Highlight' + & $raiseCanvasDown ([System.Windows.Point]::new(180,150)) + & $raiseCanvasMove ([System.Windows.Point]::new(280,230)) + $beforeLoss = $kit.Context.AnnotationDraftClearCount + $lostCapture = [SnipTestMouseEventArgs]::new( + [System.Windows.Input.Mouse]::PrimaryDevice, + [Environment]::TickCount, + [System.Windows.Point]::new(280,230)) + $lostCapture.RoutedEvent = + [System.Windows.Input.Mouse]::LostMouseCaptureEvent + $kit.HighlightLayer.RaiseEvent($lostCapture) + Should-Be $kit.Context.AnnotationDraftClearCount ($beforeLoss + 1) + Should-Be $kit.Context.Draft $null + Should-Be $kit.Context.Interaction $null + $kit.HighlightLayer.RaiseEvent($lostCapture) + Should-Be $kit.Context.AnnotationDraftClearCount ($beforeLoss + 1) + } finally { + try { & $kit.Context.CancelDraft } catch {} + & $activateTool 'Select' + & $clearCanvasContent + } + } + } + + Describe 'Task 7 routed keyboard and stable history contract' { + $newPopupKeyEvent = { + param( + [System.Windows.IInputElement]$Target, + [System.Windows.Input.Key]$Key, + [System.Windows.Window]$Window + ) + $source = [System.Windows.PresentationSource]::FromVisual($Target) + if ($null -eq $source) { + $source = [System.Windows.PresentationSource]::FromVisual($Window) + } + $eventArgs = [System.Windows.Input.KeyEventArgs]::new( + [System.Windows.Input.Keyboard]::PrimaryDevice, + $source,[Environment]::TickCount,$Key) + $eventArgs.RoutedEvent = + [System.Windows.Input.Keyboard]::PreviewKeyDownEvent + $eventArgs + } + + It 'each real key traverses the single resolver once and focused Button keeps native input' { + $originalResolver = $kit.Context.CommandRouter.Resolve + $calls = [System.Collections.ArrayList]::new() + $kit.Context.CommandRouter.Resolve = { + param($focusedRole,$editorState,$key,$modifiers) + $calls.Add([pscustomobject]@{ + Role=$focusedRole; Key=$key; Modifiers=@($modifiers) + }) | Out-Null + & $originalResolver $focusedRole $editorState $key $modifiers + }.GetNewClosure() + try { + & $clearCanvasContent + & $drawRectangle ([System.Windows.Point]::new(100,100)) ` + ([System.Windows.Point]::new(180,160)) + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(140,130)) + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::None + } + $before = $calls.Count + & $raiseCanvasKey ([System.Windows.Input.Key]::Right) | Out-Null + Should-Be $calls.Count ($before + 1) + Should-Be $calls[-1].Role 'Canvas' + Should-Be $kit.Context.CommandRouter.LastCommand 'MoveSelectionRight1' + + $button = $kit.Context.ToolControls.Select + $button.Focus() | Out-Null + $space = New-RoutedKeyEvent -Target $button -Key Space + $before = $calls.Count + $button.RaiseEvent($space) + Should-Be $calls.Count ($before + 1) + Should-Be $calls[-1].Role 'Button' + Should-Be $kit.Context.CommandRouter.LastCommand 'ActivateFocusedButton' + Should-BeFalse $space.Handled + } finally { + $kit.Context.CommandRouter.Resolve = $originalResolver + & $clearCanvasContent + } + } + + It 'canvas Space starts temporary pan through the resolver and key-up or capture loss ends it' { + $originalResolver = $kit.Context.CommandRouter.Resolve + $calls = @{ Count=0 } + $kit.Context.CommandRouter.Resolve = { + param($focusedRole,$editorState,$key,$modifiers) + $calls.Count++ + & $originalResolver $focusedRole $editorState $key $modifiers + }.GetNewClosure() + try { + & $clearCanvasContent + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::None + } + $kit.HighlightLayer.Focus() | Out-Null + + $spaceDown = New-RoutedKeyEvent -Target $kit.HighlightLayer -Key Space + $kit.HighlightLayer.RaiseEvent($spaceDown) + Should-BeTrue $spaceDown.Handled + Should-Be $calls.Count 1 + Should-Be $kit.Context.CommandRouter.LastCommand 'TemporaryPan' + Should-BeTrue $kit.State.Panning + Should-BeTrue $kit.State.TemporaryPan + + $spaceUp = New-RoutedKeyEvent -Target $kit.HighlightLayer -Key Space ` + -RoutedEvent ([System.Windows.Input.Keyboard]::PreviewKeyUpEvent) + $kit.HighlightLayer.RaiseEvent($spaceUp) + Should-BeTrue $spaceUp.Handled + Should-BeFalse $kit.State.Panning + Should-BeFalse $kit.State.TemporaryPan + Should-Be $calls.Count 1 + + $kit.HighlightLayer.RaiseEvent((New-RoutedKeyEvent ` + -Target $kit.HighlightLayer -Key Space)) + Should-BeTrue $kit.State.Panning + $lostCapture = [System.Windows.Input.MouseEventArgs]::new( + [System.Windows.Input.Mouse]::PrimaryDevice, + [Environment]::TickCount) + $lostCapture.RoutedEvent = [System.Windows.Input.Mouse]::LostMouseCaptureEvent + $kit.HighlightLayer.RaiseEvent($lostCapture) + Should-BeFalse $kit.State.Panning + Should-BeFalse $kit.State.TemporaryPan + } finally { + if ($kit.State.Panning -or $kit.State.TemporaryPan) { + & $kit.EndPan + } + $kit.Context.CommandRouter.Resolve = $originalResolver + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::None + } + & $clearCanvasContent + } + } + + It 'real Escape unwinds popup editor crop draft selection and active tool one level per press' { + try { + & $clearCanvasContent + & $drawRectangle ([System.Windows.Point]::new(100,100)) ` + ([System.Windows.Point]::new(180,160)) + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(140,130)) + $selectedId = $kit.Context.SelectedAnnotationId + + & $kit.SetResponsiveMode 760 540 + & $raiseClick $kit.Context.Shell.MoreButton + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + $menuItem = $kit.Context.Shell.MoreMenuItems.Highlight + $menuItem.Focus() | Out-Null + $resolveBefore = $kit.Context.CommandRouter.ResolveCount + $menuEscape = & $newPopupKeyEvent $menuItem Escape $kit.Win + $menuItem.RaiseEvent($menuEscape) + Should-Be $kit.Context.CommandRouter.ResolveCount ($resolveBefore + 1) + Should-Be $kit.Context.CommandRouter.LastCommand 'ClosePopup' + Should-BeTrue $menuEscape.Handled + Should-BeFalse $kit.Context.Shell.MoreMenuState.IsExpanded + Should-BeTrue ([object]::ReferenceEquals( + [System.Windows.Input.Keyboard]::FocusedElement, + $kit.Context.Shell.MoreButton)) + Should-Be $kit.Context.SelectedAnnotationId $selectedId + + & $activateTool 'Text' + & $raiseCanvasClick ([System.Windows.Point]::new(300,260)) + $editor = @($kit.HighlightLayer.Children | + Where-Object { $_ -is [System.Windows.Controls.TextBox] }) | + Select-Object -Last 1 + $editor.Text='draft text' + $editor.Focus() | Out-Null + $editorEscape = & $raiseControlKey $editor ` + ([System.Windows.Input.Key]::Escape) + Should-Be $kit.Context.CommandRouter.LastCommand 'CancelTextEdit' + Should-BeFalse $kit.State.EditingText + Should-Be $kit.Context.SelectedAnnotationId $selectedId + + & $activateTool 'Crop' + & $raiseCanvasDrag ([System.Windows.Point]::new(220,180)) ` + @([System.Windows.Point]::new(360,280)) ` + ([System.Windows.Point]::new(500,380)) + $draftEscape = & $raiseCanvasKey ([System.Windows.Input.Key]::Escape) + Should-BeTrue $draftEscape.Handled + Should-Be $kit.Context.CommandRouter.LastCommand 'CancelDraft' + Should-Be $kit.Context.Draft $null + Should-Be $kit.Context.SelectedAnnotationId $selectedId + + & $activateTool 'Select' + $selectionEscape = & $raiseCanvasKey ([System.Windows.Input.Key]::Escape) + Should-BeTrue $selectionEscape.Handled + Should-Be $kit.Context.CommandRouter.LastCommand 'ClearSelection' + Should-Be $kit.Context.SelectedAnnotationId $null + Should-Be $kit.Context.ActiveTool 'Select' + + & $activateTool 'Crop' + $toolEscape = & $raiseCanvasKey ([System.Windows.Input.Key]::Escape) + Should-BeTrue $toolEscape.Handled + Should-Be $kit.Context.CommandRouter.LastCommand 'ActivateSelect' + Should-Be $kit.Context.ActiveTool 'Select' + } finally { + & $kit.CommandRouter.CloseTransientMenus + & $kit.SetResponsiveMode 1200 700 + & $clearCanvasContent + } + } + + It 'every static popup tree routes its actual MenuItem origin once without overriding native navigation' { + $moreControl = @($kit.Context.TransientMenus | Where-Object Name -eq 'More')[0] + $cases = @( + [pscustomobject]@{ Name='Copy'; Control=$kit.SplitControls.Copy; Opener=$kit.SplitControls.Copy.OptionsButton }, + [pscustomobject]@{ Name='ArrowLine'; Control=$kit.SplitControls.ArrowLine; Opener=$kit.SplitControls.ArrowLine.OptionsButton }, + [pscustomobject]@{ Name='RectangleEllipse'; Control=$kit.SplitControls.RectangleEllipse; Opener=$kit.SplitControls.RectangleEllipse.OptionsButton }, + [pscustomobject]@{ Name='BlurPixelate'; Control=$kit.SplitControls.BlurPixelate; Opener=$kit.SplitControls.BlurPixelate.OptionsButton }, + [pscustomobject]@{ Name='Color'; Control=$kit.Context.Shell.ColorMenuControl; Opener=$kit.Context.ToolControls.Color }, + [pscustomobject]@{ Name='More'; Control=$moreControl; Opener=$kit.Context.Shell.MoreButton } + ) + try { + $kit.Win.Width = 1240 + $kit.Win.Height = 760 + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Render) + & $kit.SetResponsiveMode 1240 760 + foreach ($case in $cases) { + if ($case.Name -eq 'More') { + $kit.Win.Width = 1000 + $kit.Win.Height = 650 + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Render) + & $kit.SetResponsiveMode 1000 650 + } + & $case.Control.OpenOptions $case.Opener + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + $menu = $case.Control.Menu + if (-not $case.Control.State.IsExpanded) { + throw "$($case.Name) popup did not open (IsOpen=$($menu.IsOpen); attached=$($case.Control.State.HandlersAttached); openerVisible=$($case.Opener.IsVisible); mode=$($kit.Context.ModeState.Value))" + } + $lifecycle = $menu.Tag + Should-Be $lifecycle.Kind 'SnipPreviewMenuLifecycle' + Should-BeTrue $lifecycle.PopupKeyRouteAttached + Should-Be $lifecycle.PopupKeyRouteAttachCount 1 + Should-BeTrue ($null -ne $lifecycle.PopupKeyRouteHandler) + $handler = $lifecycle.PopupKeyRouteHandler + Set-SnipPreviewMenuStyle -Menu $menu -Context $kit.Context | Out-Null + Should-BeTrue ([object]::ReferenceEquals( + $handler,$menu.Tag.PopupKeyRouteHandler)) + Should-Be $menu.Tag.PopupKeyRouteAttachCount 1 + + $item = @($menu.Items | Where-Object { + $_ -is [System.Windows.Controls.MenuItem] -and $_.Visibility -eq 'Visible' + })[0] + $item.Focus() | Out-Null + if (-not [object]::ReferenceEquals( + [System.Windows.Input.Keyboard]::FocusedElement,$item)) { + throw "$($case.Name) popup item did not receive focus" + } + $resolveBefore = $kit.Context.CommandRouter.ResolveCount + $routeBefore = $lifecycle.PopupKeyRouteInvocationCount + $down = & $newPopupKeyEvent $item Down $kit.Win + $item.RaiseEvent($down) + Should-Be $kit.Context.CommandRouter.ResolveCount ($resolveBefore + 1) + Should-Be $lifecycle.PopupKeyRouteInvocationCount ($routeBefore + 1) + if ($kit.Context.CommandRouter.LastCommand -ne 'PopupNavigation') { + throw "$($case.Name) popup resolved '$($kit.Context.CommandRouter.LastCommand)'" + } + Should-BeTrue ([object]::ReferenceEquals( + $lifecycle.LastPopupKeyOrigin,$item)) + Should-BeFalse $lifecycle.LastPopupRouteHandled + & $case.Control.CloseOptions + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + } + } finally { + & $kit.CommandRouter.CloseTransientMenus + $kit.Win.Width = 1180 + $kit.Win.Height = 760 + & $kit.SetResponsiveMode 1200 700 + } + } + + It 'rebuilt property and annotation popup trees detach the old route and attach one fresh route' { + $assertNativePopupRoute = { + param($Control,$Context,$Window,$KeyEventFactory) + & $Control.OpenOptions + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + $menu = $Control.Menu + $lifecycle = $menu.Tag + Should-BeTrue $lifecycle.PopupKeyRouteAttached + Should-Be $lifecycle.PopupKeyRouteAttachCount 1 + $item = @($menu.Items | Where-Object { + $_ -is [System.Windows.Controls.MenuItem] + })[0] + $item.Focus() | Out-Null + $before = $Context.CommandRouter.ResolveCount + $down = & $KeyEventFactory $item Down $Window + $item.RaiseEvent($down) + Should-Be $Context.CommandRouter.ResolveCount ($before + 1) + Should-Be $Context.CommandRouter.LastCommand 'PopupNavigation' + Should-BeFalse $lifecycle.LastPopupRouteHandled + & $Control.CloseOptions + $lifecycle + }.GetNewClosure() + try { + & $clearCanvasContent + & $drawRectangle ([System.Windows.Point]::new(100,100)) ` + ([System.Windows.Point]::new(220,180)) + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(150,140)) + & $kit.SetResponsiveMode 1000 650 + $firstProperty = $kit.Context.PropertyMenuControl + $firstPropertyLifecycle = & $assertNativePopupRoute $firstProperty ` + $kit.Context $kit.Win $newPopupKeyEvent + & $kit.SetPropertyIsland 'Select' + Should-BeFalse $firstPropertyLifecycle.PopupKeyRouteAttached + $secondProperty = $kit.Context.PropertyMenuControl + Should-BeFalse ([object]::ReferenceEquals($firstProperty,$secondProperty)) + $secondPropertyLifecycle = & $assertNativePopupRoute $secondProperty ` + $kit.Context $kit.Win $newPopupKeyEvent + Should-BeFalse ([object]::ReferenceEquals( + $firstPropertyLifecycle.PopupKeyRouteHandler, + $secondPropertyLifecycle.PopupKeyRouteHandler)) + + & $activateTool 'Crop' + $aspectControl = $kit.Context.CropAspectMenuControl + $aspectLifecycle = & $assertNativePopupRoute $aspectControl ` + $kit.Context $kit.Win $newPopupKeyEvent + & $activateTool 'Select' + Should-BeFalse $aspectLifecycle.PopupKeyRouteAttached + + & $raiseCanvasClick ([System.Windows.Point]::new(150,140)) + $rightClick = New-RoutedMouseRightClick -Target $kit.HighlightLayer ` + -Position ([System.Windows.Point]::new(150,140)) + $kit.HighlightLayer.RaiseEvent($rightClick) + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + $firstAnnotation = $kit.Context.AnnotationMenuControl + $firstAnnotationLifecycle = & $assertNativePopupRoute $firstAnnotation ` + $kit.Context $kit.Win $newPopupKeyEvent + + $kit.HighlightLayer.RaiseEvent((New-RoutedMouseRightClick ` + -Target $kit.HighlightLayer ` + -Position ([System.Windows.Point]::new(150,140)))) + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + Should-BeFalse $firstAnnotationLifecycle.PopupKeyRouteAttached + $secondAnnotation = $kit.Context.AnnotationMenuControl + $secondAnnotationLifecycle = & $assertNativePopupRoute $secondAnnotation ` + $kit.Context $kit.Win $newPopupKeyEvent + Should-BeFalse ([object]::ReferenceEquals( + $firstAnnotationLifecycle.PopupKeyRouteHandler, + $secondAnnotationLifecycle.PopupKeyRouteHandler)) + } finally { + if ($null -ne $kit.Context.AnnotationMenuControl) { + try { & $kit.Context.AnnotationMenuControl.CloseOptions } catch {} + } + & $kit.CommandRouter.CloseTransientMenus + & $kit.SetResponsiveMode 1200 700 + & $clearCanvasContent + } + } + + It 'Undo of a selected creation clears the ID and Redo restores identity without selection' { + try { + & $clearCanvasContent + & $drawRectangle ([System.Windows.Point]::new(100,100)) ` + ([System.Windows.Point]::new(180,160)) + $createdId = $kit.Context.Annotations[0].Id + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(140,130)) + Should-Be $kit.Context.SelectedAnnotationId $createdId + + & $raiseClick ($kit.Win.FindName('UndoBtn')) + Should-Be $kit.Context.Annotations.Count 0 + Should-Be $kit.Context.SelectedAnnotationId $null + & $raiseClick ($kit.Win.FindName('RedoBtn')) + Should-Be $kit.Context.Annotations.Count 1 + Should-Be $kit.Context.Annotations[0].Id $createdId + Should-Be $kit.Context.SelectedAnnotationId $null + } finally { + & $clearCanvasContent + } + } + + It 'canvas Arrow and Shift Arrow move one and ten pixels while real controls retain keys' { + try { + & $clearCanvasContent + & $drawRectangle ([System.Windows.Point]::new(100,100)) ` + ([System.Windows.Point]::new(180,160)) + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(140,130)) + $selectedId = $kit.Context.SelectedAnnotationId + + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::None + } + $right = & $raiseCanvasKey ([System.Windows.Input.Key]::Right) + Should-BeTrue $right.Handled + $afterOne = @($kit.Context.Annotations | Where-Object Id -eq $selectedId)[0] + Should-Be $afterOne.Geometry.X 101 + + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::Shift + } + $down = & $raiseCanvasKey ([System.Windows.Input.Key]::Down) + Should-BeTrue $down.Handled + $afterTen = @($kit.Context.Annotations | Where-Object Id -eq $selectedId)[0] + Should-Be $afterTen.Geometry.Y 110 + + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::None + } + $save = $kit.Win.FindName('SaveBtn') + $save.Focus() | Out-Null + $buttonDelete = New-RoutedKeyEvent -Target $save -Key Delete + $save.RaiseEvent($buttonDelete) + Should-BeFalse $buttonDelete.Handled + Should-Be (@($kit.Context.Annotations | Where-Object Id -eq $selectedId)).Count 1 + + & $kit.SetResponsiveMode 760 540 + & $raiseClick $kit.Context.Shell.MoreButton + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + $menuItem = $kit.Context.Shell.MoreMenuItems.Highlight + $menuItem.Focus() | Out-Null + $menuDelete = New-RoutedKeyEvent -Target $menuItem -Key Delete + $menuItem.RaiseEvent($menuDelete) + Should-Be (@($kit.Context.Annotations | Where-Object Id -eq $selectedId)).Count 1 + & $kit.CommandRouter.CloseTransientMenus + } finally { + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::None + } + & $kit.SetResponsiveMode 1200 700 + & $clearCanvasContent + } + } + + It 'canvas Delete Undo and Redo preserve the stable record identity without stale selection' { + try { + & $clearCanvasContent + & $drawRectangle ([System.Windows.Point]::new(100,100)) ` + ([System.Windows.Point]::new(180,160)) + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(140,130)) + $selectedId = $kit.Context.SelectedAnnotationId + $historyBefore = $kit.Context.UndoStack.Count + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::None + } + + $delete = & $raiseCanvasKey ([System.Windows.Input.Key]::Delete) + Should-BeTrue $delete.Handled + Should-Be $kit.Context.Annotations.Count 0 + Should-Be $kit.Context.SelectedAnnotationId $null + Should-Be $kit.Context.UndoStack.Count ($historyBefore + 1) + + & $raiseClick ($kit.Win.FindName('UndoBtn')) + Should-Be $kit.Context.Annotations.Count 1 + Should-Be $kit.Context.Annotations[0].Id $selectedId + Should-Be $kit.Context.SelectedAnnotationId $null + + & $raiseClick ($kit.Win.FindName('RedoBtn')) + Should-Be $kit.Context.Annotations.Count 0 + Should-Be $kit.Context.SelectedAnnotationId $null + } finally { + & $clearCanvasContent + } + } + + It 'a real inline editor retains arrows and Delete ahead of the selected canvas record' { + try { + & $clearCanvasContent + & $drawRectangle ([System.Windows.Point]::new(100,100)) ` + ([System.Windows.Point]::new(180,160)) + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(140,130)) + $selectedId = $kit.Context.SelectedAnnotationId + $selected = @($kit.Context.Annotations | Where-Object Id -eq $selectedId)[0] + $originalX = $selected.Geometry.X + $originalY = $selected.Geometry.Y + + & $activateTool 'Text' + & $raiseCanvasClick ([System.Windows.Point]::new(300,260)) + $editor = @($kit.HighlightLayer.Children | + Where-Object { $_ -is [System.Windows.Controls.TextBox] }) | + Select-Object -Last 1 + Should-BeTrue ($null -ne $editor) + $editor.Text = 'editing' + $editor.CaretIndex = $editor.Text.Length + $editor.Focus() | Out-Null + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::None + } + foreach ($key in @([System.Windows.Input.Key]::Left,[System.Windows.Input.Key]::Delete)) { + & $raiseControlKey $editor $key | Out-Null + } + $unchanged = @($kit.Context.Annotations | + Where-Object Id -eq $selectedId)[0] + Should-Be $unchanged.Geometry.X $originalX + Should-Be $unchanged.Geometry.Y $originalY + Should-Be $kit.Context.Annotations.Count 1 + + $escape = & $raiseControlKey $editor ([System.Windows.Input.Key]::Escape) + Should-BeTrue $escape.Handled + } finally { + & $clearCanvasContent + } + } + + It 'a real editable Select property retains arrows Delete and Escape ahead of canvas commands' { + try { + & $clearCanvasContent + & $drawRectangle ([System.Windows.Point]::new(100,100)) ` + ([System.Windows.Point]::new(180,160)) + & $activateTool 'Select' + & $raiseCanvasClick ([System.Windows.Point]::new(140,130)) + $selectedId=$kit.Context.SelectedAnnotationId + $positionEditor=$kit.Context.PropertyControls.Position.Element + Should-BeTrue ($positionEditor -is [System.Windows.Controls.TextBox]) + $originalText=$positionEditor.Text + [System.Windows.Input.Keyboard]::Focus($positionEditor) | Out-Null + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Input) + if (-not $kit.Context.EditingProperty) { + # The off-screen modal window cannot always acquire OS + # keyboard activation. Route the equivalent WPF focus event + # through the real editor when native Focus() is denied. + $focusEvent = [System.Windows.Input.KeyboardFocusChangedEventArgs]::new( + [System.Windows.Input.Keyboard]::PrimaryDevice, + [Environment]::TickCount, + [System.Windows.Input.Keyboard]::FocusedElement, + $positionEditor) + $focusEvent.RoutedEvent = + [System.Windows.Input.Keyboard]::GotKeyboardFocusEvent + $positionEditor.RaiseEvent($focusEvent) + } + Should-BeTrue $kit.Context.EditingProperty + foreach($key in @([System.Windows.Input.Key]::Left, + [System.Windows.Input.Key]::Delete)){ + $eventArgs=& $raiseControlKey $positionEditor $key + Should-Be $kit.Context.CommandRouter.LastCommand 'PropertyInput' + Should-Be $kit.Context.SelectedAnnotationId $selectedId + Should-Be $kit.Context.Annotations.Count 1 + } + $escape=& $raiseControlKey $positionEditor ` + ([System.Windows.Input.Key]::Escape) + Should-Be $kit.Context.CommandRouter.LastCommand 'CancelPropertyEdit' + Should-BeTrue $escape.Handled + Should-Be $positionEditor.Text $originalText + Should-BeFalse $kit.Context.EditingProperty + Should-Be $kit.Context.SelectedAnnotationId $selectedId + }finally{ + & $clearCanvasContent + } + } + } + + Describe 'Task 7 real Crop routes' { + It 'real Aspect menu applies every preset only through the Apply control' { + try { + & $clearCanvasContent + foreach ($case in @( + [pscustomobject]@{ Name='Free'; X=100; Y=80; Width=400; Height=220 }, + [pscustomobject]@{ Name='Original'; X=135; Y=80; Width=330; Height=220 }, + [pscustomobject]@{ Name='1:1'; X=190; Y=80; Width=220; Height=220 }, + [pscustomobject]@{ Name='4:3'; X=154; Y=80; Width=293; Height=220 }, + [pscustomobject]@{ Name='16:9'; X=105; Y=80; Width=391; Height=220 })) { + & $activateTool 'Crop' + & $raiseCanvasDrag ([System.Windows.Point]::new(100,80)) ` + @([System.Windows.Point]::new(300,190)) ` + ([System.Windows.Point]::new(500,300)) + $committedBefore = $kit.Context.CropRectangle + $historyBefore = $kit.Context.UndoStack.Count + + & $raiseClick $kit.Context.PropertyControls.Aspect.Button + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + & $raiseMenuClick $kit.Context.PropertyControls.Aspect.MenuItems[$case.Name] + Should-Be $kit.Context.CropRectangle $committedBefore + Should-Be $kit.Context.UndoStack.Count $historyBefore + & $invokePropertyEntry $kit.Context.PropertyControls.Apply + + $crop = $kit.Context.CropRectangle + Should-BeTrue ($null -ne $crop) + Should-Be $kit.Context.UndoStack.Count ($historyBefore + 1) + Should-Be $crop.X $case.X + Should-Be $crop.Y $case.Y + Should-Be $crop.Width $case.Width + Should-Be $crop.Height $case.Height + $historyAfterApply = $kit.Context.UndoStack.Count + & $invokePropertyEntry $kit.Context.PropertyControls.Apply + Should-Be $kit.Context.UndoStack.Count $historyAfterApply + & $invokePropertyEntry $kit.Context.PropertyControls.Reset + Should-Be $kit.Context.CropRectangle $null + $historyAfterReset = $kit.Context.UndoStack.Count + & $invokePropertyEntry $kit.Context.PropertyControls.Reset + Should-Be $kit.Context.UndoStack.Count $historyAfterReset + } + + foreach ($portraitCase in @( + [pscustomobject]@{ Name='4:3'; X=5; Y=30; Width=90; Height=120 }, + [pscustomobject]@{ Name='16:9'; X=5; Y=10; Width=90; Height=160 })) { + & $activateTool 'Crop' + & $raiseCanvasDrag ([System.Windows.Point]::new(5,10)) ` + @([System.Windows.Point]::new(50,90)) ` + ([System.Windows.Point]::new(95,170)) + & $raiseClick $kit.Context.PropertyControls.Aspect.Button + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + & $raiseMenuClick ` + $kit.Context.PropertyControls.Aspect.MenuItems[$portraitCase.Name] + & $invokePropertyEntry $kit.Context.PropertyControls.Apply + Should-Be $kit.Context.CropRectangle.X $portraitCase.X + Should-Be $kit.Context.CropRectangle.Y $portraitCase.Y + Should-Be $kit.Context.CropRectangle.Width $portraitCase.Width + Should-Be $kit.Context.CropRectangle.Height $portraitCase.Height + & $invokePropertyEntry $kit.Context.PropertyControls.Reset + } + } finally { + if ($null -ne $kit.Context.CropRectangle) { + & $invokePropertyEntry $kit.Context.PropertyControls.Reset + } + & $clearCanvasContent + } + } + + It 'Apply Escape Reset Undo and Redo use deep non-destructive snapshots' { + try { + & $clearCanvasContent + & $drawRectangle ([System.Windows.Point]::new(60,60)) ` + ([System.Windows.Point]::new(140,120)) + $annotationId = $kit.Context.Annotations[0].Id + & $activateTool 'Crop' + & $raiseCanvasDrag ([System.Windows.Point]::new(100,100)) ` + @([System.Windows.Point]::new(250,200)) ` + ([System.Windows.Point]::new(400,300)) + & $invokePropertyEntry $kit.Context.PropertyControls.Apply + $applied = $kit.Context.CropRectangle + $historyAfterApply = $kit.Context.UndoStack.Count + & $raiseClick ($kit.Win.FindName('UndoBtn')) + Should-Be $kit.Context.CropRectangle $null + & $raiseClick ($kit.Win.FindName('RedoBtn')) + Should-Be $kit.Context.CropRectangle.X $applied.X + Should-Be $kit.Context.CropRectangle.Y $applied.Y + Should-Be $kit.Context.CropRectangle.Width $applied.Width + Should-Be $kit.Context.CropRectangle.Height $applied.Height + Should-Be $kit.Context.Annotations[0].Id $annotationId + + & $raiseCanvasDrag ([System.Windows.Point]::new(200,160)) ` + @([System.Windows.Point]::new(300,240)) ` + ([System.Windows.Point]::new(500,360)) + $kit.Context.GetKeyboardModifiers = { + [System.Windows.Input.ModifierKeys]::None + } + $escape = & $raiseCanvasKey ([System.Windows.Input.Key]::Escape) + Should-BeTrue $escape.Handled + Should-Be $kit.Context.CropRectangle.X $applied.X + Should-Be $kit.Context.CropRectangle.Y $applied.Y + Should-Be $kit.Context.CropRectangle.Width $applied.Width + Should-Be $kit.Context.CropRectangle.Height $applied.Height + Should-Be $kit.Context.UndoStack.Count $historyAfterApply + Should-Be $kit.Context.Draft $null + + & $invokePropertyEntry $kit.Context.PropertyControls.Reset + Should-Be $kit.Context.CropRectangle $null + & $raiseClick ($kit.Win.FindName('UndoBtn')) + Should-Be $kit.Context.CropRectangle.X $applied.X + Should-Be $kit.Context.Annotations[0].Id $annotationId + & $raiseClick ($kit.Win.FindName('RedoBtn')) + Should-Be $kit.Context.CropRectangle $null + Should-Be $kit.Context.Annotations[0].Id $annotationId + } finally { + if ($null -ne $kit.Context.CropRectangle) { + & $invokePropertyEntry $kit.Context.PropertyControls.Reset + } + & $clearCanvasContent + } + } + + It 'a new routed crop edit clears redo and semantic snapshots deep-copy crop points and properties' { + try { + & $clearCanvasContent + $pointRecord = New-SnipAnnotation -Kind Pen -Geometry ([pscustomobject]@{ + Type='Points'; Points=@( + [pscustomobject]@{ X=10; Y=10 }, + [pscustomobject]@{ X=40; Y=50 }) + }) -Color Green -StrokeWidth 4 -Opacity 0.8 ` + -Properties ([ordered]@{ + Style=[pscustomobject]@{ Smoothing=0.5 } + Labels=[System.Collections.ArrayList]@('one','two') + }) -Z 4 + $kit.Context.Annotations.Add($pointRecord) | Out-Null + & $kit.Render + & $activateTool 'Crop' + & $raiseClick $kit.Context.PropertyControls.Aspect.Button + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + & $raiseMenuClick $kit.Context.PropertyControls.Aspect.MenuItems.Free + & $raiseCanvasDrag ([System.Windows.Point]::new(100,100)) ` + @([System.Windows.Point]::new(200,180)) ` + ([System.Windows.Point]::new(300,260)) + & $invokePropertyEntry $kit.Context.PropertyControls.Apply + $appliedReference = $kit.Context.CropRectangle + & $invokePropertyEntry $kit.Context.PropertyControls.Reset + $snapshot = $kit.Context.UndoStack.Peek() + Should-BeTrue ($null -ne $snapshot.CropRectangle) + Should-BeFalse ([object]::ReferenceEquals( + $snapshot.Annotations,$kit.Context.Annotations)) + Should-BeFalse ([object]::ReferenceEquals( + $snapshot.Annotations[0],$kit.Context.Annotations[0])) + Should-BeFalse ([object]::ReferenceEquals( + $snapshot.Annotations[0].Geometry,$kit.Context.Annotations[0].Geometry)) + Should-BeFalse ([object]::ReferenceEquals( + $snapshot.Annotations[0].Geometry.Points, + $kit.Context.Annotations[0].Geometry.Points)) + Should-BeFalse ([object]::ReferenceEquals( + $snapshot.Annotations[0].Properties,$kit.Context.Annotations[0].Properties)) + Should-BeFalse ([object]::ReferenceEquals( + $snapshot.CropRectangle,$appliedReference)) + + & $raiseClick ($kit.Win.FindName('UndoBtn')) + Should-Be $kit.Context.RedoStack.Count 1 + Should-Be $kit.Context.ActiveTool 'Crop' + Should-Be $kit.Context.CropRectangle.X 100 + Should-Be $kit.Context.CropRectangle.Y 100 + Should-Be $kit.Context.CropRectangle.Width 200 + Should-Be $kit.Context.CropRectangle.Height 160 + & $raiseCanvasDown ([System.Windows.Point]::new(220,180)) + Should-Be $kit.Context.Draft.Kind 'Crop' + Should-Be ($null -eq + $kit.Context.Draft.PSObject.Properties['ResizeHandle']) $true + & $raiseCanvasMove ([System.Windows.Point]::new(320,260)) + & $raiseCanvasUp ([System.Windows.Point]::new(420,340)) + Should-Be $kit.Context.Draft.Candidate.X 220 + Should-Be $kit.Context.Draft.Candidate.Y 180 + Should-Be $kit.Context.Draft.Candidate.Width 200 + Should-Be $kit.Context.Draft.Candidate.Height 160 + & $invokePropertyEntry $kit.Context.PropertyControls.Apply + Should-Be $kit.Context.RedoStack.Count 0 + } finally { + if ($null -ne $kit.Context.CropRectangle) { + & $invokePropertyEntry $kit.Context.PropertyControls.Reset + } + & $clearCanvasContent + } + } + + It 'crop overlay maps in source space under zoom and pan with constant-DIP handles' { + try { + & $clearCanvasContent + & $activateTool 'Crop' + & $raiseClick $kit.Context.PropertyControls.Aspect.Button + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + & $raiseMenuClick $kit.Context.PropertyControls.Aspect.MenuItems.Free + & $raiseCanvasDrag ([System.Windows.Point]::new(100,120)) ` + @([System.Windows.Point]::new(250,220)) ` + ([System.Windows.Point]::new(400,320)) + & $invokePropertyEntry $kit.Context.PropertyControls.Apply + foreach ($zoom in @(0.5,1.0,2.0)) { + & $kit.SetZoom $zoom + $kit.Scroller.ScrollToHorizontalOffset(80) + $kit.Scroller.ScrollToVerticalOffset(60) + $kit.Win.UpdateLayout() + + $overlay = @($kit.InteractionLayer.Children | Where-Object { + $null -ne $_.Tag -and $_.Tag.Role -eq 'CropOverlay' + }) | Select-Object -First 1 + Should-BeTrue ($null -ne $overlay) + Should-Be ([System.Windows.Controls.Canvas]::GetLeft($overlay)) 100 + Should-Be ([System.Windows.Controls.Canvas]::GetTop($overlay)) 120 + Should-Be $overlay.Width 300 + Should-Be $overlay.Height 200 + Should-Be ($overlay.Width * $zoom) (300*$zoom) 0.01 + $viewportOrigin = $overlay.TranslatePoint( + [System.Windows.Point]::new(0,0),$kit.Scroller) + Should-Be $viewportOrigin.X ` + (100*$zoom-$kit.Scroller.HorizontalOffset) 1.0 + Should-Be $viewportOrigin.Y ` + (120*$zoom-$kit.Scroller.VerticalOffset) 1.0 + $handles = @($kit.InteractionLayer.Children | Where-Object { + $null -ne $_.Tag -and $_.Tag.Role -eq 'CropHandle' + }) + Should-Be $handles.Count 8 + foreach ($handle in $handles) { + Should-Be ($handle.Width * $zoom) 10 0.01 + } + } + + & $kit.SetZoom 2.0 + Should-Be $kit.Context.Draft $null + Should-Be $kit.Context.CropRectangle.X 100 + Should-Be $kit.Context.CropRectangle.Y 120 + Should-Be $kit.Context.CropRectangle.Width 300 + Should-Be $kit.Context.CropRectangle.Height 200 + $bottomRight = @($kit.InteractionLayer.Children | Where-Object { + $null -ne $_.Tag -and $_.Tag.Role -eq 'CropHandle' -and + $_.Tag.Handle -eq 'BottomRight' + })[0] + Should-Be ([System.Windows.Controls.Canvas]::GetLeft($bottomRight)) 397.5 0.01 + Should-Be ([System.Windows.Controls.Canvas]::GetTop($bottomRight)) 317.5 0.01 + $handleCenterX = [System.Windows.Controls.Canvas]::GetLeft($bottomRight) + + ($bottomRight.Width / 2.0) + $handleCenterY = [System.Windows.Controls.Canvas]::GetTop($bottomRight) + + ($bottomRight.Height / 2.0) + $handleStart = [System.Windows.Point]::new($handleCenterX,$handleCenterY) + Should-Be $handleStart.X 400 0.01 + Should-Be $handleStart.Y 320 0.01 + Should-Be (& $kit.GetCropHandleAt $handleStart) 'BottomRight' + & $raiseCanvasDown $handleStart + Should-Be $kit.Context.Draft.Anchor.X 400 0.01 + Should-Be $kit.Context.Draft.Anchor.Y 320 0.01 + Should-Be $kit.Context.Draft.ResizeHandle 'BottomRight' + & $raiseCanvasMove ([System.Windows.Point]::new(450,360)) + & $raiseCanvasUp ([System.Windows.Point]::new(450,360)) + Should-Be $kit.Context.CropRectangle.Width 300 + Should-Be $kit.Context.CropRectangle.Height 200 + Should-Be $kit.Context.Draft.Candidate.Width 350 + Should-Be $kit.Context.Draft.Candidate.Height 240 + & $invokePropertyEntry $kit.Context.PropertyControls.Apply + Should-Be $kit.Context.CropRectangle.Width 350 + Should-Be $kit.Context.CropRectangle.Height 240 + } finally { + & $kit.SetZoom 1.0 + if ($null -ne $kit.Context.CropRectangle) { + & $invokePropertyEntry $kit.Context.PropertyControls.Reset + } + & $clearCanvasContent + } + } + + It 'Wide Compact and Narrow keep Crop Aspect Reset and Apply on actual property routes' { + try { + foreach ($case in @( + [pscustomobject]@{ Width=1200; Height=700; Mode='Wide'; Overflow=$false }, + [pscustomobject]@{ Width=900; Height=600; Mode='Compact'; Overflow=$true }, + [pscustomobject]@{ Width=760; Height=540; Mode='Narrow'; Overflow=$true })) { + $kit.Win.Width=$case.Width; $kit.Win.Height=$case.Height + $kit.Win.UpdateLayout() + & $activateTool 'Select' + Should-Be $kit.Context.ActiveTool 'Select' + Should-BeTrue (@($kit.Context.ToolOrder) -contains 'Select') + Should-BeTrue (@($kit.Context.ToolOrder) -contains 'Crop') + & $activateTool 'Crop' + & $kit.SetResponsiveMode $case.Width $case.Height + Should-Be $kit.ResponsiveMode.Value $case.Mode + Should-Be $kit.Context.ActiveTool 'Crop' + Should-BeTrue ($null -ne $kit.Context.PropertyControls.Aspect) + Should-BeTrue ($null -ne $kit.Context.PropertyControls.Reset) + Should-BeTrue ($null -ne $kit.Context.PropertyControls.Apply) + $oldAspectController = $kit.Context.PropertyControls.Aspect.Controller + & $raiseClick $kit.Context.PropertyControls.Aspect.Button + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + Should-BeTrue $oldAspectController.State.IsExpanded + Should-BeTrue $oldAspectController.State.IsMenuWindowRegistered + & $raiseMenuClick $kit.Context.PropertyControls.Aspect.MenuItems.Free + Should-BeFalse $oldAspectController.State.IsExpanded + Should-BeFalse $oldAspectController.State.IsMenuWindowRegistered + Should-BeTrue $kit.Context.PropertyControls.Aspect.Button.IsKeyboardFocused + + & $raiseCanvasDrag ([System.Windows.Point]::new(120,100)) ` + @([System.Windows.Point]::new(220,180)) ` + ([System.Windows.Point]::new(320,260)) + if ($case.Overflow) { + & $raiseClick $kit.Context.PropertyControls.MoreProperties.Element + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.Invoke( + [Action]{}, [System.Windows.Threading.DispatcherPriority]::Background) + Should-BeTrue $kit.Context.PropertyControls.MoreProperties.Controller.State.IsExpanded + Should-BeTrue ($kit.Context.PropertyControls.Apply.Element -is + [System.Windows.Controls.MenuItem]) + Should-BeTrue $kit.Context.PropertyControls.MoreProperties.Controller.State.IsMenuWindowRegistered + & $invokePropertyEntry $kit.Context.PropertyControls.Apply + Should-BeFalse $kit.Context.PropertyControls.MoreProperties.Controller.State.IsExpanded + } else { + Should-BeTrue ($kit.Context.PropertyControls.Apply.Element -is + [System.Windows.Controls.Button]) + & $invokePropertyEntry $kit.Context.PropertyControls.Apply + } + Should-BeTrue ($null -ne $kit.Context.CropRectangle) + & $invokePropertyEntry $kit.Context.PropertyControls.Reset + Should-Be $kit.Context.CropRectangle $null + + Set-SnipPropertyIsland -Context $kit.Context -Tool Crop | Out-Null + Should-BeFalse $oldAspectController.State.HandlersAttached + Should-BeFalse $kit.Context.TransientMenus.Contains($oldAspectController) + } + } finally { + & $kit.SetResponsiveMode 1200 700 + & $clearCanvasContent + } + } + + It 'crop routes preserve the original Bitmap and one frozen BitmapSource' { + $originalClipboardSetter = $kit.Context.ClipboardSetter + $originalSaveBitmap = $kit.Context.SaveBitmap + try { + & $clearCanvasContent + $bitmapReference = $kit.Context.Bitmap + $sourceReference = $kit.Context.BitmapSource + $sentinel = $kit.Context.Bitmap.GetPixel(0,0).ToArgb() + & $activateTool 'Crop' + & $raiseCanvasDrag ([System.Windows.Point]::new(120,90)) ` + @([System.Windows.Point]::new(300,220)) ` + ([System.Windows.Point]::new(520,360)) + & $invokePropertyEntry $kit.Context.PropertyControls.Apply + $outputs = [System.Collections.ArrayList]::new() + $kit.Context.ClipboardSetter = { + param($image) + $outputs.Add([pscustomobject]@{ + Kind='Copy'; Width=$image.PixelWidth; Height=$image.PixelHeight + }) | Out-Null + }.GetNewClosure() + $kit.Context.SaveBitmap = { + param($image) + $outputs.Add([pscustomobject]@{ + Kind='Save'; Width=$image.Width; Height=$image.Height + }) | Out-Null + 'synthetic-uncropped.png' + }.GetNewClosure() + & $raiseMenuClick $kit.SplitControls.Copy.MenuItems.CopyKeepOpen + & $raiseClick ($kit.Win.FindName('SaveBtn')) + Should-Be $outputs.Count 2 + Should-Be $outputs[0].Kind 'Copy' + Should-Be $outputs[0].Width 1200 + Should-Be $outputs[0].Height 800 + Should-Be $outputs[1].Kind 'Save' + Should-Be $outputs[1].Width 1200 + Should-Be $outputs[1].Height 800 + & $invokePropertyEntry $kit.Context.PropertyControls.Reset + & $raiseClick ($kit.Win.FindName('UndoBtn')) + & $raiseClick ($kit.Win.FindName('RedoBtn')) + + Should-BeTrue ([object]::ReferenceEquals($kit.Context.Bitmap,$bitmapReference)) + Should-BeTrue ([object]::ReferenceEquals($kit.Context.BitmapSource,$sourceReference)) + Should-BeTrue $kit.Context.BitmapSource.IsFrozen + Should-Be $kit.Context.Bitmap.Width 1200 + Should-Be $kit.Context.Bitmap.Height 800 + Should-Be $kit.Context.Bitmap.GetPixel(0,0).ToArgb() $sentinel + } finally { + $kit.Context.ClipboardSetter = $originalClipboardSetter + $kit.Context.SaveBitmap = $originalSaveBitmap + if ($null -ne $kit.Context.CropRectangle) { + & $invokePropertyEntry $kit.Context.PropertyControls.Reset + } + & $clearCanvasContent + } + } + } Describe 'Zoom' { It 'starts at fit-to-viewport scale (<=1)' { @@ -204,12 +6256,12 @@ Show-PreviewWindow -Bitmap $bmp -TestAction { Should-Be $kit.State.Annotations.Count 1 $a = $kit.State.Annotations[0] - Should-Be $a.Type 'highlight' + Should-Be $a.Kind 'Highlight' Should-Be $a.Color 'Yellow' - Should-Be $a.X 100 - Should-Be $a.Y 150 - Should-Be $a.W 200 - Should-Be $a.H 200 + Should-Be $a.Geometry.X 100 + Should-Be $a.Geometry.Y 150 + Should-Be $a.Geometry.Width 200 + Should-Be $a.Geometry.Height 200 $kit.HighlightBtn.IsChecked = $false } } @@ -227,10 +6279,10 @@ Show-PreviewWindow -Bitmap $bmp -TestAction { Should-Be $kit.State.Annotations.Count 1 $a = $kit.State.Annotations[0] - Should-Be $a.Type 'rect' + Should-Be $a.Kind 'Rectangle' Should-Be $a.Color 'Red' - Should-Be $a.W 150 - Should-Be $a.H 200 + Should-Be $a.Geometry.Width 150 + Should-Be $a.Geometry.Height 200 $kit.RectBtn.IsChecked = $false } } @@ -248,9 +6300,9 @@ Show-PreviewWindow -Bitmap $bmp -TestAction { Should-Be $kit.State.Annotations.Count 1 $a = $kit.State.Annotations[0] - Should-Be $a.Type 'arrow' - Should-Be $a.X 100; Should-Be $a.Y 100 - Should-Be $a.W 300; Should-Be $a.H 200 + Should-Be $a.Kind 'Arrow' + Should-Be $a.Geometry.Start.X 100; Should-Be $a.Geometry.Start.Y 100 + Should-Be $a.Geometry.End.X 400; Should-Be $a.Geometry.End.Y 300 $kit.ArrowBtn.IsChecked = $false } It 'discards arrows shorter than 6 canvas units' { @@ -276,10 +6328,10 @@ Show-PreviewWindow -Bitmap $bmp -TestAction { & $kit.UpdateDraw ([System.Windows.Point]::new(110, 120)) & $kit.FinishDraw $a = $kit.State.Annotations[0] - Should-Be $a.X 10 - Should-Be $a.Y 20 - Should-Be $a.W 100 - Should-Be $a.H 100 + Should-Be $a.Geometry.X 10 + Should-Be $a.Geometry.Y 20 + Should-Be $a.Geometry.Width 100 + Should-Be $a.Geometry.Height 100 $kit.HighlightBtn.IsChecked = $false } } @@ -382,7 +6434,7 @@ Show-PreviewWindow -Bitmap $bmp -TestAction { & $kit.FinishDraw $kit.RectBtn.IsChecked = $true Should-Be $kit.State.Annotations.Count 1 - Should-Be $kit.State.Annotations[0].Type 'highlight' + Should-Be $kit.State.Annotations[0].Kind 'Highlight' $kit.RectBtn.IsChecked = $false } } @@ -416,11 +6468,11 @@ Show-PreviewWindow -Bitmap $bmp -TestAction { Should-BeFalse $kit.State.EditingText Should-Be $kit.State.Annotations.Count 1 $a = $kit.State.Annotations[0] - Should-Be $a.Type 'text' - Should-Be $a.Text 'hello' - Should-Be $a.X 120 - Should-Be $a.Y 340 - Should-Be $a.FontSize 18 + Should-Be $a.Kind 'Text' + Should-Be $a.Properties.Text 'hello' + Should-Be $a.Geometry.X 120 + Should-Be $a.Geometry.Y 340 + Should-Be $a.Properties.FontSize 18 $kit.TextBtn.IsChecked = $false } It 'PickColor while editing text updates TextBox foreground live and commit uses new color' { @@ -456,7 +6508,7 @@ Show-PreviewWindow -Bitmap $bmp -TestAction { # Simulate right-click → pick Red from context menu → mutate + re-render $kit.State.Annotations[0].Color = 'Red' & $kit.Render - $rendered = $kit.HighlightLayer.Children | Where-Object { $_ -is [System.Windows.Controls.TextBlock] } | Select-Object -First 1 + $rendered = $kit.AnnotationLayer.Children | Where-Object { $_ -is [System.Windows.Controls.TextBlock] } | Select-Object -First 1 Should-BeTrue ($rendered -ne $null) $expected = $kit.Palette['Red'] $actual = $rendered.Foreground.Color @@ -482,7 +6534,7 @@ Show-PreviewWindow -Bitmap $bmp -TestAction { $tb.Text = "text-$c" & $tb.Tag Should-Be $kit.State.Annotations[0].Color $c - Should-Be $kit.State.Annotations[0].Text "text-$c" + Should-Be $kit.State.Annotations[0].Properties.Text "text-$c" } $kit.TextBtn.IsChecked = $false } @@ -496,9 +6548,9 @@ Show-PreviewWindow -Bitmap $bmp -TestAction { $a = $kit.State.Annotations[0] # Canvas coords == image pixels (LayoutTransform only affects render). # Bounds.Scale is always 1.0 in this design, so FontSize stays 18. - Should-Be $a.X 50 - Should-Be $a.Y 100 - Should-Be $a.FontSize 18 + Should-Be $a.Geometry.X 50 + Should-Be $a.Geometry.Y 100 + Should-Be $a.Properties.FontSize 18 $kit.TextBtn.IsChecked = $false } } @@ -506,7 +6558,13 @@ Show-PreviewWindow -Bitmap $bmp -TestAction { Describe 'Full click dispatch via HandleMouseDown' { It 'click with no tool active starts pan' { $kit.State.Panning = $false - foreach ($b in $kit.HighlightBtn, $kit.RectBtn, $kit.ArrowBtn, $kit.TextBtn) { $b.IsChecked = $false } + & $activateTool 'Highlight' + $peer = [System.Windows.Automation.Peers.ToggleButtonAutomationPeer]::new( + $kit.HighlightBtn) + $provider = $peer.GetPattern( + [System.Windows.Automation.Peers.PatternInterface]::Toggle) + ([System.Windows.Automation.Provider.IToggleProvider]$provider).Toggle() + Should-Be $kit.Context.ActiveTool $null $sv = [System.Windows.Point]::new(100, 100) $hl = [System.Windows.Point]::new(100, 100) & $kit.HandleMouseDown $hl $sv @@ -537,8 +6595,8 @@ Show-PreviewWindow -Bitmap $bmp -TestAction { $tb.Text = 'click-text' & $tb.Tag Should-Be $kit.State.Annotations.Count 1 - Should-Be $kit.State.Annotations[0].Type 'text' - Should-Be $kit.State.Annotations[0].Text 'click-text' + Should-Be $kit.State.Annotations[0].Kind 'Text' + Should-Be $kit.State.Annotations[0].Properties.Text 'click-text' $kit.TextBtn.IsChecked = $false } It 'click outside image bounds is ignored' { diff --git a/Test-SnipIT.ps1 b/Test-SnipIT.ps1 index 27231b1..7833496 100644 --- a/Test-SnipIT.ps1 +++ b/Test-SnipIT.ps1 @@ -2,9 +2,18 @@ # Run: pwsh -NoProfile -File ./Test-SnipIT.ps1 $ErrorActionPreference = 'Stop' +$scriptUnderTest = if ([string]::IsNullOrWhiteSpace($env:SNIPIT_SCRIPT_UNDER_TEST)) { + Join-Path $PSScriptRoot 'SnipIT.ps1' +} else { + [IO.Path]::GetFullPath($env:SNIPIT_SCRIPT_UNDER_TEST) +} +if (-not (Test-Path -LiteralPath $scriptUnderTest -PathType Leaf)) { + throw "SNIPIT_SCRIPT_UNDER_TEST does not exist: $scriptUnderTest" +} + # Dot-source SnipIT.ps1 in CoreOnly mode: loads pure functions, then early-returns # before any Windows-only Bootstrap / PInvoke / UI code runs. -. (Join-Path $PSScriptRoot 'SnipIT.ps1') -CoreOnly +. $scriptUnderTest -CoreOnly $script:Pass = 0; $script:Fail = 0; $script:Failures = @() @@ -280,15 +289,16 @@ It 'deep-copies a single highlight annotation' { $src = @([pscustomobject]@{ Type='highlight'; Color='yellow'; X=10; Y=20; W=100; H=50; Text=$null; FontSize=0 }) $r = Copy-AnnotationList $src ShouldBe $r.Count 1 - ShouldBe $r[0].Type 'highlight' - ShouldBe $r[0].X 10 + ShouldBe $r[0].Kind 'Highlight' + ShouldBe $r[0].Geometry.Type 'Bounds' + ShouldBe $r[0].Geometry.X 10 } It 'mutations on the copy do not affect the original' { $orig = @([pscustomobject]@{ Type='rect'; Color='red'; X=5; Y=5; W=50; H=50; Text=$null; FontSize=0 }) $copy = Copy-AnnotationList $orig - $copy[0].X = 999 + $copy[0].Geometry.X = 999 ShouldBe $orig[0].X 5 # original untouched - ShouldBe $copy[0].X 999 + ShouldBe $copy[0].Geometry.X 999 } It 'preserves mixed annotation types (highlight + rect + arrow + text)' { $src = @( @@ -299,12 +309,14 @@ It 'preserves mixed annotation types (highlight + rect + arrow + text)' { ) $r = Copy-AnnotationList $src ShouldBe $r.Count 4 - ShouldBe $r[0].Type 'highlight' - ShouldBe $r[1].Type 'rect' - ShouldBe $r[2].Type 'arrow' - ShouldBe $r[3].Type 'text' - ShouldBe $r[3].Text 'hello' - ShouldBe $r[3].FontSize 24 + ShouldBe $r[0].Kind 'Highlight' + ShouldBe $r[1].Kind 'Rectangle' + ShouldBe $r[2].Kind 'Arrow' + ShouldBe $r[3].Kind 'Text' + ShouldBe $r[2].Geometry.Type 'Line' + ShouldBe $r[2].Geometry.End.X 50 + ShouldBe $r[3].Properties.Text 'hello' + ShouldBe $r[3].Properties.FontSize 24 } It 'undo-then-redo round trip: a sequence of copies yields identical content' { $original = @( @@ -315,8 +327,10 @@ It 'undo-then-redo round trip: a sequence of copies yields identical content' { $snap2 = Copy-AnnotationList $snap1 # redo entry after "undo" $snap3 = Copy-AnnotationList $snap2 # restored after "redo" ShouldBe $snap3.Count 2 - ShouldBe $snap3[0].X 1 - ShouldBe $snap3[1].Text 'hi' + ShouldBe $snap3[0].Geometry.X 1 + ShouldBe $snap3[1].Properties.Text 'hi' + ShouldBe $snap3[0].Id $snap1[0].Id + ShouldBe $snap3[1].Id $snap1[1].Id # And the original is untouched throughout ShouldBe $original[0].X 1 } @@ -594,13 +608,18 @@ It 'passes the capture from the current iteration into the preview handler' { ShouldBe $state.received[2] 3 } -Describe 'Full-screen and window capture New-snip paths (RAN-14 regression)' -# Structural guard: the pure Invoke-CaptureLoop tests above cover the contract, -# but the actual bug was at the call sites (Invoke-FullScreenCapture and -# Invoke-WindowCapture grabbed one bitmap outside the loop and passed that same -# disposed reference back into Show-PreviewWindow on iteration 2+). Inspect the -# source directly so this pattern cannot silently return. +Describe 'Capture entry points serialize through the coordinator' +# Structural guard: Task 3 replaces the nested Invoke-CaptureLoop call sites +# with one coordinator. Keep the old pure loop tests above as the RAN-14 +# regression proof, but require every compatibility entry point to submit a +# request instead of opening a capture or Preview surface itself. $script:SnipITSource = Get-Content -Raw (Join-Path $PSScriptRoot 'SnipIT.ps1') +$script:SnipITTokens = $null +$script:SnipITParseErrors = $null +$script:SnipITAst = [System.Management.Automation.Language.Parser]::ParseInput( + $script:SnipITSource, + [ref]$script:SnipITTokens, + [ref]$script:SnipITParseErrors) function Get-FunctionBody { param([string]$Name) $pattern = "(?ms)^function\s+$([regex]::Escape($Name))\s*\{(.*?)^\}" @@ -608,30 +627,2280 @@ function Get-FunctionBody { if (-not $m.Success) { throw "Function '$Name' not found in SnipIT.ps1" } return $m.Groups[1].Value } -foreach ($fn in 'Invoke-FullScreenCapture', 'Invoke-WindowCapture') { - It "$fn routes New-snip through Invoke-CaptureLoop" { - $body = Get-FunctionBody -Name $fn - ShouldBeTrue ($body -match '\bInvoke-CaptureLoop\b') - } - It "$fn does not grab a bitmap outside the preview loop" { - # The RAN-14 bug pattern: `$bmp = New-ScreenBitmap ...` at the top of - # the function, followed by a do/while that reuses `$bmp` across - # iterations. The fix moves the New-ScreenBitmap call inside a - # per-iteration factory scriptblock, so the only New-ScreenBitmap - # occurrence in the body must sit inside a `{ ... }` block. +function Get-SnipFunctionAst { + param([string]$Name) + $matches = @($script:SnipITAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $Name + }.GetNewClosure(), $true)) + if ($matches.Count -ne 1) { + throw "Expected one function AST for '$Name', found $($matches.Count)" + } + $matches[0] +} +function Get-SnipCommandAsts { + param( + [string]$FunctionName, + [string]$CommandName + ) + $scope = if ([string]::IsNullOrWhiteSpace($FunctionName)) { + $script:SnipITAst + } else { + (Get-SnipFunctionAst -Name $FunctionName).Body + } + @($scope.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq $CommandName + }.GetNewClosure(), $true)) +} +function Get-SnipCommandParameterValue { + param( + [System.Management.Automation.Language.CommandAst]$Command, + [string]$Name + ) + $elements = $Command.CommandElements + for ($index = 0; $index -lt $elements.Count; $index++) { + $element = $elements[$index] + if ($element -isnot [System.Management.Automation.Language.CommandParameterAst] -or + $element.ParameterName -ne $Name) { + continue + } + $valueAst = $element.Argument + if ($null -eq $valueAst -and $index + 1 -lt $elements.Count -and + $elements[$index + 1] -isnot [System.Management.Automation.Language.CommandParameterAst]) { + $valueAst = $elements[$index + 1] + } + if ($valueAst -is [System.Management.Automation.Language.StringConstantExpressionAst]) { + return $valueAst.Value + } + if ($null -ne $valueAst) { return $valueAst.Extent.Text } + return $null + } + return $null +} +function Test-SnipCommandParameter { + param( + [System.Management.Automation.Language.CommandAst]$Command, + [string]$Name + ) + foreach ($element in $Command.CommandElements) { + if ($element -is [System.Management.Automation.Language.CommandParameterAst] -and + $element.ParameterName -eq $Name) { + return $true + } + } + $false +} +function Get-SnipContainingFunctionName { + param([System.Management.Automation.Language.Ast]$Ast) + $current = $Ast.Parent + while ($null -ne $current) { + if ($current -is [System.Management.Automation.Language.FunctionDefinitionAst]) { + return $current.Name + } + $current = $current.Parent + } + return $null +} +foreach ($fn in 'Invoke-SmartCapture', 'Invoke-FullScreenCapture', 'Invoke-WindowCapture') { + It "$fn submits through Request-SnipCapture" { $body = Get-FunctionBody -Name $fn - # Iteratively strip innermost braces until none are left so nested - # scriptblocks (try/finally inside $factory = { ... }) collapse. - do { - $prev = $body - $body = [regex]::Replace($body, '(?s)\{[^{}]*\}', '') - } while ($body -ne $prev) - ShouldBeFalse ($body -match 'New-ScreenBitmap') - } - It ('{0} does not reuse $bmp across a do/while iteration' -f $fn) { + ShouldBeTrue ($body -match '\bRequest-SnipCapture\b') + } + It "$fn cannot invoke capture or Preview surfaces directly" { $body = Get-FunctionBody -Name $fn - ShouldBeFalse ($body -match '(?ms)\}\s*while\s*\(\s*\$again\s*\)') + ShouldBeFalse ($body -match '\b(?:Invoke-CaptureLoop|Show-SmartOverlay|Show-PreviewWindow|New-ScreenBitmap|Invoke-PendingCapture)\b') + } +} + +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]) +} + +function New-TestDisposeProbe { + param([string]$Id = ([guid]::NewGuid().ToString())) + $probe = [pscustomobject]@{ + Id = $Id + DisposeCount = 0 + Disposed = $false + TouchCount = 0 + } + $probe | Add-Member -MemberType ScriptMethod -Name Touch -Value { + if ($this.Disposed) { throw [ObjectDisposedException]::new($this.Id) } + $this.TouchCount++ + } + $probe | Add-Member -MemberType ScriptMethod -Name Dispose -Value { + if ($this.Disposed) { throw [InvalidOperationException]::new("Double dispose: $($this.Id)") } + $this.Disposed = $true + $this.DisposeCount++ + } + $probe +} + +function New-TestCloseSurface { + param([hashtable]$State) + [pscustomobject]@{ + Close = { + param([string]$Result) + $State.CloseCount++ + $State.LastResult = $Result + }.GetNewClosure() + } +} + +Describe 'Serialized capture coordinator runtime contract' +It 'creates immutable-shape requests with identity, mode, delay, source, and submission time' { + $request = New-SnipCaptureRequest -Mode Smart -Delay ([timespan]::FromSeconds(3)) -Source Hotkey + ShouldBeTrue ($request.Id -is [guid]) + ShouldBe $request.Mode 'Smart' + ShouldBe $request.Delay ([timespan]::FromSeconds(3)) + ShouldBe $request.Source 'Hotkey' + ShouldBeTrue ($request.SubmittedAt -is [datetimeoffset]) + ShouldBe (@($request.PSObject.Properties.Name) -join ',') 'Id,Mode,Delay,Source,SubmittedAt' +} + +It 'keeps only the latest pending request and marks the active surface Preempted' { + $posts = [System.Collections.ArrayList]::new() + $surfaceState = @{ CloseCount = 0; LastResult = $null } + $coordinator = New-SnipCaptureCoordinator -Post { + param($work) + [void]$posts.Add($work) + }.GetNewClosure() + Request-SnipCapture -Coordinator $coordinator -Mode Smart -Source Hotkey | Out-Null + $coordinator.Phase = 'Previewing' + $coordinator.ActiveSurface = New-TestCloseSurface -State $surfaceState + + Request-SnipCapture -Coordinator $coordinator -Mode Full -Source Tray | Out-Null + Request-SnipCapture -Coordinator $coordinator -Mode Window -Source Widget | Out-Null + + ShouldBe $coordinator.PendingRequest.Mode 'Window' + ShouldBe $coordinator.PendingRequest.Source 'Widget' + ShouldBe $surfaceState.LastResult 'Preempted' + ShouldBe $surfaceState.CloseCount 1 +} + +It 'owns a one-shot delay whose callback re-enters Request-SnipCapture' { + $posts = [System.Collections.ArrayList]::new() + $delayState = @{ Callback = $null; Starts = 0; Cancels = 0 } + $services = [pscustomobject]@{ + StartDelay = { + param($delay, $callback, $request, $coordinator) + $delayState.Starts++ + $delayState.Callback = $callback + [pscustomobject]@{ Id = $request.Id } + }.GetNewClosure() + CancelDelay = { + param($handle) + $delayState.Cancels++ + }.GetNewClosure() + } + $coordinator = New-SnipCaptureCoordinator -Services $services -Post { + param($work) + [void]$posts.Add($work) + }.GetNewClosure() + + $request = Request-SnipCapture -Coordinator $coordinator -Mode Smart ` + -Delay ([timespan]::FromSeconds(5)) -Source TrayDelay + ShouldBe $coordinator.Phase 'DelayPending' + ShouldBe $coordinator.ActiveRequest.Id $request.Id + ShouldBe $delayState.Starts 1 + ShouldBe $posts.Count 0 + + & $delayState.Callback + ShouldBe $delayState.Cancels 1 + ShouldBe $coordinator.Phase 'CaptureStarting' + ShouldBe $coordinator.ActiveRequest.Id $request.Id + ShouldBe $posts.Count 1 +} + +It 'cancels a delayed request before any capture factory runs' { + $state = @{ Cancels = 0; Captures = 0 } + $services = [pscustomobject]@{ + StartDelay = { param($delay,$callback,$request,$coordinator) [pscustomobject]@{} } + CancelDelay = { param($handle) $state.Cancels++ }.GetNewClosure() + SmartCapture = { param($coordinator,$request) $state.Captures++; $null }.GetNewClosure() + } + $coordinator = New-SnipCaptureCoordinator -Services $services -Post { param($work) & $work } + Request-SnipCapture -Coordinator $coordinator -Mode Smart ` + -Delay ([timespan]::FromSeconds(3)) -Source TrayDelay | Out-Null + + Close-SnipActiveSurface -Coordinator $coordinator -Result UserCancelled | Out-Null + ShouldBe $state.Cancels 1 + ShouldBe $state.Captures 0 + ShouldBe $coordinator.Phase 'Idle' + ShouldBe $coordinator.ActiveRequest $null +} + +It 'queues during Completing without interrupting output, then preempts after Save completes' { + $posts = [System.Collections.ArrayList]::new() + $surfaceState = @{ CloseCount = 0; LastResult = $null } + $coordinator = New-SnipCaptureCoordinator -Post { + param($work) + [void]$posts.Add($work) + }.GetNewClosure() + $coordinator.Phase = 'Completing' + $coordinator.ActiveRequest = New-SnipCaptureRequest -Mode Smart -Source Hotkey + $coordinator.ActiveSurface = New-TestCloseSurface -State $surfaceState + + Request-SnipCapture -Coordinator $coordinator -Mode Window -Source Tray | Out-Null + ShouldBe $surfaceState.CloseCount 0 + ShouldBe $coordinator.PendingRequest.Mode 'Window' + + Complete-SnipSurface -Coordinator $coordinator -Result Completed -Operation Save | Out-Null + ShouldBe $surfaceState.CloseCount 1 + ShouldBe $surfaceState.LastResult 'Preempted' + ShouldBe $coordinator.Phase 'Previewing' + ShouldBe $posts.Count 0 +} + +It 'returns a recoverable Copy failure to Previewing without losing edits' { + $surfaceState = @{ CloseCount = 0; LastResult = $null } + $coordinator = New-SnipCaptureCoordinator -Post { param($work) & $work } + $coordinator.Phase = 'Completing' + $coordinator.ActiveRequest = New-SnipCaptureRequest -Mode Smart -Source Hotkey + $coordinator.ActiveSurface = New-TestCloseSurface -State $surfaceState + + Complete-SnipSurface -Coordinator $coordinator -Result Failed -Operation Copy | Out-Null + ShouldBe $coordinator.Phase 'Previewing' + ShouldBe $surfaceState.CloseCount 0 + ShouldBeTrue ($null -ne $coordinator.ActiveRequest) +} + +It 'never recurses even when Post and a reentrant Preview request run synchronously' { + $state = @{ + Captures = [System.Collections.ArrayList]::new() + Probes = [System.Collections.ArrayList]::new() + } + $services = [pscustomobject]@{ + FullCapture = { + param($coordinator,$request) + [void]$state.Captures.Add($request.Source) + $probe = New-TestDisposeProbe -Id $request.Source + [void]$state.Probes.Add($probe) + $probe + }.GetNewClosure() + Preview = { + param($bitmap,$accept,$coordinator,$request) + & $accept + $bitmap.Touch() + if ($request.Source -eq 'First') { + Request-SnipCapture -Coordinator $coordinator -Mode Full -Source Second | Out-Null + } + $bitmap.Dispose() + if ($request.Source -eq 'First') { 'Preempted' } else { 'UserCancelled' } + }.GetNewClosure() + } + $coordinator = New-SnipCaptureCoordinator -Services $services -Post { param($work) & $work } + + Request-SnipCapture -Coordinator $coordinator -Mode Full -Source First | Out-Null + + ShouldBe $coordinator.MaxPumpDepth 1 + ShouldBe ($state.Captures -join ',') 'First,Second' + ShouldBe $coordinator.Phase 'Idle' + ShouldBe $state.Probes.Count 2 + foreach ($probe in $state.Probes) { ShouldBe $probe.DisposeCount 1 } +} + +It 'recovers deterministically when Post throws before invoking the pump' { + $coordinator = New-SnipCaptureCoordinator -Post { + param($work) + throw 'post failed' + } + $thrown = $null + try { + Request-SnipCapture -Coordinator $coordinator -Mode Full -Source ThrowingPost | Out-Null + } catch { + $thrown = $_ + } + + ShouldBeTrue ($null -eq $thrown) + ShouldBeFalse $coordinator.PumpScheduled + ShouldBeFalse $coordinator.PumpRescheduleRequested + ShouldBe $coordinator.Phase 'Recovering' + ShouldBeTrue ($null -ne $coordinator.LastError) +} + +It 'recovers deterministically when Post explicitly declines the pump' { + $coordinator = New-SnipCaptureCoordinator -Post { + param($work) + return $false + } + + Request-SnipCapture -Coordinator $coordinator -Mode Full -Source DeclinedPost | Out-Null + + ShouldBeFalse $coordinator.PumpScheduled + ShouldBeFalse $coordinator.PumpRescheduleRequested + ShouldBe $coordinator.Phase 'Recovering' + ShouldBeTrue ($null -ne $coordinator.LastError) +} + +It 'resumes after transient Post failure and runs only the latest pending request' { + foreach ($failureKind in 'Throw','Decline') { + $state = @{ + PostCalls = 0 + Queue = [System.Collections.ArrayList]::new() + Captures = [System.Collections.ArrayList]::new() + Probes = [System.Collections.ArrayList]::new() + } + $services = [pscustomobject]@{ + FullCapture = { + param($coordinator,$request) + [void]$state.Captures.Add($request.Source) + $probe = New-TestDisposeProbe -Id "$failureKind-$($request.Source)" + [void]$state.Probes.Add($probe) + $probe + }.GetNewClosure() + Preview = { + param($bitmap,$accept,$coordinator,$request) + & $accept + $bitmap.Dispose() + 'UserCancelled' + } + } + $coordinator = New-SnipCaptureCoordinator -Services $services -Post { + param($work) + $state.PostCalls++ + if ($state.PostCalls -eq 1) { + if ($failureKind -eq 'Throw') { throw 'transient post failure' } + return $false + } + [void]$state.Queue.Add($work) + return $true + }.GetNewClosure() + + Request-SnipCapture -Coordinator $coordinator -Mode Full -Source First | Out-Null + ShouldBe $coordinator.Phase 'Recovering' + ShouldBeFalse $coordinator.PumpScheduled + ShouldBeFalse $coordinator.PumpRescheduleRequested + ShouldBe $state.Queue.Count 0 + + Request-SnipCapture -Coordinator $coordinator -Mode Full -Source Second | Out-Null + Request-SnipCapture -Coordinator $coordinator -Mode Full -Source Latest | Out-Null + ShouldBe $coordinator.PendingRequest.Source 'Latest' + ShouldBe $state.Queue.Count 1 + + while ($state.Queue.Count -gt 0) { + $work = $state.Queue[0] + $state.Queue.RemoveAt(0) + & $work + } + + ShouldBe ($state.Captures -join ',') 'Latest' + ShouldBe $state.Probes.Count 1 + ShouldBe $state.Probes[0].DisposeCount 1 + ShouldBe $state.PostCalls 3 + ShouldBe $coordinator.MaxPumpDepth 1 + ShouldBe $coordinator.PendingRequest $null + ShouldBeFalse $coordinator.PumpScheduled + ShouldBe $coordinator.Phase 'Idle' + } +} + +It 'retries persistent Post decline only once per new external request' { + $state = @{ PostCalls = 0 } + $coordinator = New-SnipCaptureCoordinator -Post { + param($work) + $state.PostCalls++ + return $false + }.GetNewClosure() + + Request-SnipCapture -Coordinator $coordinator -Mode Full -Source First | Out-Null + Request-SnipCapture -Coordinator $coordinator -Mode Full -Source Second | Out-Null + Request-SnipCapture -Coordinator $coordinator -Mode Full -Source Latest | Out-Null + + ShouldBe $state.PostCalls 3 + ShouldBe $coordinator.PendingRequest.Source 'Latest' + ShouldBeFalse $coordinator.PumpScheduled + ShouldBeFalse $coordinator.PumpRescheduleRequested + ShouldBe $coordinator.Phase 'Recovering' +} + +It 'does not mistake synchronous Post output for a decline after work ran' { + $state = @{ Captures = 0 } + $services = [pscustomobject]@{ + FullCapture = { + param($coordinator,$request) + $state.Captures++ + New-TestDisposeProbe -Id synchronous-post + }.GetNewClosure() + Preview = { + param($bitmap,$accept,$coordinator,$request) + & $accept + $bitmap.Dispose() + 'UserCancelled' + } + } + $coordinator = New-SnipCaptureCoordinator -Services $services -Post { + param($work) + & $work + $false + } + + Request-SnipCapture -Coordinator $coordinator -Mode Full -Source SynchronousPost | Out-Null + + ShouldBe $state.Captures 1 + ShouldBe $coordinator.Phase 'Idle' + ShouldBeFalse $coordinator.PumpScheduled + ShouldBe $coordinator.MaxPumpDepth 1 + ShouldBe $coordinator.LastError $null +} + +It 'disposes a stale capture before a posted pending transaction begins' { + $state = @{ + First = $null + Second = $null + SecondSawCleanup = $false + } + $services = [pscustomobject]@{ + FullCapture = { + param($coordinator,$request) + if ($request.Source -eq 'First') { + $state.First = New-TestDisposeProbe -Id First + Request-SnipCapture -Coordinator $coordinator -Mode Full -Source Second | Out-Null + return $state.First + } + $state.SecondSawCleanup = ($state.First.DisposeCount -eq 1) + $state.Second = New-TestDisposeProbe -Id Second + $state.Second + }.GetNewClosure() + Preview = { + param($bitmap,$accept,$coordinator,$request) + & $accept + $bitmap.Dispose() + 'UserCancelled' + } + } + $coordinator = New-SnipCaptureCoordinator -Services $services -Post { param($work) & $work } + + Request-SnipCapture -Coordinator $coordinator -Mode Full -Source First | Out-Null + ShouldBe $state.First.DisposeCount 1 + ShouldBeTrue $state.SecondSawCleanup + ShouldBe $state.Second.DisposeCount 1 + ShouldBe $coordinator.MaxPumpDepth 1 +} + +It 're-resolves active-window capture data for every Window transaction' { + $state = @{ ResolveCount = 0; Seen = [System.Collections.ArrayList]::new() } + $services = [pscustomobject]@{ + WindowCapture = { + param($coordinator,$request) + $state.ResolveCount++ + $probe = New-TestDisposeProbe -Id ("window-$($state.ResolveCount)") + $probe | Add-Member -NotePropertyName TargetVersion -NotePropertyValue $state.ResolveCount + $probe + }.GetNewClosure() + Preview = { + param($bitmap,$accept,$coordinator,$request) + & $accept + [void]$state.Seen.Add($bitmap.TargetVersion) + $bitmap.Dispose() + 'UserCancelled' + }.GetNewClosure() + } + $coordinator = New-SnipCaptureCoordinator -Services $services -Post { param($work) & $work } + + Request-SnipCapture -Coordinator $coordinator -Mode Window -Source Tray | Out-Null + Request-SnipCapture -Coordinator $coordinator -Mode Window -Source Widget | Out-Null + ShouldBe $state.ResolveCount 2 + ShouldBe ($state.Seen -join ',') '1,2' +} + +It 'preserves a pending request through failed recovery and cleans owned state first' { + $state = @{ Stale = New-TestDisposeProbe -Id Stale; Captured = $false } + $services = [pscustomobject]@{ + FullCapture = { + param($coordinator,$request) + ShouldBe $state.Stale.DisposeCount 1 + $state.Captured = $true + New-TestDisposeProbe -Id Recovered + }.GetNewClosure() + Preview = { + param($bitmap,$accept,$coordinator,$request) + & $accept + $bitmap.Dispose() + 'UserCancelled' + } + } + $coordinator = New-SnipCaptureCoordinator -Services $services -Post { param($work) & $work } + $coordinator.Phase = 'Selecting' + $coordinator.ActiveRequest = New-SnipCaptureRequest -Mode Smart -Source First + $coordinator.PendingRequest = New-SnipCaptureRequest -Mode Full -Source Pending + $coordinator.OwnedBitmap = $state.Stale + + Complete-SnipSurface -Coordinator $coordinator -Result Failed -Operation Selection | Out-Null + ShouldBeTrue $state.Captured + ShouldBe $state.Stale.DisposeCount 1 + ShouldBe $coordinator.Phase 'Idle' + ShouldBe $coordinator.PendingRequest $null +} + +It 'stops idempotently, cancels delay, closes surfaces, disposes ownership, and rejects later requests' { + $state = @{ Cancels = 0; CloseCount = 0; LastResult = $null } + $services = [pscustomobject]@{ + CancelDelay = { param($handle) $state.Cancels++ }.GetNewClosure() + } + $coordinator = New-SnipCaptureCoordinator -Services $services -Post { param($work) & $work } + $coordinator.Phase = 'Selecting' + $coordinator.ActiveRequest = New-SnipCaptureRequest -Mode Smart -Source Hotkey + $coordinator.DelayHandle = [pscustomobject]@{} + $coordinator.OwnedBitmap = New-TestDisposeProbe -Id Shutdown + $coordinator.ActiveSurface = New-TestCloseSurface -State $state + + Stop-SnipCaptureCoordinator -Coordinator $coordinator + Stop-SnipCaptureCoordinator -Coordinator $coordinator + # A modal surface returns after the first shutdown signal. The follow-up + # stop must finish state cleanup without signalling or disposing twice. + $coordinator.ActiveSurface = $null + $coordinator.SurfaceCloseRequested = $false + Stop-SnipCaptureCoordinator -Coordinator $coordinator + $rejected = Request-SnipCapture -Coordinator $coordinator -Mode Full -Source Tray + + ShouldBe $coordinator.Phase 'ShuttingDown' + ShouldBeTrue $coordinator.ShutdownRequested + ShouldBe $state.Cancels 1 + ShouldBe $state.CloseCount 1 + ShouldBe $state.LastResult 'Shutdown' + ShouldBe $coordinator.OwnedBitmap $null + ShouldBe $coordinator.ActiveRequest $null + ShouldBe $rejected $null +} + +It 'ignores Failed and UserCancelled completions that arrive after shutdown' { + foreach ($lateResult in 'Failed','UserCancelled') { + $posts = [System.Collections.ArrayList]::new() + $coordinator = New-SnipCaptureCoordinator -Post { + param($work) + [void]$posts.Add($work) + }.GetNewClosure() + $coordinator.Phase = 'Selecting' + $coordinator.ActiveRequest = New-SnipCaptureRequest -Mode Smart -Source Shutdown + + Stop-SnipCaptureCoordinator -Coordinator $coordinator + $postsBefore = $posts.Count + Complete-SnipSurface -Coordinator $coordinator -Result $lateResult ` + -Operation Selection | Out-Null + + ShouldBeTrue $coordinator.ShutdownRequested + ShouldBe $coordinator.Phase 'ShuttingDown' + ShouldBe $posts.Count $postsBefore + ShouldBeFalse $coordinator.PumpScheduled + ShouldBe $coordinator.ActiveRequest $null + } +} + +It 'has no legacy recursive pending-capture state or direct delayed surface calls' { + ShouldBeFalse ($script:SnipITSource -match '\$script:PendingCaptureType') + ShouldBeFalse ($script:SnipITSource -match '\bInvoke-PendingCapture\b') + $delayBody = Get-FunctionBody -Name Start-DelayedCapture + ShouldBeTrue ($delayBody -match '\bRequest-SnipCapture\b') + ShouldBeFalse ($delayBody -match '\b(?:Invoke-SmartCapture|Invoke-FullScreenCapture|Invoke-WindowCapture)\b') +} + +It 'routes function-owned capture ingress through exact Request-SnipCapture ASTs' { + ShouldBe $script:SnipITParseErrors.Count 0 + + $widgetCalls = @(Get-SnipCommandAsts -FunctionName Show-FloatingWidget ` + -CommandName Request-SnipCapture) + ShouldBe $widgetCalls.Count 0 + $widgetBody = Get-FunctionBody -Name Show-FloatingWidget + ShouldBeFalse ($widgetBody -match '\b(?:Invoke-SmartCapture|Invoke-FullScreenCapture|Invoke-WindowCapture)\b') + $widgetServiceCalls = [regex]::Matches( + $widgetBody, "&\s*\`$submitRequest\s+'(Smart|Full|Window)'") + ShouldBe $widgetServiceCalls.Count 3 + ShouldBe (($widgetServiceCalls | ForEach-Object { + $_.Groups[1].Value + } | Sort-Object) -join ',') 'Full,Smart,Window' + + $trayMenuCalls = @(Get-SnipCommandAsts -FunctionName New-SnipTrayMenu ` + -CommandName Request-SnipCapture) + ShouldBe $trayMenuCalls.Count 0 + $trayMenuBody = Get-FunctionBody -Name New-SnipTrayMenu + ShouldBeFalse ($trayMenuBody -match '\b(?:Invoke-SmartCapture|Invoke-FullScreenCapture|Invoke-WindowCapture)\b') + + $delayCalls = @(Get-SnipCommandAsts -FunctionName Start-DelayedCapture ` + -CommandName Request-SnipCapture) + ShouldBe $delayCalls.Count 1 + ShouldBe (Get-SnipCommandParameterValue -Command $delayCalls[0] -Name Source) 'TrayDelay' + ShouldBeTrue (Test-SnipCommandParameter -Command $delayCalls[0] -Name Delay) + + $previewCalls = @(Get-SnipCommandAsts -FunctionName New-SnipRuntimeCaptureServices ` + -CommandName Request-SnipCapture) + $previewNewCalls = @($previewCalls | Where-Object { + (Get-SnipCommandParameterValue -Command $_ -Name Source) -eq 'PreviewNew' + }) + ShouldBe $previewNewCalls.Count 1 + ShouldBe (Get-SnipCommandParameterValue -Command $previewNewCalls[0] -Name Mode) 'Smart' + + $requestCalls = @(Get-SnipCommandAsts -FunctionName Request-SnipCapture ` + -CommandName Request-SnipCapture) + $delayElapsedCalls = @($requestCalls | Where-Object { + Test-SnipCommandParameter -Command $_ -Name DelayElapsed + }) + ShouldBe $delayElapsedCalls.Count 1 +} + +It 'routes root hotkey and tray callbacks through exact Request-SnipCapture ASTs' { + $rootCalls = @(Get-SnipCommandAsts -CommandName Request-SnipCapture | Where-Object { + $null -eq (Get-SnipContainingFunctionName -Ast $_) + }) + $hotkeyCalls = @($rootCalls | Where-Object { + (Get-SnipCommandParameterValue -Command $_ -Name Source) -eq 'Hotkey' + }) + ShouldBe $hotkeyCalls.Count 1 + ShouldBe (Get-SnipCommandParameterValue -Command $hotkeyCalls[0] -Name Mode) 'Smart' + + $trayCalls = @($rootCalls | Where-Object { + (Get-SnipCommandParameterValue -Command $_ -Name Source) -eq 'Tray' + }) + ShouldBe $trayCalls.Count 0 + + $serviceAdapterCalls = @($rootCalls | Where-Object { + (Get-SnipCommandParameterValue -Command $_ -Name Source) -eq '$Source' + }) + ShouldBe $serviceAdapterCalls.Count 1 + ShouldBe (Get-SnipCommandParameterValue -Command $serviceAdapterCalls[0] -Name Mode) '$Mode' + ShouldBeTrue (Test-SnipCommandParameter -Command $serviceAdapterCalls[0] -Name Delay) +} + +$script:MixedDpiMonitorDescriptors = @( + [pscustomobject][ordered]@{ + Id = 'left-125'; X = -1600; Y = 0; Width = 1600; Height = 900 + DpiX = 120; DpiY = 120; IsPrimary = $false + }, + [pscustomobject][ordered]@{ + Id = 'primary-100'; X = 0; Y = 0; Width = 1920; Height = 1080 + DpiX = 96; DpiY = 96; IsPrimary = $true + } +) + +Describe 'Per-monitor Smart overlay geometry contract' +It 'normalizes mixed-DPI descriptors without changing monitor order' { + $layouts = @(Get-SnipMonitorLayouts -MonitorDescriptors $script:MixedDpiMonitorDescriptors) + + ShouldBe $layouts.Count 2 + ShouldBe (($layouts | ForEach-Object Id) -join ',') 'left-125,primary-100' + ShouldBe $layouts[0].Index 0 + ShouldBe $layouts[0].PhysicalX -1600 + ShouldBe $layouts[0].PhysicalWidth 1600 + ShouldBe $layouts[0].ScaleX 1.25 + ShouldBe $layouts[0].ScaleY 1.25 + ShouldBe $layouts[0].DipWidth 1280 + ShouldBe $layouts[0].DipHeight 720 + ShouldBe $layouts[1].Index 1 + ShouldBe $layouts[1].ScaleX 1 + ShouldBe $layouts[1].DipWidth 1920 +} + +It 'round-trips monitor-local DIPs through each layout transform' { + $layouts = @(Get-SnipMonitorLayouts -MonitorDescriptors $script:MixedDpiMonitorDescriptors) + foreach ($layout in $layouts) { + $dip = ConvertTo-SnipDipPoint -PhysicalX ($layout.PhysicalX + 125) ` + -PhysicalY ($layout.PhysicalY + 250) ` + -MonitorPhysicalX $layout.PhysicalX -MonitorPhysicalY $layout.PhysicalY ` + -ScaleX $layout.ScaleX -ScaleY $layout.ScaleY + $physical = ConvertTo-SnipPhysicalPoint -DipX $dip.X -DipY $dip.Y ` + -MonitorPhysicalX $layout.PhysicalX -MonitorPhysicalY $layout.PhysicalY ` + -ScaleX $layout.ScaleX -ScaleY $layout.ScaleY + ShouldBe $physical.X ($layout.PhysicalX + 125) + ShouldBe $physical.Y ($layout.PhysicalY + 250) + } +} + +It 'rejects a malformed monitor descriptor before any layout is emitted' { + $threw = $false + try { + Get-SnipMonitorLayouts -MonitorDescriptors @( + [pscustomobject]@{ Id='broken'; X=0; Y=0; Width=100; Height=100; DpiX=96 } + ) | Out-Null + } catch { + $threw = $true + ShouldBeTrue ($_.Exception.Message -match 'DpiY') + } + ShouldBeTrue $threw +} + +It 'rejects zero-area and non-positive-DPI monitor descriptors' { + foreach ($case in @( + @{ Descriptor = [pscustomobject]@{ Id='zero'; X=0; Y=0; Width=0; Height=100; DpiX=96; DpiY=96 }; Pattern = 'Width' }, + @{ Descriptor = [pscustomobject]@{ Id='dpi'; X=0; Y=0; Width=100; Height=100; DpiX=0; DpiY=96 }; Pattern = 'DpiX' } + )) { + $threw = $false + try { Get-SnipMonitorLayouts -MonitorDescriptors @($case.Descriptor) | Out-Null } + catch { + $threw = $true + ShouldBeTrue ($_.Exception.Message -match $case.Pattern) + } + ShouldBeTrue $threw + } +} + +It 'maps a cross-monitor physical selection without a seam' { + $layouts = @(Get-SnipMonitorLayouts -MonitorDescriptors $script:MixedDpiMonitorDescriptors) + $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 + ShouldBe $parts[0].MonitorId 'left-125' + ShouldBe $parts[0].PhysicalX -500 + ShouldBe $parts[0].PhysicalWidth 500 + ShouldBe $parts[0].DipX 880 + ShouldBe $parts[0].DipWidth 400 + ShouldBe $parts[1].MonitorId 'primary-100' + ShouldBe $parts[1].PhysicalX 0 + ShouldBe $parts[1].PhysicalWidth 500 + ShouldBe $parts[1].DipX 0 + ShouldBe $parts[1].DipWidth 500 +} + +It 'uses half-open bounds at the mixed-DPI monitor seam' { + $layouts = @(Get-SnipMonitorLayouts -MonitorDescriptors $script:MixedDpiMonitorDescriptors) + $left = @(Get-SnipOverlayIntersections ` + -Rectangle ([pscustomobject]@{ X=-1; Y=10; Width=1; Height=20 }) ` + -MonitorLayouts $layouts) + $right = @(Get-SnipOverlayIntersections ` + -Rectangle ([pscustomobject]@{ X=0; Y=10; Width=1; Height=20 }) ` + -MonitorLayouts $layouts) + $both = @(Get-SnipOverlayIntersections ` + -Rectangle ([pscustomobject]@{ X=-1; Y=10; Width=2; Height=20 }) ` + -MonitorLayouts $layouts) + + ShouldBe $left.Count 1 + ShouldBe $left[0].MonitorId 'left-125' + ShouldBe $right.Count 1 + ShouldBe $right[0].MonitorId 'primary-100' + ShouldBe $both.Count 2 + ShouldBe (($both | Measure-Object PhysicalWidth -Sum).Sum) 2 +} + +It 'returns intersections in monitor-layout order' { + $layouts = @(Get-SnipMonitorLayouts -MonitorDescriptors @( + $script:MixedDpiMonitorDescriptors[1], + $script:MixedDpiMonitorDescriptors[0] + )) + $parts = @(Get-SnipOverlayIntersections ` + -Rectangle ([pscustomobject]@{ X=-10; Y=10; Width=20; Height=20 }) ` + -MonitorLayouts $layouts) + ShouldBe (($parts | ForEach-Object MonitorId) -join ',') 'primary-100,left-125' +} + +It 'returns no intersections for zero-area selection geometry' { + $layouts = @(Get-SnipMonitorLayouts -MonitorDescriptors $script:MixedDpiMonitorDescriptors) + foreach ($rectangle in @( + [pscustomobject]@{ X=0; Y=0; Width=0; Height=10 }, + [pscustomobject]@{ X=0; Y=0; Width=10; Height=0 } + )) { + ShouldBe @(Get-SnipOverlayIntersections -Rectangle $rectangle ` + -MonitorLayouts $layouts).Count 0 + } +} + +It 'rejects malformed selection geometry' { + $layouts = @(Get-SnipMonitorLayouts -MonitorDescriptors $script:MixedDpiMonitorDescriptors) + $threw = $false + try { + Get-SnipOverlayIntersections ` + -Rectangle ([pscustomobject]@{ X=0; Y=0; Width=10 }) ` + -MonitorLayouts $layouts | Out-Null + } catch { + $threw = $true + ShouldBeTrue ($_.Exception.Message -match 'Height') + } + ShouldBeTrue $threw +} + +It 'normalizes fractional physical bounds once with away-from-zero rounding' { + $layouts = @(Get-SnipMonitorLayouts -MonitorDescriptors @( + [pscustomobject]@{ + Id='fractional-left'; X=-1600.5; Y=-0.5 + Width=1600.5; Height=900.5; DpiX=120.0; DpiY=120.0 + }, + [pscustomobject]@{ + Id='fractional-primary'; X=0.0; Y=0.0 + Width=1919.5; Height=1079.5; DpiX=96.0; DpiY=96.0 + } + )) + + ShouldBe $layouts[0].PhysicalX -1601 + ShouldBe $layouts[0].PhysicalY -1 + ShouldBe $layouts[0].PhysicalWidth 1601 + ShouldBe $layouts[0].PhysicalHeight 901 + ShouldBe $layouts[0].PhysicalRight 0 + ShouldBeTrue ($layouts[0].PhysicalX -is [int]) + ShouldBeTrue ($layouts[0].PhysicalWidth -is [int]) + ShouldBe $layouts[1].PhysicalX 0 + ShouldBe $layouts[1].PhysicalWidth 1920 + ShouldBe $layouts[1].DpiX 96.0 + + $parts = @(Get-SnipOverlayIntersections ` + -Rectangle ([pscustomobject]@{ X=-1; Y=10; Width=2; Height=20 }) ` + -MonitorLayouts $layouts) + ShouldBe $parts.Count 2 + ShouldBe (($parts | Measure-Object PhysicalWidth -Sum).Sum) 2 +} + +It 'rejects a positive fractional monitor size that normalizes to zero pixels' { + $threw = $false + try { + Get-SnipMonitorLayouts -MonitorDescriptors @( + [pscustomobject]@{ + Id='subpixel'; X=0; Y=0; Width=0.4; Height=100 + DpiX=96; DpiY=96 + } + ) | Out-Null + } catch { + $threw = $true + ShouldBeTrue ($_.Exception.Message -match 'Width') + } + ShouldBeTrue $threw +} + +Describe 'Stable annotation record contract' +It 'freezes the canonical Core API parameter surfaces' { + $expected = [ordered]@{ + 'New-SnipAnnotation' = @('Kind','Geometry','Color','StrokeWidth','Opacity','Properties','Z','Id') + 'Copy-SnipAnnotation' = @('Annotation') + 'Find-SnipAnnotation' = @('Annotations','ImageX','ImageY','Tolerance') + 'Select-SnipAnnotation' = @('Annotations','ImageX','ImageY','Tolerance') + 'Move-SnipAnnotation' = @('Annotation','DeltaX','DeltaY','SourceWidth','SourceHeight') + 'Resize-SnipAnnotation' = @('Annotation','Handle','DeltaX','DeltaY','SourceWidth','SourceHeight') + 'Get-SnipCropRectangle' = @('Candidate','SourceWidth','SourceHeight','Preset') + 'Set-SnipCrop' = @('Action','Candidate','SourceWidth','SourceHeight','Preset') + 'New-SnipEditorSnapshot'= @('Annotations','CropRectangle') + } + foreach ($entry in $expected.GetEnumerator()) { + $command = Get-Command $entry.Key -CommandType Function -ErrorAction Stop + foreach ($parameterName in $entry.Value) { + ShouldBeTrue $command.Parameters.ContainsKey($parameterName) + } + } +} +It 'New-SnipAnnotation creates every canonical field and distinct nonempty IDs' { + $geometry = [pscustomobject]@{ Type='Bounds'; X=10; Y=20; Width=30; Height=40 } + $first = New-SnipAnnotation -Kind Rectangle -Geometry $geometry -Color '#FF8069' ` + -StrokeWidth 3 -Opacity 0.75 -Properties ([ordered]@{ Fill='#00000000' }) -Z 4 + $second = New-SnipAnnotation -Kind Rectangle -Geometry $geometry -Color '#FF8069' ` + -StrokeWidth 3 -Opacity 0.75 -Properties ([ordered]@{ Fill='#00000000' }) -Z 4 + + ShouldBe (($first.PSObject.Properties.Name) -join ',') ` + 'Id,Kind,Geometry,Color,StrokeWidth,Opacity,Properties,Z' + ShouldBeTrue (-not [string]::IsNullOrWhiteSpace([string]$first.Id)) + ShouldBeFalse ($first.Id -eq $second.Id) + $parsedId = [guid]::Empty + ShouldBeTrue ([guid]::TryParse([string]$first.Id, [ref]$parsedId)) + ShouldBe $first.Kind 'Rectangle' + ShouldBe $first.Geometry.Type 'Bounds' + ShouldBe $first.Z 4 +} +It 'New-SnipAnnotation preserves an explicit import ID without aliasing inputs' { + $id = [guid]::NewGuid().ToString() + $geometry = [pscustomobject]@{ + Type='Points' + Points=@([pscustomobject]@{X=1;Y=2}, [pscustomobject]@{X=3;Y=4}) + } + $properties = [ordered]@{ Metadata=[pscustomobject]@{ Tags=[Collections.ArrayList]@('a','b') } } + $record = New-SnipAnnotation -Kind Pen -Geometry $geometry -Color '#A8EFD7' ` + -StrokeWidth 2 -Opacity 1 -Properties $properties -Z 2 -Id $id + + ShouldBe $record.Id $id + $record.Geometry.Points[0].X = 99 + $record.Properties.Metadata.Tags[0] = 'changed' + ShouldBe $geometry.Points[0].X 1 + ShouldBe $properties.Metadata.Tags[0] 'a' +} +It 'Copy-SnipAnnotation rejects a canonical record without a stable ID' { + $canonicalWithoutId = [pscustomobject][ordered]@{ + Kind='Rectangle' + Geometry=[pscustomobject]@{Type='Bounds';X=1;Y=2;Width=3;Height=4} + Color='red'; StrokeWidth=1; Opacity=1; Properties=@{}; Z=0 + } + $threw = $false + try { + Copy-SnipAnnotation -Annotation $canonicalWithoutId | Out-Null + } catch { + $threw = $_.Exception -is [ArgumentException] + } + ShouldBeTrue $threw +} +It 'Copy-SnipAnnotation preserves ID and deeply copies nested semantic values' { + $id = [guid]::NewGuid().ToString() + $source = New-SnipAnnotation -Kind Pen -Geometry ([pscustomobject]@{ + Type='Points' + Points=[Collections.ArrayList]@( + [pscustomobject]@{ X=4; Y=5 }, + [pscustomobject]@{ X=8; Y=9 } + ) + }) -Color '#FFD36B' -StrokeWidth 5 -Opacity 0.4 -Properties ([ordered]@{ + Labels=[Collections.ArrayList]@('one', [pscustomobject]@{ Value=2 }) + Nested=@{ Values=@(3,4); Unrelated='preserved' } + }) -Z 7 -Id $id + + $copy = Copy-SnipAnnotation -Annotation $source + ShouldBe $copy.Id $id + ShouldBeFalse ([object]::ReferenceEquals($source, $copy)) + ShouldBeFalse ([object]::ReferenceEquals($source.Geometry, $copy.Geometry)) + ShouldBeFalse ([object]::ReferenceEquals($source.Geometry.Points, $copy.Geometry.Points)) + ShouldBeFalse ([object]::ReferenceEquals($source.Properties, $copy.Properties)) + ShouldBeFalse ([object]::ReferenceEquals($source.Properties.Labels, $copy.Properties.Labels)) + ShouldBe $copy.Properties.Nested.Unrelated 'preserved' + + $copy.Geometry.Points[0].X = 40 + $copy.Properties.Labels[1].Value = 20 + $copy.Properties.Nested.Values[0] = 30 + ShouldBe $source.Geometry.Points[0].X 4 + ShouldBe $source.Properties.Labels[1].Value 2 + ShouldBe $source.Properties.Nested.Values[0] 3 +} +It 'rejects disposable semantic properties without taking ownership' { + $cache = [IO.MemoryStream]::new() + try { + $threw = $false + try { + New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds';X=1;Y=2;Width=3;Height=4 + }) -Color red -StrokeWidth 1 -Opacity 1 -Properties ([ordered]@{ + Metadata='preserved'; PrivacyCache=$cache + }) -Z 0 | Out-Null + } catch { + $threw = $_.Exception -is [ArgumentException] + } + ShouldBeTrue $threw + ShouldBeTrue $cache.CanWrite + } finally { + $cache.Dispose() + } +} +It 'rejects unsupported mutable reference properties rather than aliasing them' { + $builder = [Text.StringBuilder]::new('source') + $source = [pscustomobject][ordered]@{ + Id=[guid]::NewGuid().ToString(); Kind='Rectangle' + Geometry=[pscustomobject]@{Type='Bounds';X=1;Y=2;Width=3;Height=4} + Color='red'; StrokeWidth=1; Opacity=1 + Properties=[ordered]@{Builder=$builder; Metadata='preserved'}; Z=0 + } + $threw = $false + try { + Copy-SnipAnnotation -Annotation $source | Out-Null + } catch { + $threw = $_.Exception -is [ArgumentException] + } + ShouldBeTrue $threw + ShouldBe $builder.ToString() 'source' +} +It 'canonical copies and editor snapshots isolate supported scalar-key nested maps' { + $nested = [Collections.Specialized.OrderedDictionary]::new() + $nested.Add('Labels', [Collections.ArrayList]@( + 'one', [pscustomobject]@{ Value=2; Enabled=$true } + )) + $nested.Add([int]7, [ordered]@{ Name='seven'; Values=@(3,4) }) + $annotation = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds';X=1;Y=2;Width=30;Height=40 + }) -Color red -StrokeWidth 1 -Opacity 1 -Properties ([ordered]@{ + Nested=$nested; FontSize=[int]14; Visible=$true; Weight=[decimal]1.5 + }) -Z 0 + + $copy = Copy-SnipAnnotation -Annotation $annotation + $snapshot = New-SnipEditorSnapshot -Annotations @($annotation) + $annotationSeven = ($annotation.Properties.Nested.GetEnumerator() | + Where-Object { $_.Key -eq [int]7 } | Select-Object -First 1).Value + $snapshotSeven = ($snapshot.Annotations[0].Properties.Nested.GetEnumerator() | + Where-Object { $_.Key -eq [int]7 } | Select-Object -First 1).Value + ShouldBeFalse ([object]::ReferenceEquals($annotation.Properties.Nested, $copy.Properties.Nested)) + ShouldBeFalse ([object]::ReferenceEquals($annotation.Properties.Nested, $snapshot.Annotations[0].Properties.Nested)) + ShouldBeFalse ([object]::ReferenceEquals($annotation.Properties.Nested['Labels'], $copy.Properties.Nested['Labels'])) + ShouldBeFalse ([object]::ReferenceEquals($annotationSeven, $snapshotSeven)) + $copy.Properties.Nested['Labels'][1].Value = 20 + $snapshotSeven.Values[0] = 30 + ShouldBe $annotation.Properties.Nested['Labels'][1].Value 2 + ShouldBe $annotationSeven.Values[0] 3 + ShouldBe $annotation.Properties.FontSize 14 + ShouldBe $annotation.Properties.Visible $true + ShouldBe $annotation.Properties.Weight ([decimal]1.5) +} +It 'rejects disposable and mutable dictionary keys without changing their source state' { + $stream = [IO.MemoryStream]::new() + $builder = [Text.StringBuilder]::new('source-key') + try { + $results = [Collections.ArrayList]::new() + foreach ($key in @($stream, $builder)) { + $payload = [pscustomobject]@{ Name='source-value' } + $properties = [Collections.Specialized.OrderedDictionary]::new() + $properties.Add($key, $payload) + $source = [pscustomobject][ordered]@{ + Id=[guid]::NewGuid().ToString();Kind='Rectangle' + Geometry=[pscustomobject]@{Type='Bounds';X=1;Y=2;Width=3;Height=4} + Color='red';StrokeWidth=1;Opacity=1;Properties=$properties;Z=0 + } + $threw = $false + try { + Copy-SnipAnnotation -Annotation $source | Out-Null + } catch { + $threw = $_.Exception -is [ArgumentException] + } + [void]$results.Add($threw) + ShouldBe $properties.Count 1 + ShouldBeTrue ([object]::ReferenceEquals($properties[$key], $payload)) + } + ShouldBeTrue $stream.CanWrite + ShouldBe $builder.ToString() 'source-key' + ShouldBe ($results -join ',') 'True,True' + } finally { + $stream.Dispose() + } +} +It 'canonical copies reject a value type that carries a disposable reference without taking ownership' { + $stream = [IO.MemoryStream]::new() + try { + $pair = [Collections.Generic.KeyValuePair[string,IO.MemoryStream]]::new('Cache', $stream) + $source = [pscustomobject][ordered]@{ + Id=[guid]::NewGuid().ToString();Kind='Rectangle' + Geometry=[pscustomobject]@{Type='Bounds';X=1;Y=2;Width=3;Height=4} + Color='red';StrokeWidth=1;Opacity=1 + Properties=[ordered]@{ Pair=$pair; Metadata='source' };Z=0 + } + $threw = $false + try { + Copy-SnipAnnotation -Annotation $source | Out-Null + } catch { + $threw = $_.Exception -is [ArgumentException] + } + ShouldBeTrue $stream.CanWrite + ShouldBeTrue ([object]::ReferenceEquals($source.Properties.Pair.Value, $stream)) + ShouldBe $source.Properties.Metadata 'source' + ShouldBeTrue $threw + } finally { + $stream.Dispose() + } +} +It 'editor snapshots reject a value type that carries a reference without aliasing source state' { + $builder = [Text.StringBuilder]::new('snapshot-source') + $pair = [Collections.Generic.KeyValuePair[string,Text.StringBuilder]]::new('Builder', $builder) + $source = [pscustomobject][ordered]@{ + Id=[guid]::NewGuid().ToString();Kind='Rectangle' + Geometry=[pscustomobject]@{Type='Bounds';X=1;Y=2;Width=3;Height=4} + Color='red';StrokeWidth=1;Opacity=1 + Properties=[ordered]@{ Pair=$pair; Metadata='source' };Z=0 + } + $threw = $false + try { + New-SnipEditorSnapshot -Annotations @($source) | Out-Null + } catch { + $threw = $_.Exception -is [ArgumentException] + } + ShouldBe $builder.ToString() 'snapshot-source' + ShouldBeTrue ([object]::ReferenceEquals($source.Properties.Pair.Value, $builder)) + ShouldBe $source.Properties.Metadata 'source' + ShouldBeTrue $threw +} +It 'normalizes point geometry with a linear collection builder' { + $command = Get-Command ConvertTo-SnipAnnotationGeometry -CommandType Function + $plusEquals = @($command.ScriptBlock.Ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Operator -eq [Management.Automation.Language.TokenKind]::PlusEquals + }, $true)) + ShouldBe $plusEquals.Count 0 +} +It 'Copy-AnnotationList normalizes legacy records and retains ArrayList caller shape' { + $legacyId = [guid]::NewGuid().ToString() + $legacy = [pscustomobject]@{ + Id=$legacyId; Type='arrow'; Color='red'; X=2; Y=3; W=10; H=-2 + Text=$null; FontSize=0 + } + $canonical = New-SnipAnnotation -Kind Text -Geometry ([pscustomobject]@{ + Type='TextBounds'; X=20; Y=30; Width=40; Height=10 + }) -Color='green' -StrokeWidth 1 -Opacity 1 -Properties ([ordered]@{ + Text='hello'; FontSize=14 + }) -Z 5 + $copy = Copy-AnnotationList -Annotations @($legacy, $canonical) + + ShouldBeTrue ($copy -is [Collections.ArrayList]) + ShouldBe $copy.Count 2 + ShouldBe $copy[0].Id $legacyId + ShouldBe $copy[0].Kind 'Arrow' + ShouldBe $copy[0].Geometry.Type 'Line' + ShouldBe $copy[0].Geometry.Start.X 2 + ShouldBe $copy[0].Geometry.End.X 12 + ShouldBe $copy[0].Geometry.End.Y 1 + ShouldBe $copy[1].Id $canonical.Id + ShouldBeFalse ([object]::ReferenceEquals($canonical.Properties, $copy[1].Properties)) +} +It 'normalizes no-ID legacy records once with stable IDs and canonical defaults' { + $legacy = @( + [pscustomobject]@{Type='rect';Color='red';X=1;Y=2;W=10;H=12;Text=$null;FontSize=0} + [pscustomobject]@{Type='arrow';Color='green';X=20;Y=22;W=5;H=6;Text=$null;FontSize=0} + ) + $normalized = Copy-AnnotationList -Annotations $legacy + $firstId = [guid]::Empty + $secondId = [guid]::Empty + ShouldBeTrue ([guid]::TryParse([string]$normalized[0].Id, [ref]$firstId)) + ShouldBeTrue ([guid]::TryParse([string]$normalized[1].Id, [ref]$secondId)) + ShouldBeFalse ($normalized[0].Id -eq $normalized[1].Id) + ShouldBe $normalized[0].Kind 'Rectangle' + ShouldBe $normalized[0].Geometry.Type 'Bounds' + ShouldBe $normalized[1].Kind 'Arrow' + ShouldBe $normalized[1].Geometry.Type 'Line' + foreach ($annotation in $normalized) { + ShouldBe $annotation.StrokeWidth 1 + ShouldBe $annotation.Opacity 1 + ShouldBeFalse ($null -eq $annotation.Properties) + ShouldBe $annotation.Z 0 + } + + $again = Copy-AnnotationList -Annotations $normalized + ShouldBe $again[0].Id $normalized[0].Id + ShouldBe $again[1].Id $normalized[1].Id + ShouldBe (Select-SnipAnnotation -Annotations $again -ImageX 5 -ImageY 5 -Tolerance 0) $again[0].Id + ShouldBe (Find-SnipAnnotation -Annotations $again -ImageX 22 -ImageY 24 -Tolerance 1).Id $again[1].Id +} +It 'editor snapshots preserve IDs and isolate annotations and crop records' { + $annotation = New-SnipAnnotation -Kind Pen -Geometry ([pscustomobject]@{ + Type='Points'; Points=@([pscustomobject]@{X=3;Y=4},[pscustomobject]@{X=20;Y=10}) + }) -Color red -StrokeWidth 2 -Opacity 1 -Properties ([ordered]@{ + Metadata=[pscustomobject]@{Tags=[Collections.ArrayList]@('one','two')} + }) -Z 0 + $crop = [pscustomobject]@{ X=5; Y=6; Width=70; Height=50 } + $snapshot = New-SnipEditorSnapshot -Annotations @($annotation) -CropRectangle $crop + + ShouldBe (($snapshot.PSObject.Properties.Name) -join ',') 'Version,Annotations,CropRectangle' + ShouldBe $snapshot.Version 1 + ShouldBeTrue ($snapshot.Annotations -is [Collections.ArrayList]) + ShouldBe $snapshot.Annotations[0].Id $annotation.Id + ShouldBeFalse ([object]::ReferenceEquals($snapshot.Annotations[0], $annotation)) + ShouldBeFalse ([object]::ReferenceEquals($snapshot.Annotations[0].Geometry.Points, $annotation.Geometry.Points)) + ShouldBeFalse ([object]::ReferenceEquals($snapshot.Annotations[0].Properties.Metadata, $annotation.Properties.Metadata)) + ShouldBeFalse ([object]::ReferenceEquals($snapshot.CropRectangle, $crop)) + $snapshot.Annotations[0].Geometry.Points[0].X = 99 + $snapshot.Annotations[0].Properties.Metadata.Tags[0] = 'changed' + $snapshot.CropRectangle.X = 88 + ShouldBe $annotation.Geometry.Points[0].X 3 + ShouldBe $annotation.Properties.Metadata.Tags[0] 'one' + ShouldBe $crop.X 5 +} + +Describe 'Annotation hit and selection contract' +It 'Find-SnipAnnotation chooses greatest Z and the later list member on a tie' { + $low = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds'; X=0; Y=0; Width=40; Height=40 + }) -Color low -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 1 + $tieEarlier = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds'; X=5; Y=5; Width=30; Height=30 + }) -Color earlier -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 9 + $tieLater = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds'; X=10; Y=10; Width=20; Height=20 + }) -Color later -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 9 + + $hit = Find-SnipAnnotation -Annotations @($low,$tieEarlier,$tieLater) ` + -ImageX 15 -ImageY 15 -Tolerance 0 + ShouldBe $hit.Id $tieLater.Id + ShouldBe $hit.Color 'later' +} +It 'Find-SnipAnnotation copies semantic properties only for the selected winner' { + $script:FindCopyProbeCount = 0 + try { + $expensiveProperties = [pscustomobject]@{} + $expensiveProperties | Add-Member -MemberType ScriptProperty -Name Probe -Value { + $script:FindCopyProbeCount++ + 'evaluated' + } + $loser = [pscustomobject][ordered]@{ + Id=[guid]::NewGuid().ToString(); Kind='Rectangle' + Geometry=[pscustomobject]@{Type='Bounds';X=100;Y=100;Width=20;Height=20} + Color='red'; StrokeWidth=1; Opacity=1; Properties=$expensiveProperties; Z=99 + } + $winner = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds';X=0;Y=0;Width=20;Height=20 + }) -Color green -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 1 + + $hit = Find-SnipAnnotation -Annotations @($loser,$winner) -ImageX 5 -ImageY 5 -Tolerance 0 + ShouldBe $hit.Id $winner.Id + ShouldBe $script:FindCopyProbeCount 0 + } finally { + Remove-Variable FindCopyProbeCount -Scope Script -ErrorAction SilentlyContinue + } +} +It 'Find-SnipAnnotation hits bounds line points text and step geometry' { + $cases = @( + @{ Kind='Rectangle'; Geometry=[pscustomobject]@{Type='Bounds';X=2;Y=3;Width=10;Height=8}; X=5; Y=6 } + @{ Kind='Arrow'; Geometry=[pscustomobject]@{Type='Line';Start=[pscustomobject]@{X=20;Y=10};End=[pscustomobject]@{X=30;Y=20}}; X=25; Y=15 } + @{ Kind='Pen'; Geometry=[pscustomobject]@{Type='Points';Points=@([pscustomobject]@{X=40;Y=5},[pscustomobject]@{X=50;Y=5},[pscustomobject]@{X=55;Y=10})}; X=45; Y=6 } + @{ Kind='Text'; Geometry=[pscustomobject]@{Type='TextBounds';X=5;Y=30;Width=20;Height=10}; X=12; Y=35 } + @{ Kind='Steps'; Geometry=[pscustomobject]@{Type='StepBounds';X=35;Y=30;Width=12;Height=12}; X=40; Y=36 } + ) + foreach ($case in $cases) { + $annotation = New-SnipAnnotation -Kind $case.Kind -Geometry $case.Geometry ` + -Color white -StrokeWidth 2 -Opacity 1 -Properties @{} -Z 0 + $hit = Find-SnipAnnotation -Annotations @($annotation) ` + -ImageX $case.X -ImageY $case.Y -Tolerance 1 + ShouldBe $hit.Id $annotation.Id + } +} +It 'Find-SnipAnnotation uses half-open bounds and segment tolerance' { + $bounds = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds'; X=10; Y=10; Width=10; Height=10 + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $line = New-SnipAnnotation -Kind Line -Geometry ([pscustomobject]@{ + Type='Line'; Start=[pscustomobject]@{X=30;Y=10}; End=[pscustomobject]@{X=40;Y=10} + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 1 + + ShouldBe (Find-SnipAnnotation -Annotations @($bounds) -ImageX 19 -ImageY 19 -Tolerance 0).Id $bounds.Id + ShouldBe (Find-SnipAnnotation -Annotations @($bounds) -ImageX 20 -ImageY 15 -Tolerance 0) $null + ShouldBe (Find-SnipAnnotation -Annotations @($line) -ImageX 35 -ImageY 12 -Tolerance 1) $null + ShouldBe (Find-SnipAnnotation -Annotations @($line) -ImageX 35 -ImageY 12 -Tolerance 2).Id $line.Id +} +It 'Find-SnipAnnotation accepts current legacy highlight rect arrow and text records' { + $legacy = @( + [pscustomobject]@{Id='legacy-highlight';Type='highlight';Color='yellow';X=0;Y=0;W=10;H=10;Text=$null;FontSize=0;Z=0} + [pscustomobject]@{Id='legacy-rect';Type='rect';Color='red';X=20;Y=0;W=10;H=10;Text=$null;FontSize=0;Z=0} + [pscustomobject]@{Id='legacy-arrow';Type='arrow';Color='green';X=40;Y=0;W=10;H=10;Text=$null;FontSize=0;Z=0} + [pscustomobject]@{Id='legacy-text';Type='text';Color='white';X=60;Y=0;W=20;H=10;Text='hello';FontSize=14;Z=0} + ) + $probes = @( + @{X=5;Y=5;Id='legacy-highlight';Kind='Highlight'} + @{X=25;Y=5;Id='legacy-rect';Kind='Rectangle'} + @{X=45;Y=5;Id='legacy-arrow';Kind='Arrow'} + @{X=65;Y=5;Id='legacy-text';Kind='Text'} + ) + foreach ($probe in $probes) { + $hit = Find-SnipAnnotation -Annotations $legacy -ImageX $probe.X -ImageY $probe.Y -Tolerance 1 + ShouldBe $hit.Id $probe.Id + ShouldBe $hit.Kind $probe.Kind + } +} +It 'Find-SnipAnnotation safely misses unsupported degenerate and separated geometry' { + $unsupported = New-SnipAnnotation -Kind Future -Geometry ([pscustomobject]@{ + Type='FutureGeometry'; Payload=@{X=1;Y=2} + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 99 + $degenerate = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds'; X=5; Y=5; Width=0; Height=10 + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 50 + $emptyPoints = New-SnipAnnotation -Kind Pen -Geometry ([pscustomobject]@{ + Type='Points'; Points=@() + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 40 + $zeroLine = New-SnipAnnotation -Kind Line -Geometry ([pscustomobject]@{ + Type='Line';Start=[pscustomobject]@{X=8;Y=8};End=[pscustomobject]@{X=8;Y=8} + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 30 + $separated = New-SnipAnnotation -Kind Line -Geometry ([pscustomobject]@{ + Type='Line';Start=[pscustomobject]@{X=50;Y=50};End=[pscustomobject]@{X=60;Y=60} + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + + ShouldBe (Find-SnipAnnotation -Annotations @($unsupported,$degenerate,$emptyPoints,$zeroLine,$separated) ` + -ImageX 8 -ImageY 8 -Tolerance 0) $null + ShouldBe (Find-SnipAnnotation -Annotations $null -ImageX 8 -ImageY 8 -Tolerance 0) $null +} +It 'Select-SnipAnnotation returns the topmost stable ID and null on a miss' { + $annotation = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds'; X=5; Y=5; Width=10; Height=10 + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + ShouldBe (Select-SnipAnnotation -Annotations @($annotation) -ImageX 7 -ImageY 7 -Tolerance 0) $annotation.Id + ShouldBe (Select-SnipAnnotation -Annotations @($annotation) -ImageX 50 -ImageY 50 -Tolerance 0) $null +} + +Describe 'Annotation movement and resize contract' +It 'Move-SnipAnnotation preserves ID while moving every geometry type' { + $cases = @( + @{Type='Bounds'; Geometry=[pscustomobject]@{Type='Bounds';X=10;Y=10;Width=20;Height=10}; X='X'; Before=10; After=11} + @{Type='TextBounds'; Geometry=[pscustomobject]@{Type='TextBounds';X=10;Y=20;Width=20;Height=10}; X='X'; Before=10; After=11} + @{Type='StepBounds'; Geometry=[pscustomobject]@{Type='StepBounds';X=20;Y=10;Width=10;Height=10}; X='X'; Before=20; After=21} + @{Type='Line'; Geometry=[pscustomobject]@{Type='Line';Start=[pscustomobject]@{X=10;Y=10};End=[pscustomobject]@{X=20;Y=20}}; X='Start'; Before=10; After=11} + @{Type='Points'; Geometry=[pscustomobject]@{Type='Points';Points=@([pscustomobject]@{X=10;Y=10},[pscustomobject]@{X=20;Y=20})}; X='Points'; Before=10; After=11} + ) + foreach ($case in $cases) { + $annotation = New-SnipAnnotation -Kind $case.Type -Geometry $case.Geometry ` + -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $moved = Move-SnipAnnotation -Annotation $annotation -DeltaX 1 -DeltaY 10 ` + -SourceWidth 100 -SourceHeight 100 + ShouldBe $moved.Id $annotation.Id + ShouldBeFalse ([object]::ReferenceEquals($moved, $annotation)) + switch ($case.X) { + 'X' { ShouldBe $moved.Geometry.X $case.After; ShouldBe $annotation.Geometry.X $case.Before } + 'Start' { ShouldBe $moved.Geometry.Start.X $case.After; ShouldBe $moved.Geometry.Start.Y 20; ShouldBe $annotation.Geometry.Start.Y 10 } + 'Points' { ShouldBe $moved.Geometry.Points[0].X $case.After; ShouldBe $moved.Geometry.Points[0].Y 20; ShouldBe $annotation.Geometry.Points[0].Y 10 } + } + } +} +It 'Move-SnipAnnotation clamps a uniform translation without resizing geometry' { + $bounds = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds'; X=80; Y=2; Width=20; Height=10 + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $line = New-SnipAnnotation -Kind Line -Geometry ([pscustomobject]@{ + Type='Line';Start=[pscustomobject]@{X=2;Y=2};End=[pscustomobject]@{X=8;Y=8} + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $points = New-SnipAnnotation -Kind Pen -Geometry ([pscustomobject]@{ + Type='Points';Points=@([pscustomobject]@{X=90;Y=90},[pscustomobject]@{X=99;Y=99}) + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + + $movedBounds = Move-SnipAnnotation -Annotation $bounds -DeltaX 10 -DeltaY -10 -SourceWidth 100 -SourceHeight 100 + ShouldBe $movedBounds.Geometry.X 80 + ShouldBe $movedBounds.Geometry.Y 0 + ShouldBe $movedBounds.Geometry.Width 20 + ShouldBe $movedBounds.Geometry.Height 10 + $movedLine = Move-SnipAnnotation -Annotation $line -DeltaX -10 -DeltaY -10 -SourceWidth 100 -SourceHeight 100 + ShouldBe $movedLine.Geometry.Start.X 0 + ShouldBe $movedLine.Geometry.Start.Y 0 + ShouldBe $movedLine.Geometry.End.X 6 + ShouldBe $movedLine.Geometry.End.Y 6 + $movedPoints = Move-SnipAnnotation -Annotation $points -DeltaX 10 -DeltaY 10 -SourceWidth 100 -SourceHeight 100 + ShouldBe $movedPoints.Geometry.Points[0].X 90 + ShouldBe $movedPoints.Geometry.Points[1].X 99 +} +It 'Move-SnipAnnotation rejects geometry spans larger than the source without mutation' { + $cases = @( + (New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds';X=0;Y=0;Width=101;Height=10 + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0), + (New-SnipAnnotation -Kind Line -Geometry ([pscustomobject]@{ + Type='Line';Start=[pscustomobject]@{X=0;Y=0};End=[pscustomobject]@{X=100;Y=10} + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0), + (New-SnipAnnotation -Kind Pen -Geometry ([pscustomobject]@{ + Type='Points';Points=@([pscustomobject]@{X=0;Y=0},[pscustomobject]@{X=100;Y=10}) + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0) + ) + foreach ($annotation in $cases) { + $before = Copy-SnipAnnotation -Annotation $annotation + $threw = $false + try { + Move-SnipAnnotation -Annotation $annotation -DeltaX 0 -DeltaY 0 ` + -SourceWidth 100 -SourceHeight 100 | Out-Null + } catch { + $threw = $_.Exception -is [ArgumentException] + } + ShouldBeTrue $threw + ShouldBe $annotation.Id $before.Id + ShouldBe $annotation.Geometry.Type $before.Geometry.Type + } +} +It 'Resize-SnipAnnotation normalizes crossed bound edges and enforces one-pixel minima' { + $annotation = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds'; X=10; Y=10; Width=20; Height=20 + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $crossed = Resize-SnipAnnotation -Annotation $annotation -Handle TopLeft ` + -DeltaX 25 -DeltaY 25 -SourceWidth 100 -SourceHeight 100 + ShouldBe $crossed.Id $annotation.Id + ShouldBe $crossed.Geometry.X 30 + ShouldBe $crossed.Geometry.Y 30 + ShouldBe $crossed.Geometry.Width 5 + ShouldBe $crossed.Geometry.Height 5 + $minimum = Resize-SnipAnnotation -Annotation $annotation -Handle Left ` + -DeltaX 20 -DeltaY 0 -SourceWidth 100 -SourceHeight 100 + ShouldBe $minimum.Geometry.X 29 + ShouldBe $minimum.Geometry.Width 1 + ShouldBe $annotation.Geometry.X 10 + ShouldBe $annotation.Geometry.Width 20 +} +It 'Resize-SnipAnnotation clamps every bounds handle to the half-open source' { + $annotation = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds'; X=10; Y=10; Width=20; Height=15 + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $cases = @( + @{Handle='TopLeft';DX=-100;DY=-100;X=0;Y=0;W=30;H=25} + @{Handle='Top';DX=0;DY=-100;X=10;Y=0;W=20;H=25} + @{Handle='TopRight';DX=100;DY=-100;X=10;Y=0;W=40;H=25} + @{Handle='Right';DX=100;DY=0;X=10;Y=10;W=40;H=15} + @{Handle='BottomRight';DX=100;DY=100;X=10;Y=10;W=40;H=30} + @{Handle='Bottom';DX=0;DY=100;X=10;Y=10;W=20;H=30} + @{Handle='BottomLeft';DX=-100;DY=100;X=0;Y=10;W=30;H=30} + @{Handle='Left';DX=-100;DY=0;X=0;Y=10;W=30;H=15} + ) + foreach ($case in $cases) { + $resized = Resize-SnipAnnotation -Annotation $annotation -Handle $case.Handle ` + -DeltaX $case.DX -DeltaY $case.DY -SourceWidth 50 -SourceHeight 40 + ShouldBe $resized.Geometry.X $case.X + ShouldBe $resized.Geometry.Y $case.Y + ShouldBe $resized.Geometry.Width $case.W + ShouldBe $resized.Geometry.Height $case.H + ShouldBeTrue (($resized.Geometry.X + $resized.Geometry.Width) -le 50) + ShouldBeTrue (($resized.Geometry.Y + $resized.Geometry.Height) -le 40) + } +} +It 'Resize-SnipAnnotation handles TextBounds and StepBounds as resizable bounds' { + foreach ($type in 'TextBounds','StepBounds') { + $annotation = New-SnipAnnotation -Kind $type -Geometry ([pscustomobject]@{ + Type=$type; X=10; Y=10; Width=20; Height=15 + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $resized = Resize-SnipAnnotation -Annotation $annotation -Handle BottomRight ` + -DeltaX 5 -DeltaY 7 -SourceWidth 100 -SourceHeight 100 + ShouldBe $resized.Geometry.Type $type + ShouldBe $resized.Geometry.Width 25 + ShouldBe $resized.Geometry.Height 22 + ShouldBe $resized.Id $annotation.Id + } +} +It 'Resize-SnipAnnotation moves line endpoints independently and preserves ID' { + $annotation = New-SnipAnnotation -Kind Arrow -Geometry ([pscustomobject]@{ + Type='Line';Start=[pscustomobject]@{X=10;Y=10};End=[pscustomobject]@{X=20;Y=20} + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $start = Resize-SnipAnnotation -Annotation $annotation -Handle Start ` + -DeltaX -99 -DeltaY 5 -SourceWidth 30 -SourceHeight 25 + ShouldBe $start.Id $annotation.Id + ShouldBe $start.Geometry.Start.X 0 + ShouldBe $start.Geometry.Start.Y 15 + ShouldBe $start.Geometry.End.X 20 + ShouldBe $start.Geometry.End.Y 20 + $end = Resize-SnipAnnotation -Annotation $annotation -Handle End ` + -DeltaX 99 -DeltaY 99 -SourceWidth 30 -SourceHeight 25 + ShouldBe $end.Geometry.Start.X 10 + ShouldBe $end.Geometry.End.X 29 + ShouldBe $end.Geometry.End.Y 24 +} +It 'Resize-SnipAnnotation scales point geometry from the opposite handle without aliasing' { + $annotation = New-SnipAnnotation -Kind Pen -Geometry ([pscustomobject]@{ + Type='Points';Points=@( + [pscustomobject]@{X=10;Y=10}, + [pscustomobject]@{X=15;Y=15}, + [pscustomobject]@{X=20;Y=20} + ) + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $resized = Resize-SnipAnnotation -Annotation $annotation -Handle BottomRight ` + -DeltaX 10 -DeltaY 20 -SourceWidth 100 -SourceHeight 100 + ShouldBe $resized.Id $annotation.Id + ShouldBe $resized.Geometry.Points[0].X 10 + ShouldBe $resized.Geometry.Points[0].Y 10 + ShouldBe $resized.Geometry.Points[1].X 20 + ShouldBe $resized.Geometry.Points[1].Y 25 + ShouldBe $resized.Geometry.Points[2].X 30 + ShouldBe $resized.Geometry.Points[2].Y 40 + ShouldBe $annotation.Geometry.Points[1].X 15 + ShouldBeFalse ([object]::ReferenceEquals($resized.Geometry.Points, $annotation.Geometry.Points)) +} +It 'Resize-SnipAnnotation rejects nonpositive and oversized bounds without mutation' { + $cases = @( + @{X=10;Y=10;W=0;H=10}, + @{X=10;Y=10;W=10;H=-1}, + @{X=10;Y=10;W=101;H=10}, + @{X=10;Y=10;W=10;H=81} + ) + $results = [Collections.ArrayList]::new() + foreach ($case in $cases) { + $annotation = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds';X=$case.X;Y=$case.Y;Width=$case.W;Height=$case.H + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $threw = $false + try { + Resize-SnipAnnotation -Annotation $annotation -Handle Right -DeltaX 0 -DeltaY 0 ` + -SourceWidth 100 -SourceHeight 80 | Out-Null + } catch { + $threw = $_.Exception -is [ArgumentException] + } + [void]$results.Add($threw) + ShouldBe $annotation.Geometry.X $case.X + ShouldBe $annotation.Geometry.Y $case.Y + ShouldBe $annotation.Geometry.Width $case.W + ShouldBe $annotation.Geometry.Height $case.H + } + ShouldBe ($results -join ',') 'True,True,True,True' +} +It 'Resize-SnipAnnotation shifts stationary bounds axes inside source for every bounds kind' { + $cases = @( + @{Type='Bounds';X=-10;Y=10;W=20;H=15;Handle='Bottom';DX=0;DY=5;OutX=0;OutY=10;OutW=20;OutH=20}, + @{Type='TextBounds';X=95;Y=-5;W=20;H=15;Handle='Right';DX=-5;DY=0;OutX=80;OutY=0;OutW=15;OutH=15}, + @{Type='StepBounds';X=90;Y=75;W=20;H=10;Handle='Left';DX=-5;DY=0;OutX=75;OutY=70;OutW=25;OutH=10} + ) + foreach ($case in $cases) { + $annotation = New-SnipAnnotation -Kind $case.Type -Geometry ([pscustomobject]@{ + Type=$case.Type;X=$case.X;Y=$case.Y;Width=$case.W;Height=$case.H + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $resized = Resize-SnipAnnotation -Annotation $annotation -Handle $case.Handle ` + -DeltaX $case.DX -DeltaY $case.DY -SourceWidth 100 -SourceHeight 80 + ShouldBe $resized.Id $annotation.Id + ShouldBe $resized.Geometry.X $case.OutX + ShouldBe $resized.Geometry.Y $case.OutY + ShouldBe $resized.Geometry.Width $case.OutW + ShouldBe $resized.Geometry.Height $case.OutH + ShouldBeTrue ($resized.Geometry.X -ge 0 -and $resized.Geometry.Y -ge 0) + ShouldBeTrue (($resized.Geometry.X + $resized.Geometry.Width) -le 100) + ShouldBeTrue (($resized.Geometry.Y + $resized.Geometry.Height) -le 80) + ShouldBe $annotation.Geometry.X $case.X + ShouldBe $annotation.Geometry.Y $case.Y + } +} +It 'Resize-SnipAnnotation normalizes before a bounds handle crosses its opposite edge' { + $annotation = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds';X=-10;Y=10;Width=20;Height=15 + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $resized = Resize-SnipAnnotation -Annotation $annotation -Handle Left ` + -DeltaX 30 -DeltaY 0 -SourceWidth 100 -SourceHeight 80 + ShouldBe $resized.Id $annotation.Id + ShouldBe $resized.Geometry.X 20 + ShouldBe $resized.Geometry.Y 10 + ShouldBe $resized.Geometry.Width 10 + ShouldBe $resized.Geometry.Height 15 + ShouldBe $annotation.Geometry.X -10 + ShouldBe $annotation.Geometry.Width 20 +} +It 'Resize-SnipAnnotation shifts a whole line inside before moving one endpoint' { + $annotation = New-SnipAnnotation -Kind Arrow -Geometry ([pscustomobject]@{ + Type='Line';Start=[pscustomobject]@{X=90;Y=-10};End=[pscustomobject]@{X=120;Y=20} + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $resized = Resize-SnipAnnotation -Annotation $annotation -Handle Start ` + -DeltaX -5 -DeltaY 5 -SourceWidth 100 -SourceHeight 50 + ShouldBe $resized.Id $annotation.Id + ShouldBe $resized.Geometry.Start.X 64 + ShouldBe $resized.Geometry.Start.Y 5 + ShouldBe $resized.Geometry.End.X 99 + ShouldBe $resized.Geometry.End.Y 30 + ShouldBe $annotation.Geometry.Start.X 90 + ShouldBe $annotation.Geometry.End.X 120 +} +It 'Resize-SnipAnnotation rejects degenerate and oversized line spans but permits a vertical line' { + $invalid = @( + @{Start=[pscustomobject]@{X=10;Y=10};End=[pscustomobject]@{X=10;Y=10}}, + @{Start=[pscustomobject]@{X=0;Y=10};End=[pscustomobject]@{X=100;Y=10}}, + @{Start=[pscustomobject]@{X=10;Y=0};End=[pscustomobject]@{X=10;Y=50}} + ) + $results = [Collections.ArrayList]::new() + foreach ($geometry in $invalid) { + $annotation = New-SnipAnnotation -Kind Arrow -Geometry ([pscustomobject]@{ + Type='Line';Start=$geometry.Start;End=$geometry.End + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $threw = $false + try { + Resize-SnipAnnotation -Annotation $annotation -Handle Start -DeltaX 0 -DeltaY 0 ` + -SourceWidth 100 -SourceHeight 50 | Out-Null + } catch { + $threw = $_.Exception -is [ArgumentException] + } + [void]$results.Add($threw) + ShouldBe $annotation.Geometry.Start.X $geometry.Start.X + ShouldBe $annotation.Geometry.Start.Y $geometry.Start.Y + ShouldBe $annotation.Geometry.End.X $geometry.End.X + ShouldBe $annotation.Geometry.End.Y $geometry.End.Y + } + ShouldBe ($results -join ',') 'True,True,True' + + $vertical = New-SnipAnnotation -Kind Arrow -Geometry ([pscustomobject]@{ + Type='Line';Start=[pscustomobject]@{X=10;Y=-5};End=[pscustomobject]@{X=10;Y=20} + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $resized = Resize-SnipAnnotation -Annotation $vertical -Handle End ` + -DeltaX 0 -DeltaY 5 -SourceWidth 100 -SourceHeight 50 + ShouldBe $resized.Geometry.Start.X 10 + ShouldBe $resized.Geometry.Start.Y 0 + ShouldBe $resized.Geometry.End.X 10 + ShouldBe $resized.Geometry.End.Y 30 +} +It 'Resize-SnipAnnotation shifts untouched point axes before scaling from a handle' { + $verticalOutside = New-SnipAnnotation -Kind Pen -Geometry ([pscustomobject]@{ + Type='Points';Points=@( + [pscustomobject]@{X=10;Y=-5},[pscustomobject]@{X=15;Y=5},[pscustomobject]@{X=20;Y=15} + ) + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $horizontalResize = Resize-SnipAnnotation -Annotation $verticalOutside -Handle Right ` + -DeltaX 10 -DeltaY 0 -SourceWidth 50 -SourceHeight 30 + ShouldBe $horizontalResize.Id $verticalOutside.Id + ShouldBe (($horizontalResize.Geometry.Points | ForEach-Object X) -join ',') '10,20,30' + ShouldBe (($horizontalResize.Geometry.Points | ForEach-Object Y) -join ',') '0,10,20' + ShouldBe (($verticalOutside.Geometry.Points | ForEach-Object Y) -join ',') '-5,5,15' + + $horizontalOutside = New-SnipAnnotation -Kind Pen -Geometry ([pscustomobject]@{ + Type='Points';Points=@( + [pscustomobject]@{X=45;Y=5},[pscustomobject]@{X=55;Y=10},[pscustomobject]@{X=65;Y=15} + ) + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $verticalResize = Resize-SnipAnnotation -Annotation $horizontalOutside -Handle Bottom ` + -DeltaX 0 -DeltaY 5 -SourceWidth 50 -SourceHeight 30 + ShouldBe $verticalResize.Id $horizontalOutside.Id + ShouldBe (($verticalResize.Geometry.Points | ForEach-Object X) -join ',') '29,39,49' + ShouldBe (($verticalResize.Geometry.Points | ForEach-Object Y) -join ',') '5,13,20' + ShouldBe (($horizontalOutside.Geometry.Points | ForEach-Object X) -join ',') '45,55,65' +} +It 'Resize-SnipAnnotation rejects empty degenerate and oversized point spans without mutation' { + $cases = @( + @(), + @([pscustomobject]@{X=10;Y=10},[pscustomobject]@{X=10;Y=10}), + @([pscustomobject]@{X=0;Y=10},[pscustomobject]@{X=50;Y=10}), + @([pscustomobject]@{X=10;Y=0},[pscustomobject]@{X=10;Y=30}) + ) + $results = [Collections.ArrayList]::new() + foreach ($points in $cases) { + $annotation = New-SnipAnnotation -Kind Pen -Geometry ([pscustomobject]@{ + Type='Points';Points=$points + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $before = @(($annotation.Geometry.Points | ForEach-Object { "$($_.X),$($_.Y)" })) -join ';' + $threw = $false + try { + Resize-SnipAnnotation -Annotation $annotation -Handle Right -DeltaX 1 -DeltaY 0 ` + -SourceWidth 50 -SourceHeight 30 | Out-Null + } catch { + $threw = $_.Exception -is [ArgumentException] + } + [void]$results.Add($threw) + $after = @(($annotation.Geometry.Points | ForEach-Object { "$($_.X),$($_.Y)" })) -join ';' + ShouldBe $after $before + } + ShouldBe ($results -join ',') 'True,True,True,True' +} +It 'Resize-SnipAnnotation rejects handle deltas that collapse line or point extent' { + $line = New-SnipAnnotation -Kind Arrow -Geometry ([pscustomobject]@{ + Type='Line';Start=[pscustomobject]@{X=10;Y=10};End=[pscustomobject]@{X=20;Y=20} + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $lineThrew = $false + try { + Resize-SnipAnnotation -Annotation $line -Handle Start -DeltaX 10 -DeltaY 10 ` + -SourceWidth 100 -SourceHeight 100 | Out-Null + } catch { + $lineThrew = $_.Exception -is [ArgumentException] + } + ShouldBe $line.Geometry.Start.X 10 + ShouldBe $line.Geometry.Start.Y 10 + ShouldBe $line.Geometry.End.X 20 + ShouldBe $line.Geometry.End.Y 20 + + $points = New-SnipAnnotation -Kind Pen -Geometry ([pscustomobject]@{ + Type='Points';Points=@([pscustomobject]@{X=10;Y=10},[pscustomobject]@{X=20;Y=20}) + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $pointsThrew = $false + try { + Resize-SnipAnnotation -Annotation $points -Handle BottomRight -DeltaX -10 -DeltaY -10 ` + -SourceWidth 100 -SourceHeight 100 | Out-Null + } catch { + $pointsThrew = $_.Exception -is [ArgumentException] + } + ShouldBe (($points.Geometry.Points | ForEach-Object { "$($_.X),$($_.Y)" }) -join ';') '10,10;20,20' + ShouldBe "$lineThrew,$pointsThrew" 'True,True' +} +It 'Resize-SnipAnnotation clamps extreme integer deltas for every geometry family' { + $bounds = New-SnipAnnotation -Kind Rectangle -Geometry ([pscustomobject]@{ + Type='Bounds';X=10;Y=10;Width=20;Height=20 + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $maximumBounds = Resize-SnipAnnotation -Annotation $bounds -Handle BottomRight ` + -DeltaX ([int]::MaxValue) -DeltaY ([int]::MaxValue) -SourceWidth 100 -SourceHeight 100 + ShouldBe "$($maximumBounds.Geometry.X),$($maximumBounds.Geometry.Y),$($maximumBounds.Geometry.Width),$($maximumBounds.Geometry.Height)" '10,10,90,90' + $minimumBounds = Resize-SnipAnnotation -Annotation $bounds -Handle TopLeft ` + -DeltaX ([int]::MinValue) -DeltaY ([int]::MinValue) -SourceWidth 100 -SourceHeight 100 + ShouldBe "$($minimumBounds.Geometry.X),$($minimumBounds.Geometry.Y),$($minimumBounds.Geometry.Width),$($minimumBounds.Geometry.Height)" '0,0,30,30' + + $line = New-SnipAnnotation -Kind Arrow -Geometry ([pscustomobject]@{ + Type='Line';Start=[pscustomobject]@{X=10;Y=10};End=[pscustomobject]@{X=20;Y=20} + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $maximumLine = Resize-SnipAnnotation -Annotation $line -Handle End ` + -DeltaX ([int]::MaxValue) -DeltaY ([int]::MaxValue) -SourceWidth 100 -SourceHeight 100 + ShouldBe "$($maximumLine.Geometry.Start.X),$($maximumLine.Geometry.Start.Y)" '10,10' + ShouldBe "$($maximumLine.Geometry.End.X),$($maximumLine.Geometry.End.Y)" '99,99' + + $points = New-SnipAnnotation -Kind Pen -Geometry ([pscustomobject]@{ + Type='Points';Points=@( + [pscustomobject]@{X=10;Y=10},[pscustomobject]@{X=15;Y=15},[pscustomobject]@{X=20;Y=20} + ) + }) -Color white -StrokeWidth 1 -Opacity 1 -Properties @{} -Z 0 + $maximumPoints = Resize-SnipAnnotation -Annotation $points -Handle Right ` + -DeltaX ([int]::MaxValue) -DeltaY 0 -SourceWidth 100 -SourceHeight 100 + ShouldBe (($maximumPoints.Geometry.Points | ForEach-Object X) -join ',') '10,55,99' + ShouldBe (($maximumPoints.Geometry.Points | ForEach-Object Y) -join ',') '10,15,20' + ShouldBe "$($bounds.Geometry.Width),$($line.Geometry.End.X),$($points.Geometry.Points[2].X)" '20,20,20' +} + +Describe 'Non-destructive crop contract' +It 'Get-SnipCropRectangle normalizes and clamps a Free draft at every source edge' { + $cases = @( + @{Candidate=[pscustomobject]@{X=-10;Y=-20;Width=50;Height=60};X=0;Y=0;W=40;H=40} + @{Candidate=[pscustomobject]@{X=90;Y=70;Width=30;Height=20};X=90;Y=70;W=10;H=10} + @{Candidate=[pscustomobject]@{X=90;Y=70;Width=-100;Height=-90};X=0;Y=0;W=90;H=70} + ) + foreach ($case in $cases) { + $rect = Get-SnipCropRectangle -Candidate $case.Candidate -SourceWidth 100 -SourceHeight 80 -Preset Free + ShouldBe $rect.X $case.X + ShouldBe $rect.Y $case.Y + ShouldBe $rect.Width $case.W + ShouldBe $rect.Height $case.H + } + ShouldBe (Get-SnipCropRectangle -Candidate ([pscustomobject]@{X=110;Y=90;Width=5;Height=5}) ` + -SourceWidth 100 -SourceHeight 80 -Preset Free) $null +} +It 'Get-SnipCropRectangle uses the original source aspect in landscape and portrait' { + $square = [pscustomobject]@{X=0;Y=0;Width=80;Height=80} + $landscape = Get-SnipCropRectangle -Candidate $square -SourceWidth 160 -SourceHeight 90 -Preset Original + ShouldBe $landscape.X 0; ShouldBe $landscape.Y 18 + ShouldBe $landscape.Width 80; ShouldBe $landscape.Height 45 + $portrait = Get-SnipCropRectangle -Candidate $square -SourceWidth 90 -SourceHeight 160 -Preset Original + ShouldBe $portrait.X 18; ShouldBe $portrait.Y 0 + ShouldBe $portrait.Width 45; ShouldBe $portrait.Height 80 +} +It 'Get-SnipCropRectangle keeps full-source Original exact in both orientations' { + $landscape = Get-SnipCropRectangle -Candidate $null -SourceWidth 160 -SourceHeight 90 -Preset Original + ShouldBe $landscape.X 0; ShouldBe $landscape.Y 0 + ShouldBe $landscape.Width 160; ShouldBe $landscape.Height 90 + $portrait = Get-SnipCropRectangle -Candidate $null -SourceWidth 90 -SourceHeight 160 -Preset Original + ShouldBe $portrait.X 0; ShouldBe $portrait.Y 0 + ShouldBe $portrait.Width 90; ShouldBe $portrait.Height 160 +} +It 'Get-SnipCropRectangle clamps before applying an aspect preset' { + $candidate = [pscustomobject]@{X=-10;Y=10;Width=100;Height=60} + $rect = Get-SnipCropRectangle -Candidate $candidate -SourceWidth 100 -SourceHeight 80 -Preset '16:9' + ShouldBe $rect.X 0; ShouldBe $rect.Y 15 + ShouldBe $rect.Width 90; ShouldBe $rect.Height 51 + ShouldBe $candidate.X -10; ShouldBe $candidate.Width 100 +} +It 'Get-SnipCropRectangle centers odd-sized presets with away-from-zero rounding' { + $candidate = [pscustomobject]@{X=3;Y=5;Width=101;Height=77} + $rect = Get-SnipCropRectangle -Candidate $candidate -SourceWidth 200 -SourceHeight 150 -Preset '4:3' + ShouldBe $rect.X 3; ShouldBe $rect.Y 6 + ShouldBe $rect.Width 101; ShouldBe $rect.Height 76 +} +It 'Get-SnipCropRectangle rounds fractional candidates once after normalization' { + $candidate = [pscustomobject]@{X=10.5;Y=20.5;Width=80.5;Height=60.5} + $rect = Get-SnipCropRectangle -Candidate $candidate -SourceWidth 200 -SourceHeight 150 -Preset Free + ShouldBe $rect.X 11; ShouldBe $rect.Y 21 + ShouldBe $rect.Width 81; ShouldBe $rect.Height 61 + ShouldBe $candidate.X 10.5; ShouldBe $candidate.Width 80.5 +} +It 'Get-SnipCropRectangle inscribes 1 to 1 4 to 3 and 16 to 9 inside a draft' { + $draft = [pscustomobject]@{X=10;Y=20;Width=80;Height=60} + $square = Get-SnipCropRectangle -Candidate $draft -SourceWidth 120 -SourceHeight 100 -Preset '1:1' + ShouldBe $square.X 20; ShouldBe $square.Y 20; ShouldBe $square.Width 60; ShouldBe $square.Height 60 + $fourThree = Get-SnipCropRectangle -Candidate $draft -SourceWidth 120 -SourceHeight 100 -Preset '4:3' + ShouldBe $fourThree.X 10; ShouldBe $fourThree.Y 20; ShouldBe $fourThree.Width 80; ShouldBe $fourThree.Height 60 + $wide = Get-SnipCropRectangle -Candidate $draft -SourceWidth 120 -SourceHeight 100 -Preset '16:9' + ShouldBe $wide.X 10; ShouldBe $wide.Y 28; ShouldBe $wide.Width 80; ShouldBe $wide.Height 45 +} +It 'Get-SnipCropRectangle follows portrait orientation for ratio presets' { + $draft = [pscustomobject]@{X=5;Y=10;Width=90;Height=160} + $fourThree = Get-SnipCropRectangle -Candidate $draft -SourceWidth 100 -SourceHeight 180 -Preset '4:3' + ShouldBe $fourThree.X 5; ShouldBe $fourThree.Y 30 + ShouldBe $fourThree.Width 90; ShouldBe $fourThree.Height 120 + $sixteenNine = Get-SnipCropRectangle -Candidate $draft -SourceWidth 100 -SourceHeight 180 -Preset '16:9' + ShouldBe $sixteenNine.X 5; ShouldBe $sixteenNine.Y 10 + ShouldBe $sixteenNine.Width 90; ShouldBe $sixteenNine.Height 160 + $fromSource = Get-SnipCropRectangle -Candidate $null -SourceWidth 90 -SourceHeight 160 -Preset '4:3' + ShouldBe $fromSource.X 0; ShouldBe $fromSource.Y 20 + ShouldBe $fromSource.Width 90; ShouldBe $fromSource.Height 120 +} +It 'Set-SnipCrop returns a fresh applied rectangle and null on Reset' { + $candidate = [pscustomobject]@{X=10;Y=20;Width=80;Height=60} + $applied = Set-SnipCrop -Action Apply -Candidate $candidate ` + -SourceWidth 120 -SourceHeight 100 -Preset Free + ShouldBe (($applied.PSObject.Properties.Name) -join ',') 'X,Y,Width,Height' + ShouldBeFalse ([object]::ReferenceEquals($applied, $candidate)) + ShouldBe $applied.X 10; ShouldBe $applied.Y 20 + ShouldBe $applied.Width 80; ShouldBe $applied.Height 60 + ShouldBe (Set-SnipCrop -Action Reset -Candidate $candidate ` + -SourceWidth 120 -SourceHeight 100 -Preset '16:9') $null +} +It 'crop math rejects malformed sources and degenerate candidates without mutation' { + $candidate = [pscustomobject]@{X=10;Y=20;Width=0;Height=60} + ShouldBe (Get-SnipCropRectangle -Candidate $candidate -SourceWidth 100 -SourceHeight 80 -Preset Free) $null + ShouldBe $candidate.X 10; ShouldBe $candidate.Width 0 + + $sourceThrew = $false + try { + Get-SnipCropRectangle -Candidate $null -SourceWidth 0 -SourceHeight 80 -Preset Free | Out-Null + } catch { + $sourceThrew = $_.Exception -is [ArgumentOutOfRangeException] + } + ShouldBeTrue $sourceThrew + $shapeThrew = $false + try { + Get-SnipCropRectangle -Candidate ([pscustomobject]@{X=0;Y=0;Width=10}) ` + -SourceWidth 100 -SourceHeight 80 -Preset Free | Out-Null + } catch { + $shapeThrew = $_.Exception -is [ArgumentException] + } + ShouldBeTrue $shapeThrew +} + +Describe 'Task 7 canvas-only selection key precedence' +It 'does not route selection movement or Delete outside canvas focus' { + $state = @{ + PopupOpen=$false; EditingText=$false; EditingProperty=$false + Draft=$null; SelectionId='selection-1'; ActiveTool='Select' + } + foreach ($role in 'Button','Chrome','Window') { + foreach ($keyName in 'Left','Right','Up','Down','Delete') { + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole $role -EditorState $state ` + -Key $keyName -Modifiers @()) $null + } + } +} +It 'clears selection on Escape across non-editor focus roles before tool or window handling' { + foreach ($activeTool in 'Select','Pen') { + $state = @{ + PopupOpen=$false; EditingText=$false; EditingProperty=$false + Draft=$null; SelectionId='selection-1'; ActiveTool=$activeTool + } + foreach ($role in 'Canvas','Button','Chrome','Window') { + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole $role -EditorState $state ` + -Key Escape -Modifiers @()) 'ClearSelection' + } + } +} +It 'keeps popup editor and draft Escape ownership ahead of selection clearing' { + $state = @{ + PopupOpen=$true; EditingText=$false; EditingProperty=$false + Draft=$null; SelectionId='selection-1'; ActiveTool='Pen' + } + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Popup -EditorState $state ` + -Key Escape -Modifiers @()) 'ClosePopup' + $state.PopupOpen = $false + $state.EditingText = $true + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole TextEditor -EditorState $state ` + -Key Escape -Modifiers @()) 'CancelTextEdit' + $state.EditingText = $false + $state.EditingProperty = $true + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole PropertyEditor -EditorState $state ` + -Key Escape -Modifiers @()) 'CancelPropertyEdit' + $state.EditingProperty = $false + $state.Draft = [pscustomobject]@{ Kind='Crop' } + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Window -EditorState $state ` + -Key Escape -Modifiers @()) 'CancelDraft' +} +It 'activates focused buttons only for unmodified Space or Enter' { + $state = @{ + PopupOpen=$false; EditingText=$false; EditingProperty=$false + Draft=$null; SelectionId=$null; ActiveTool='Select' + } + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Button -EditorState $state ` + -Key Space -Modifiers @()) 'ActivateFocusedButton' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Button -EditorState $state ` + -Key Enter -Modifiers @()) 'ActivateFocusedButton' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Button -EditorState $state ` + -Key Enter -Modifiers Ctrl) 'CopyAndClose' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Button -EditorState $state ` + -Key Space -Modifiers Shift) $null + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Button -EditorState $state ` + -Key Enter -Modifiers Alt) $null + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Button -EditorState $state ` + -Key Space -Modifiers Ctrl) $null +} +It 'routes Undo and Redo only after editor and draft ownership' { + $state = @{ + PopupOpen=$false; EditingText=$false; EditingProperty=$false + Draft=$null; SelectionId=$null; ActiveTool='Select' } + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $state ` + -Key Z -Modifiers Ctrl) 'Undo' + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $state ` + -Key Z -Modifiers @('Ctrl','Shift')) 'Redo' + $state.EditingText = $true + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole TextEditor -EditorState $state ` + -Key Z -Modifiers Ctrl) 'TextInput' + $state.EditingText = $false + $state.Draft = [pscustomobject]@{ Kind='Crop' } + ShouldBe (Resolve-PreviewKeyCommand -FocusedRole Canvas -EditorState $state ` + -Key Z -Modifiers Ctrl) $null } Write-Host "" diff --git a/scripts/Export-SnipITModules.ps1 b/scripts/Export-SnipITModules.ps1 new file mode 100644 index 0000000..ac335a6 --- /dev/null +++ b/scripts/Export-SnipITModules.ps1 @@ -0,0 +1,495 @@ +#requires -Version 7.5 +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$SourcePath, + + [Parameter(Mandatory)] + [string]$DestinationRoot +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-SnipParsedScript { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Path + ) + + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile( + $Path, [ref]$tokens, [ref]$errors) + if ($errors.Count) { + throw "PowerShell parse failed for '$Path': $($errors.Message -join [Environment]::NewLine)" + } + $ast +} + +function Test-SnipRootFunction { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [Management.Automation.Language.FunctionDefinitionAst]$Function + ) + + $parent = $Function.Parent + while ($null -ne $parent) { + if ($parent -is [Management.Automation.Language.FunctionDefinitionAst]) { + return $false + } + $parent = $parent.Parent + } + $true +} + +function Get-SnipSurfaceJson { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [Management.Automation.Language.Ast]$Ast + ) + + $surface = @($Ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] + }, $true) | Sort-Object Name, { $_.Extent.StartOffset } | ForEach-Object { + $parameters = if ($null -eq $_.Body.ParamBlock) { + @() + } else { + @($_.Body.ParamBlock.Parameters | ForEach-Object { + $_.Name.VariablePath.UserPath + } | Sort-Object) + } + [ordered]@{ Name = $_.Name; Parameters = $parameters } + }) + $surface | ConvertTo-Json -Depth 5 -Compress +} + +function ConvertTo-SnipModuleText { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [string[]]$Sections + ) + + $normalized = foreach ($section in $Sections) { + (($section -replace "`r`n", "`n") -replace "`r", "`n").Trim("`n") + } + (($normalized -join "`n`n").TrimEnd("`n")) + "`n" +} + +$moduleFunctions = [ordered]@{ + 'src/00-Core.ps1' = @( + 'Get-DragRectangle', 'Test-IsClickVsDrag', 'Get-LoupeSourceRect', + 'Get-LoupePosition', 'Get-DefaultSnipFilename', 'Get-ImageFormatNameFromPath', + 'Resolve-SaveImagePath', 'Test-CaptureRectValid', 'Get-CropBounds', + 'Get-InstallPaths', 'Get-ShortcutArguments', 'Get-ClampedAnnotationRect', + 'Get-ZoomCenteredOffset', 'Get-SnipRecordValue', 'Test-SnipAtomicSemanticValue', + 'Copy-SnipSemanticKey', 'Copy-SnipSemanticValue', + 'ConvertTo-SnipAnnotationGeometry', 'New-SnipAnnotation', 'Copy-SnipAnnotation', + 'Copy-AnnotationList', 'New-SnipEditorSnapshot', 'Test-SnipPointNearSegment', + 'Test-SnipAnnotationHit', 'Get-SnipAnnotationHitView', 'Find-SnipAnnotation', + 'Select-SnipAnnotation', 'Move-SnipAnnotation', 'Resize-SnipAnnotation', + 'Get-SnipCropRectangle', 'Set-SnipCrop', 'Get-TrimmedRecent', + 'Test-IsSelfWindowHandle', 'Resolve-WindowCaptureTarget', 'Invoke-CaptureLoop', + 'Get-SnipThemeTokens', 'Get-SnipContrastRatio', 'Get-SnipDefaultSettings', + 'Test-SnipHotkeyDefinition', 'Format-SnipHotkey', 'Get-PreviewResponsiveMode', + 'ConvertTo-SnipDipPoint', 'ConvertTo-SnipPhysicalPoint', 'Get-SnipMonitorLayouts', + 'Get-SnipOverlayIntersections', 'ConvertTo-SnipCropLocalRect', + 'Resolve-PreviewKeyCommand', 'Get-SnipCoordinatorDecision', + 'New-SnipCaptureRequest', 'Get-SnipCoordinatorService', + 'Invoke-SnipResourceDispose', 'Remove-SnipCoordinatorBitmap', + 'ConvertTo-SnipSurfaceResult', 'New-SnipCaptureCoordinator', + 'Invoke-SnipCancelDelay', 'Send-SnipCapturePump', 'Request-SnipCapture', + 'Invoke-SnipPreviewTransfer', 'Set-SnipAuxiliarySurface', + 'Complete-SnipAuxiliarySurface', 'Close-SnipActiveSurface', + 'Complete-SnipSurface', 'Stop-SnipCaptureCoordinator', 'Invoke-SnipCapturePump' + ) + 'src/10-Bootstrap.ps1' = @( + 'Get-SnipXamlText', 'Get-SnipSettingsPath', 'Write-SnipDiag', 'Get-SnipDiag', + 'Read-SnipSettings', 'Save-SnipSettings', 'New-SnipITIcon', + 'Get-SnipITIconPath', 'Write-SnipITShortcuts', 'Write-SnipITShortcut', + 'Sync-SnipStartupShortcut', 'Install-SnipIT', 'Uninstall-SnipIT', + 'New-SnipThemeResources', 'Add-SnipThemeResources', + 'Connect-SnipWindowLifecycle', 'Convert-BitmapToBitmapSource', + 'Save-CaptureToFile' + ) + 'src/20-Native.ps1' = @( + 'Get-VirtualScreenBounds', 'Get-SnipMonitorDescriptors', + 'Get-SnipWindowAtPhysicalPoint', 'Get-SnipWindowBounds', + 'Register-SelfWindowHandle', 'Unregister-SelfWindowHandle', + 'Hide-OwnSnipITWindowsForCapture', 'Show-OwnSnipITWindowsForCapture', + 'Set-MicaBackdrop' + ) + 'src/30-Capture.ps1' = @( + 'New-ScreenBitmap', 'Get-SnipOverlayService', 'New-SnipOverlayContext', + 'Remove-SnipOverlayWindowEvents', 'New-SnipOverlayWindow', + 'Set-SnipOverlayRectangleVisual', 'Invoke-SnipOverlayRenderTick', + 'Show-SmartOverlaySet', 'Show-SmartOverlay', 'New-SnipRuntimeCaptureServices', + 'Invoke-SmartCapture', 'Invoke-FullScreenCapture', 'Invoke-WindowCapture', + 'Start-DelayedCapture' + ) + 'src/40-Preview.ps1' = @( + 'New-SnipPreviewContext', 'Set-SnipPreviewMenuStyle', + 'Connect-SnipPreviewMenuButton', 'Connect-SnipPreviewTransientContextMenu', + 'New-SnipSplitControl', 'Set-SnipPropertyIsland', + 'Set-SnipPreviewStatusPresentation', 'Set-SnipPreviewResponsiveMode', + 'Initialize-SnipPreviewAnnotations', 'New-SnipPreviewWindow', + 'Show-PreviewWindow' + ) + 'src/50-Tray.ps1' = @( + 'Show-SettingsWindow', 'Show-AboutWindow', 'Clear-SnipWidgetWindow', + 'Show-FloatingWidget', 'Register-SnipHotkeyBinding', + 'Set-SnipHotkeyBinding', 'New-SnipTrayMenu' + ) + 'src/90-Main.ps1' = @() +} + +$ownerByFunction = @{} +foreach ($entry in $moduleFunctions.GetEnumerator()) { + foreach ($name in $entry.Value) { + if ($ownerByFunction.ContainsKey($name)) { + throw "Duplicate function ownership in exporter map: $name" + } + $ownerByFunction[$name] = $entry.Key + } +} + +$resolvedSource = [IO.Path]::GetFullPath($SourcePath) +$resolvedDestination = [IO.Path]::GetFullPath($DestinationRoot) +$sourceAst = Get-SnipParsedScript -Path $resolvedSource +$sourceText = [IO.File]::ReadAllText( + $resolvedSource, [Text.UTF8Encoding]::new($false, $true)) +$rootFunctions = @($sourceAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] +}, $true) | Where-Object { Test-SnipRootFunction -Function $_ }) + +$provenancePattern = '(?m)^# source: (src/[0-9]{2}-[^\r\n]+\.ps1)\r?\n' +$provenanceMatches = [regex]::Matches($sourceText, $provenancePattern) +if ($provenanceMatches.Count) { + if ($provenanceMatches.Count -ne $moduleFunctions.Count) { + throw "Expected $($moduleFunctions.Count) source provenance headers but found $($provenanceMatches.Count)" + } + + $moduleText = [ordered]@{} + $expectedModules = @($moduleFunctions.Keys) + for ($index = 0; $index -lt $provenanceMatches.Count; $index++) { + $match = $provenanceMatches[$index] + $module = $match.Groups[1].Value + if ($module -ne $expectedModules[$index]) { + throw "Unexpected source provenance order: $module" + } + + $start = $match.Index + $match.Length + $end = if ($index + 1 -lt $provenanceMatches.Count) { + $provenanceMatches[$index + 1].Index + } else { + $sourceText.Length + } + $moduleSource = $sourceText.Substring($start, $end - $start) + if ($module -eq 'src/00-Core.ps1') { + $tokens = $null + $errors = $null + $moduleAst = [Management.Automation.Language.Parser]::ParseInput( + $moduleSource, [ref]$tokens, [ref]$errors) + if ($errors.Count) { + throw "Provenance Core parse failed: $($errors.Message -join [Environment]::NewLine)" + } + $embedded = @($moduleAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left.Extent.Text -eq '$script:SnipEmbeddedXaml' + }, $true)) + if ($embedded.Count -ne 1) { + throw "Expected one generated embedded XAML assignment but found $($embedded.Count)" + } + $moduleSource = $moduleSource.Remove( + $embedded[0].Extent.StartOffset, + $embedded[0].Extent.EndOffset - $embedded[0].Extent.StartOffset) + } + $moduleText[$module] = ConvertTo-SnipModuleText -Sections @($moduleSource) + } +} +else { +$functionsByModule = @{} +foreach ($module in $moduleFunctions.Keys) { + $functionsByModule[$module] = [Collections.Generic.List[object]]::new() +} +foreach ($function in $rootFunctions) { + if (-not $ownerByFunction.ContainsKey($function.Name)) { + throw "Unknown or unowned root function: $($function.Name)" + } + $functionsByModule[$ownerByFunction[$function.Name]].Add($function) +} +foreach ($name in $ownerByFunction.Keys) { + $matches = @($rootFunctions | Where-Object Name -eq $name) + if ($matches.Count -ne 1) { + throw "Expected one root function '$name' but found $($matches.Count)" + } +} + +$tryStatement = @($sourceAst.EndBlock.Statements | + Where-Object { $_ -is [Management.Automation.Language.TryStatementAst] }) +if ($tryStatement.Count -ne 1) { + throw "Expected one outer startup try statement but found $($tryStatement.Count)" +} +$startupTry = $tryStatement[0] + +$ownershipUnits = @( + @($sourceAst.EndBlock.Statements | Where-Object { $_ -ne $startupTry }) + + @($startupTry.Body.Statements) +) | Sort-Object { $_.Extent.StartOffset } +for ($index = 1; $index -lt $ownershipUnits.Count; $index++) { + $previous = $ownershipUnits[$index - 1] + $current = $ownershipUnits[$index] + if ($previous.Extent.EndOffset -gt $current.Extent.StartOffset) { + throw "Overlapping ownership extents: '$($previous.Extent.Text)' and '$($current.Extent.Text)'" + } +} + +$coreGate = $null +$bootstrapDefaults = [Collections.Generic.List[string]]::new() +$mainDefaults = [Collections.Generic.List[string]]::new() +$excludedBridgeCount = 0 +foreach ($statement in $sourceAst.EndBlock.Statements) { + if ($statement -is [Management.Automation.Language.FunctionDefinitionAst] -or + $statement -eq $startupTry) { + continue + } + if ($statement -is [Management.Automation.Language.IfStatementAst] -and + $statement.Extent.Text -eq 'if ($CoreOnly) { return }') { + $coreGate = $statement.Extent.Text + continue + } + if ($statement -isnot [Management.Automation.Language.AssignmentStatementAst]) { + throw "Unassigned script-root statement: $($statement.Extent.Text)" + } + $left = $statement.Left.Extent.Text + switch ($left) { + '$script:SnipEmbeddedXaml' { $excludedBridgeCount++; continue } + '$script:SnipSourceRoot' { $excludedBridgeCount++; continue } + '$script:UndoStackMaxDepth' { $bootstrapDefaults.Add($statement.Extent.Text); continue } + '$script:DiagRingSize' { $bootstrapDefaults.Add($statement.Extent.Text); continue } + '$script:DiagRing' { $bootstrapDefaults.Add($statement.Extent.Text); continue } + '$script:SnipITAppVersion' { $bootstrapDefaults.Add($statement.Extent.Text); continue } + '$script:SingleInstanceMutex' { $bootstrapDefaults.Add($statement.Extent.Text); continue } + '$script:UtilityContext' { $bootstrapDefaults.Add($statement.Extent.Text); continue } + '$hotkeyForm' { $mainDefaults.Add($statement.Extent.Text); continue } + '$tray' { $mainDefaults.Add($statement.Extent.Text); continue } + '$menu' { $mainDefaults.Add($statement.Extent.Text); continue } + '$trayDoubleClickHandler' { $mainDefaults.Add($statement.Extent.Text); continue } + '$hkWin' { $mainDefaults.Add($statement.Extent.Text); continue } + '$registeredHotkey' { $mainDefaults.Add($statement.Extent.Text); continue } + default { throw "Unassigned script-root assignment: $left" } + } +} +if ($null -eq $coreGate -or $excludedBridgeCount -ne 2) { + throw 'CoreOnly gate or generated-only bridge inventory differs from the reviewed source' +} + +$bootstrapPhase = [Collections.Generic.List[string]]::new() +$nativePhase = [Collections.Generic.List[string]]::new() +$settingsPhase = [Collections.Generic.List[string]]::new() +$nativeSentinels = [Collections.Generic.List[string]]::new() +$previewSentinels = [Collections.Generic.List[string]]::new() +$captureSentinels = [Collections.Generic.List[string]]::new() +$traySentinels = [Collections.Generic.List[string]]::new() +$mainStatements = [Collections.Generic.List[string]]::new() +$phase = 'Bootstrap' +foreach ($statement in $startupTry.Body.Statements) { + if ($statement -is [Management.Automation.Language.FunctionDefinitionAst]) { + continue + } + + $left = if ($statement -is [Management.Automation.Language.AssignmentStatementAst]) { + $statement.Left.Extent.Text + } else { '' } + if ($phase -eq 'Bootstrap' -and $left -eq '$pinvoke') { $phase = 'Native' } + if ($phase -eq 'Native' -and $left -eq '$script:Settings') { $phase = 'Settings' } + if ($phase -eq 'Settings' -and $left -eq '$script:SelfWindowHandles') { $phase = 'Sentinels' } + if ($phase -eq 'Sentinels' -and $left -eq '$script:CurrentPreviewWindow') { $phase = 'PreviewSentinel' } + if ($phase -eq 'PreviewSentinel' -and $left -eq '$script:CaptureCoordinator') { $phase = 'CaptureSentinel' } + if ($phase -eq 'CaptureSentinel' -and $left -eq '$script:WidgetWindow') { $phase = 'TraySentinel' } + if ($statement -is [Management.Automation.Language.IfStatementAst] -and + $statement.Extent.Text -eq 'if ($env:SNIPIT_TEST_MODE) { return }') { + $phase = 'Main' + } + + switch ($phase) { + 'Bootstrap' { $bootstrapPhase.Add($statement.Extent.Text) } + 'Native' { $nativePhase.Add($statement.Extent.Text) } + 'Settings' { $settingsPhase.Add($statement.Extent.Text) } + 'Sentinels' { $nativeSentinels.Add($statement.Extent.Text) } + 'PreviewSentinel' { $previewSentinels.Add($statement.Extent.Text) } + 'CaptureSentinel' { $captureSentinels.Add($statement.Extent.Text) } + 'TraySentinel' { $traySentinels.Add($statement.Extent.Text) } + 'Main' { $mainStatements.Add($statement.Extent.Text) } + default { throw "Unknown extraction phase: $phase" } + } +} + +if ($bootstrapPhase.Count -ne 17 -or $nativePhase.Count -ne 3 -or + $settingsPhase.Count -ne 3 -or $nativeSentinels.Count -ne 2 -or + $previewSentinels.Count -ne 1 -or $captureSentinels.Count -ne 1 -or + $traySentinels.Count -ne 1 -or $mainStatements.Count -ne 54) { + throw (('Direct startup statement inventory differs: bootstrap={0}, native={1}, ' + + 'settings={2}, nativeSentinels={3}, preview={4}, capture={5}, tray={6}, main={7}') -f + $bootstrapPhase.Count, $nativePhase.Count, $settingsPhase.Count, + $nativeSentinels.Count, $previewSentinels.Count, $captureSentinels.Count, + $traySentinels.Count, $mainStatements.Count) +} + +$firstFunction = $rootFunctions | Sort-Object { $_.Extent.StartOffset } | Select-Object -First 1 +$coreHeader = $sourceText.Substring(0, $firstFunction.Extent.StartOffset) +$moduleText = [ordered]@{} +foreach ($module in $moduleFunctions.Keys) { + $sections = [Collections.Generic.List[string]]::new() + if ($module -eq 'src/00-Core.ps1') { + $sections.Add($coreHeader) + $sections.Add(@' +if (-not (Get-Variable -Name SnipEntryPath -Scope Script -ErrorAction Ignore)) { + $script:SnipEntryPath = $PSCommandPath +} +if (-not (Get-Variable -Name SnipInstallSourcePath -Scope Script -ErrorAction Ignore)) { + $script:SnipInstallSourcePath = $PSCommandPath +} +'@) + } else { + $sections.Add("#requires -Version 7.5`n# Generated deterministically from the reviewed SnipIT.ps1 AST by scripts/Export-SnipITModules.ps1.") + } + + switch ($module) { + 'src/10-Bootstrap.ps1' { + foreach ($text in $bootstrapDefaults) { $sections.Add($text) } + $bootstrapText = @( + '$script:SnipBootstrapInitializer = {' + ' param([ValidateSet(''Bootstrap'', ''Settings'')][string]$Phase)' + ' if ($Phase -eq ''Bootstrap'') {' + ($bootstrapPhase | ForEach-Object { + $_.Replace('$PSCommandPath', '$script:SnipEntryPath') + }) + ' return $true' + ' }' + ($settingsPhase | ForEach-Object { + $_.Replace('$freshInstall', '$script:SnipFreshInstall') + }) + ' $true' + '}' + ) -join "`n`n" + $sections.Add($bootstrapText) + } + 'src/20-Native.ps1' { + $nativeText = @( + '$script:SnipNativeInitializer = {' + ($nativePhase | ForEach-Object { $_ }) + ($nativeSentinels | ForEach-Object { $_ }) + '}' + ) -join "`n`n" + $sections.Add($nativeText) + } + 'src/30-Capture.ps1' { + foreach ($text in $captureSentinels) { $sections.Add($text) } + } + 'src/40-Preview.ps1' { + foreach ($text in $previewSentinels) { $sections.Add($text) } + } + 'src/50-Tray.ps1' { + foreach ($text in $traySentinels) { $sections.Add($text) } + } + } + + foreach ($function in @($functionsByModule[$module] | + Sort-Object { $_.Extent.StartOffset })) { + $functionText = $function.Extent.Text + if ($module -eq 'src/10-Bootstrap.ps1') { + $pathState = if ($function.Name -eq 'Install-SnipIT') { + '$script:SnipInstallSourcePath' + } else { + '$script:SnipEntryPath' + } + $functionText = $functionText.Replace('$PSCommandPath', $pathState) + } + $sections.Add($functionText) + } + if ($module -eq 'src/00-Core.ps1') { + $sections.Add($coreGate) + } + if ($module -eq 'src/90-Main.ps1') { + foreach ($text in $mainDefaults) { $sections.Add($text) } + $tryBody = [Collections.Generic.List[string]]::new() + $tryBody.Add('$bootstrapReady = & $script:SnipBootstrapInitializer -Phase Bootstrap') + $tryBody.Add('if (-not $bootstrapReady) { return }') + $tryBody.Add('& $script:SnipNativeInitializer') + $tryBody.Add('$null = & $script:SnipBootstrapInitializer -Phase Settings') + $tryBody.Add('$script:SnipBootstrapInitializer = $null') + $tryBody.Add('$script:SnipNativeInitializer = $null') + foreach ($text in $mainStatements) { + $tryBody.Add($text.Replace( + '$freshInstall', '$script:SnipFreshInstall')) + } + $finallyText = $startupTry.Finally.Extent.Text.TrimEnd() + $finallyText = $finallyText.Substring(0, $finallyText.Length - 1).TrimEnd() + @' + + $script:SnipBootstrapInitializer = $null + $script:SnipNativeInitializer = $null +} +'@ + $mainText = "try {`n" + (($tryBody | ForEach-Object { $_ }) -join "`n`n") + + "`n}`n" + $startupTry.CatchClauses[0].Extent.Text + + "`nfinally " + $finallyText + $sections.Add($mainText) + } + $moduleText[$module] = ConvertTo-SnipModuleText -Sections $sections +} +} + +$combined = ($moduleFunctions.Keys | ForEach-Object { $moduleText[$_] }) -join "`n" +$combinedTokens = $null +$combinedErrors = $null +$combinedAst = [Management.Automation.Language.Parser]::ParseInput( + $combined, [ref]$combinedTokens, [ref]$combinedErrors) +if ($combinedErrors.Count) { + throw "Combined module parse failed: $($combinedErrors.Message -join [Environment]::NewLine)" +} +if ((Get-SnipSurfaceJson -Ast $sourceAst) -ne (Get-SnipSurfaceJson -Ast $combinedAst)) { + throw 'Combined module function surface or multiplicity differs from the source' +} + +[IO.Directory]::CreateDirectory((Join-Path $resolvedDestination 'src')) | Out-Null +$temporaryPaths = [Collections.Generic.List[string]]::new() +try { + foreach ($module in $moduleFunctions.Keys) { + $target = Join-Path $resolvedDestination $module + $temporary = "$target.tmp.$PID" + [IO.File]::WriteAllText( + $temporary, $moduleText[$module], [Text.UTF8Encoding]::new($false)) + [void](Get-SnipParsedScript -Path $temporary) + $temporaryPaths.Add($temporary) + } + foreach ($module in $moduleFunctions.Keys) { + $target = Join-Path $resolvedDestination $module + Move-Item -LiteralPath "$target.tmp.$PID" -Destination $target -Force + } +} +finally { + foreach ($temporary in $temporaryPaths) { + if (Test-Path -LiteralPath $temporary) { + Remove-Item -LiteralPath $temporary -Force + } + } +} + +[pscustomobject]@{ + SourcePath = $resolvedSource + DestinationRoot = $resolvedDestination + RootFunctions = $rootFunctions.Count + TotalFunctions = @($sourceAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] + }, $true)).Count +} diff --git a/src/00-Core.ps1 b/src/00-Core.ps1 new file mode 100644 index 0000000..6b303df --- /dev/null +++ b/src/00-Core.ps1 @@ -0,0 +1,2548 @@ +#requires -Version 7.5 +<# + SnipIT — professional snipping tool for Windows 11 + Pure PowerShell 7.5+ on .NET 9. No admin. No external dependencies. + + 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 + + Run with -CoreOnly to dot-source cross-platform logic/contracts (used by tests). +#> +param([switch]$CoreOnly) + +#region Core (pure logic, no UI / no Win32, cross-platform testable) ========= + +if (-not (Get-Variable -Name SnipEntryPath -Scope Script -ErrorAction Ignore)) { + $script:SnipEntryPath = $PSCommandPath +} +if (-not (Get-Variable -Name SnipInstallSourcePath -Scope Script -ErrorAction Ignore)) { + $script:SnipInstallSourcePath = $PSCommandPath +} + +function Get-DragRectangle { + param( + [Parameter(Mandatory)] [double]$AnchorX, + [Parameter(Mandatory)] [double]$AnchorY, + [Parameter(Mandatory)] [double]$CurrentX, + [Parameter(Mandatory)] [double]$CurrentY + ) + [pscustomobject]@{ + X = [math]::Min($AnchorX, $CurrentX) + Y = [math]::Min($AnchorY, $CurrentY) + Width = [math]::Abs($CurrentX - $AnchorX) + Height = [math]::Abs($CurrentY - $AnchorY) + } +} + +function Test-IsClickVsDrag { + param( + [Parameter(Mandatory)] [double]$AnchorX, + [Parameter(Mandatory)] [double]$AnchorY, + [Parameter(Mandatory)] [double]$CurrentX, + [Parameter(Mandatory)] [double]$CurrentY, + [double]$Threshold = 4 + ) + $dx = [math]::Abs($CurrentX - $AnchorX) + $dy = [math]::Abs($CurrentY - $AnchorY) + if ($dx -lt $Threshold -and $dy -lt $Threshold) { 'click' } else { 'drag' } +} + +function Get-LoupeSourceRect { + param( + [Parameter(Mandatory)] [int]$MouseX, + [Parameter(Mandatory)] [int]$MouseY, + [Parameter(Mandatory)] [int]$VsX, + [Parameter(Mandatory)] [int]$VsY, + [Parameter(Mandatory)] [int]$VsWidth, + [Parameter(Mandatory)] [int]$VsHeight, + [int]$Size = 18 + ) + $half = [math]::Floor($Size / 2) + $sx = $MouseX - $VsX - $half + $sy = $MouseY - $VsY - $half + $sx = [math]::Max(0, [math]::Min($VsWidth - $Size, $sx)) + $sy = [math]::Max(0, [math]::Min($VsHeight - $Size, $sy)) + [pscustomobject]@{ X = [int]$sx; Y = [int]$sy; Size = $Size } +} + +function Get-LoupePosition { + # Position the magnifier loupe near the cursor, flipping to the opposite side + # when it would spill off the virtual screen. + # $Offset — gap below/right of the cursor in the default position + # $FlipMarginX/Y — gap above/left of the cursor after a flip (smaller than + # $Offset so the flipped loupe sits tighter to the cursor) + param( + [Parameter(Mandatory)] [int]$MouseX, + [Parameter(Mandatory)] [int]$MouseY, + [Parameter(Mandatory)] [int]$VsX, + [Parameter(Mandatory)] [int]$VsY, + [Parameter(Mandatory)] [int]$VsWidth, + [Parameter(Mandatory)] [int]$VsHeight, + [int]$LoupeWidth = 170, + [int]$LoupeHeight = 190, + [int]$Offset = 24, + [int]$FlipMarginX = 14, + [int]$FlipMarginY = 10 + ) + $lx = $MouseX - $VsX + $Offset + $ly = $MouseY - $VsY + $Offset + if ($lx + $LoupeWidth -gt $VsWidth) { $lx = $MouseX - $VsX - $LoupeWidth - $FlipMarginX } + if ($ly + $LoupeHeight -gt $VsHeight) { $ly = $MouseY - $VsY - $LoupeHeight - $FlipMarginY } + [pscustomobject]@{ X = [int]$lx; Y = [int]$ly } +} + +function Get-DefaultSnipFilename { + param([datetime]$Timestamp = (Get-Date)) + "snip-{0:yyyyMMdd-HHmmss}.png" -f $Timestamp +} + +function Get-ImageFormatNameFromPath { + param([Parameter(Mandatory)] [string]$Path) + switch ([IO.Path]::GetExtension($Path).ToLower()) { + '.jpg' { 'Jpeg' } + '.jpeg' { 'Jpeg' } + '.bmp' { 'Bmp' } + default { 'Png' } + } +} + +function Resolve-SaveImagePath { + # If the user typed a non-image extension (e.g. "foo.txt"), force it to match the + # selected filter so we never save PNG bytes under a misleading extension. + param( + [Parameter(Mandatory)] [string]$Path, + [Parameter(Mandatory)] [ValidateSet('Png','Jpeg','Bmp')] [string]$FilterFormat + ) + $ext = [IO.Path]::GetExtension($Path).ToLower() + if ($ext -in '.png','.jpg','.jpeg','.bmp') { return $Path } + $targetExt = switch ($FilterFormat) { 'Jpeg' { '.jpg' } 'Bmp' { '.bmp' } default { '.png' } } + $dir = [IO.Path]::GetDirectoryName($Path) + $base = [IO.Path]::GetFileNameWithoutExtension($Path) + if ([string]::IsNullOrEmpty($dir)) { "$base$targetExt" } else { Join-Path $dir "$base$targetExt" } +} + +function Test-CaptureRectValid { + param( + [Parameter(Mandatory)] [int]$Width, + [Parameter(Mandatory)] [int]$Height, + [int]$MinSize = 2 + ) + ($Width -ge $MinSize) -and ($Height -ge $MinSize) +} + +function Get-CropBounds { + param( + [Parameter(Mandatory)] [int]$RectX, + [Parameter(Mandatory)] [int]$RectY, + [Parameter(Mandatory)] [int]$RectW, + [Parameter(Mandatory)] [int]$RectH, + [Parameter(Mandatory)] [int]$VsX, + [Parameter(Mandatory)] [int]$VsY + ) + [pscustomobject]@{ + X = $RectX - $VsX + Y = $RectY - $VsY + Width = $RectW + Height = $RectH + } +} + +function Get-InstallPaths { + param( + [string]$LocalAppData = $env:LOCALAPPDATA, + [string]$DesktopDir, + [string]$StartupDir + ) + [pscustomobject]@{ + AppDir = Join-Path $LocalAppData 'SnipIT' + ScriptPath = Join-Path (Join-Path $LocalAppData 'SnipIT') 'SnipIT.ps1' + Marker = Join-Path (Join-Path $LocalAppData 'SnipIT') '.installed' + DesktopShortcut = if ($DesktopDir) { Join-Path $DesktopDir 'SnipIT.lnk' } else { $null } + StartupShortcut = if ($StartupDir) { Join-Path $StartupDir 'SnipIT.lnk' } else { $null } + } +} + +function Get-ShortcutArguments { + param([Parameter(Mandatory)] [string]$ScriptPath) + "-NoProfile -WindowStyle Hidden -Sta -File `"$ScriptPath`"" +} + +function Get-ClampedAnnotationRect { + # Clamp an annotation rect (image-pixel coords) to a bitmap's bounds. + # The origin is pinned to [0, W-1] / [0, H-1], and the width/height are + # clamped so the rect still fits. Minimum size is 1x1 so the annotation + # doesn't become zero-area if the user drew entirely past the right/bottom edge. + param( + [Parameter(Mandatory)] [int]$X, + [Parameter(Mandatory)] [int]$Y, + [Parameter(Mandatory)] [int]$Width, + [Parameter(Mandatory)] [int]$Height, + [Parameter(Mandatory)] [int]$BitmapWidth, + [Parameter(Mandatory)] [int]$BitmapHeight + ) + $nx = [math]::Max(0, [math]::Min($BitmapWidth - 1, $X)) + $ny = [math]::Max(0, [math]::Min($BitmapHeight - 1, $Y)) + $nw = [math]::Max(1, [math]::Min($BitmapWidth - $nx, $Width)) + $nh = [math]::Max(1, [math]::Min($BitmapHeight - $ny, $Height)) + [pscustomobject]@{ X = $nx; Y = $ny; Width = $nw; Height = $nh } +} + +function Get-ZoomCenteredOffset { + # Compute the scroll offset that keeps the content point currently under + # ($CursorX, $CursorY) anchored after a scale change (Ctrl+MouseWheel zoom). + # Content coords = viewport-offset + viewport-position; if the same image + # pixel should land on the same viewport pixel after scaling, the offset + # must shift by (OldOffset + Cursor) * NewScale/OldScale - Cursor. + # Result is clamped to [0, Content - Viewport]. + param( + [Parameter(Mandatory)] [double]$CursorX, + [Parameter(Mandatory)] [double]$CursorY, + [Parameter(Mandatory)] [double]$OldScrollX, + [Parameter(Mandatory)] [double]$OldScrollY, + [Parameter(Mandatory)] [double]$OldScale, + [Parameter(Mandatory)] [double]$NewScale, + [Parameter(Mandatory)] [double]$ContentWidth, + [Parameter(Mandatory)] [double]$ContentHeight, + [Parameter(Mandatory)] [double]$ViewportWidth, + [Parameter(Mandatory)] [double]$ViewportHeight + ) + if ($OldScale -le 0) { $OldScale = 1.0 } + $ratio = $NewScale / $OldScale + $newX = ($OldScrollX + $CursorX) * $ratio - $CursorX + $newY = ($OldScrollY + $CursorY) * $ratio - $CursorY + $maxX = [math]::Max(0.0, $ContentWidth - $ViewportWidth) + $maxY = [math]::Max(0.0, $ContentHeight - $ViewportHeight) + $newX = [math]::Max(0.0, [math]::Min($maxX, $newX)) + $newY = [math]::Max(0.0, [math]::Min($maxY, $newY)) + [pscustomobject]@{ X = $newX; Y = $newY } +} + +function Get-SnipRecordValue { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $InputObject, + [Parameter(Mandatory)] [string]$Name, + [AllowNull()] $Default = $null, + [switch]$Required + ) + + $found = $false + $value = $null + if ($InputObject -is [System.Collections.IDictionary]) { + foreach ($key in $InputObject.Keys) { + if ([string]$key -ieq $Name) { + $value = $InputObject[$key] + $found = $true + break + } + } + } elseif ($null -ne $InputObject.PSObject) { + $property = $InputObject.PSObject.Properties[$Name] + if ($null -ne $property) { + $value = $property.Value + $found = $true + } + } + + if ($found) { return $value } + if ($Required) { + throw [ArgumentException]::new("Expected property '$Name'.", $Name) + } + return $Default +} + +function Test-SnipAtomicSemanticValue { + param([AllowNull()] $Value) + + if ($null -eq $Value) { return $false } + # Semantic records currently need only immutable text, booleans, characters, + # and numeric scalars. An explicit list prevents arbitrary structs from + # smuggling mutable or disposable references into history snapshots. + $typeName = $Value.GetType().FullName + $typeName -in @( + 'System.String', 'System.Boolean', 'System.Char', + 'System.SByte', 'System.Byte', 'System.Int16', 'System.UInt16', + 'System.Int32', 'System.UInt32', 'System.Int64', 'System.UInt64', + 'System.Single', 'System.Double', 'System.Decimal' + ) +} + +function Copy-SnipSemanticKey { + param([AllowNull()] $Key) + + if ($null -eq $Key -or -not (Test-SnipAtomicSemanticValue -Value $Key)) { + $typeName = if ($null -eq $Key) { '' } else { $Key.GetType().FullName } + throw [ArgumentException]::new( + "Unsupported semantic dictionary key type '$typeName'.", 'Key') + } + if ($Key -is [string]) { + # Materialize a separate immutable key object instead of retaining the + # source dictionary's reference. + return [string]::new($Key.ToCharArray()) + } + return $Key +} + +function Copy-SnipSemanticValue { + param([AllowNull()] $Value) + + if ($null -eq $Value) { return $null } + # Disposable preview/cache objects are deliberately not semantic record + # data. Reject them without disposing; Task 9 owns caches outside history. + if ($Value -is [System.IDisposable]) { + throw [ArgumentException]::new( + "Disposable semantic values are unsupported ($($Value.GetType().FullName)).", + 'Value') + } + if (Test-SnipAtomicSemanticValue -Value $Value) { return $Value } + if ($Value.GetType().IsValueType) { + throw [ArgumentException]::new( + "Unsupported semantic value type '$($Value.GetType().FullName)'.", 'Value') + } + + if ($Value -is [System.Collections.Specialized.OrderedDictionary]) { + $copy = [ordered]@{} + foreach ($entry in $Value.GetEnumerator()) { + $keyCopy = Copy-SnipSemanticKey -Key $entry.Key + $valueCopy = Copy-SnipSemanticValue -Value $entry.Value + $copy.Add($keyCopy, $valueCopy) + } + return $copy + } + if ($Value -is [System.Collections.IDictionary]) { + $copy = @{} + foreach ($entry in $Value.GetEnumerator()) { + $keyCopy = Copy-SnipSemanticKey -Key $entry.Key + $valueCopy = Copy-SnipSemanticValue -Value $entry.Value + $copy.Add($keyCopy, $valueCopy) + } + return $copy + } + if ($Value.GetType().IsArray) { + $copy = @($Value | ForEach-Object { Copy-SnipSemanticValue -Value $_ }) + return ,$copy + } + if ($Value -is [System.Collections.IList]) { + $copy = [System.Collections.ArrayList]::new() + foreach ($item in $Value) { + [void]$copy.Add((Copy-SnipSemanticValue -Value $item)) + } + return ,$copy + } + if ($Value -is [pscustomobject]) { + $properties = [ordered]@{} + foreach ($property in $Value.PSObject.Properties) { + if (-not $property.IsGettable) { continue } + $properties[$property.Name] = Copy-SnipSemanticValue -Value $property.Value + } + return [pscustomobject]$properties + } + + # Unknown reference types could be mutable. Reject rather than introducing + # a hidden alias or guessing at clone/ownership semantics. + throw [ArgumentException]::new( + "Unsupported semantic reference type '$($Value.GetType().FullName)'.", 'Value') +} + +function ConvertTo-SnipAnnotationGeometry { + param([Parameter(Mandatory)] $Geometry) + + $type = [string](Get-SnipRecordValue -InputObject $Geometry -Name Type -Required) + switch ($type.ToLowerInvariant()) { + 'bounds' { + return [pscustomobject][ordered]@{ + Type = 'Bounds' + X = [int](Get-SnipRecordValue -InputObject $Geometry -Name X -Required) + Y = [int](Get-SnipRecordValue -InputObject $Geometry -Name Y -Required) + Width = [int](Get-SnipRecordValue -InputObject $Geometry -Name Width -Required) + Height = [int](Get-SnipRecordValue -InputObject $Geometry -Name Height -Required) + } + } + 'textbounds' { + return [pscustomobject][ordered]@{ + Type = 'TextBounds' + X = [int](Get-SnipRecordValue -InputObject $Geometry -Name X -Required) + Y = [int](Get-SnipRecordValue -InputObject $Geometry -Name Y -Required) + Width = [int](Get-SnipRecordValue -InputObject $Geometry -Name Width -Required) + Height = [int](Get-SnipRecordValue -InputObject $Geometry -Name Height -Required) + } + } + 'stepbounds' { + return [pscustomobject][ordered]@{ + Type = 'StepBounds' + X = [int](Get-SnipRecordValue -InputObject $Geometry -Name X -Required) + Y = [int](Get-SnipRecordValue -InputObject $Geometry -Name Y -Required) + Width = [int](Get-SnipRecordValue -InputObject $Geometry -Name Width -Required) + Height = [int](Get-SnipRecordValue -InputObject $Geometry -Name Height -Required) + } + } + 'line' { + $start = Get-SnipRecordValue -InputObject $Geometry -Name Start -Required + $end = Get-SnipRecordValue -InputObject $Geometry -Name End -Required + return [pscustomobject][ordered]@{ + Type = 'Line' + Start = [pscustomobject][ordered]@{ + X = [int](Get-SnipRecordValue -InputObject $start -Name X -Required) + Y = [int](Get-SnipRecordValue -InputObject $start -Name Y -Required) + } + End = [pscustomobject][ordered]@{ + X = [int](Get-SnipRecordValue -InputObject $end -Name X -Required) + Y = [int](Get-SnipRecordValue -InputObject $end -Name Y -Required) + } + } + } + 'points' { + $sourcePoints = Get-SnipRecordValue -InputObject $Geometry -Name Points -Required + $points = [System.Collections.ArrayList]::new() + if ($null -ne $sourcePoints) { + foreach ($point in $sourcePoints) { + [void]$points.Add([pscustomobject][ordered]@{ + X = [int](Get-SnipRecordValue -InputObject $point -Name X -Required) + Y = [int](Get-SnipRecordValue -InputObject $point -Name Y -Required) + }) + } + } + return [pscustomobject][ordered]@{ Type = 'Points'; Points = @($points) } + } + default { + # Unknown future geometry remains copyable and harmless. Consumers + # that do not understand its discriminator must safely ignore it. + return Copy-SnipSemanticValue -Value $Geometry + } + } +} + +function New-SnipAnnotation { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Kind, + [Parameter(Mandatory)] $Geometry, + [Parameter(Mandatory)] [AllowEmptyString()] [string]$Color, + [Parameter(Mandatory)] [double]$StrokeWidth, + [Parameter(Mandatory)] [double]$Opacity, + [Parameter(Mandatory)] [AllowNull()] $Properties, + [Parameter(Mandatory)] [double]$Z, + [AllowNull()] [AllowEmptyString()] [string]$Id + ) + + if ($PSBoundParameters.ContainsKey('Id')) { + if ([string]::IsNullOrWhiteSpace($Id)) { + throw [ArgumentException]::new('An explicit annotation Id must be nonempty.', 'Id') + } + $recordId = $Id + } else { + $recordId = [guid]::NewGuid().ToString() + } + $semanticProperties = if ($null -eq $Properties) { + [ordered]@{} + } else { + Copy-SnipSemanticValue -Value $Properties + } + + [pscustomobject][ordered]@{ + Id = $recordId + Kind = $Kind + Geometry = ConvertTo-SnipAnnotationGeometry -Geometry $Geometry + Color = $Color + StrokeWidth = $StrokeWidth + Opacity = $Opacity + Properties = $semanticProperties + Z = $Z + } +} + +function Copy-SnipAnnotation { + param([Parameter(Mandatory)] $Annotation) + + $kind = Get-SnipRecordValue -InputObject $Annotation -Name Kind + $geometry = Get-SnipRecordValue -InputObject $Annotation -Name Geometry + $id = [string](Get-SnipRecordValue -InputObject $Annotation -Name Id) + if (-not [string]::IsNullOrWhiteSpace([string]$kind) -and $null -ne $geometry) { + if ([string]::IsNullOrWhiteSpace($id)) { + throw [ArgumentException]::new( + 'A canonical annotation must already own a nonempty stable Id.', 'Annotation') + } + $arguments = @{ + Kind = [string]$kind + Geometry = $geometry + Color = [string](Get-SnipRecordValue -InputObject $Annotation -Name Color -Default '') + StrokeWidth = [double](Get-SnipRecordValue -InputObject $Annotation -Name StrokeWidth -Default 1.0) + Opacity = [double](Get-SnipRecordValue -InputObject $Annotation -Name Opacity -Default 1.0) + Properties = Get-SnipRecordValue -InputObject $Annotation -Name Properties -Default ([ordered]@{}) + Z = [double](Get-SnipRecordValue -InputObject $Annotation -Name Z -Default 0) + Id = $id + } + return New-SnipAnnotation @arguments + } + + $legacyType = [string](Get-SnipRecordValue -InputObject $Annotation -Name Type -Required) + $x = [int](Get-SnipRecordValue -InputObject $Annotation -Name X -Required) + $y = [int](Get-SnipRecordValue -InputObject $Annotation -Name Y -Required) + $width = [int](Get-SnipRecordValue -InputObject $Annotation -Name W -Required) + $height = [int](Get-SnipRecordValue -InputObject $Annotation -Name H -Required) + $properties = [ordered]@{} + switch ($legacyType.ToLowerInvariant()) { + 'highlight' { + $canonicalKind = 'Highlight' + $canonicalGeometry = [pscustomobject]@{ + Type='Bounds'; X=$x; Y=$y; Width=$width; Height=$height + } + } + 'rect' { + $canonicalKind = 'Rectangle' + $canonicalGeometry = [pscustomobject]@{ + Type='Bounds'; X=$x; Y=$y; Width=$width; Height=$height + } + } + 'arrow' { + $canonicalKind = 'Arrow' + $canonicalGeometry = [pscustomobject]@{ + Type='Line' + Start=[pscustomobject]@{X=$x;Y=$y} + End=[pscustomobject]@{X=($x + $width);Y=($y + $height)} + } + } + 'text' { + $canonicalKind = 'Text' + $canonicalGeometry = [pscustomobject]@{ + Type='TextBounds'; X=$x; Y=$y; Width=$width; Height=$height + } + $properties.Text = Get-SnipRecordValue -InputObject $Annotation -Name Text + $properties.FontSize = Get-SnipRecordValue -InputObject $Annotation -Name FontSize -Default 0 + } + default { + throw [ArgumentException]::new("Unsupported legacy annotation type '$legacyType'.", 'Annotation') + } + } + + $arguments = @{ + Kind = $canonicalKind + Geometry = $canonicalGeometry + Color = [string](Get-SnipRecordValue -InputObject $Annotation -Name Color -Default '') + StrokeWidth = [double](Get-SnipRecordValue -InputObject $Annotation -Name StrokeWidth -Default 1.0) + Opacity = [double](Get-SnipRecordValue -InputObject $Annotation -Name Opacity -Default 1.0) + Properties = $properties + Z = [double](Get-SnipRecordValue -InputObject $Annotation -Name Z -Default 0) + } + if (-not [string]::IsNullOrWhiteSpace($id)) { $arguments.Id = $id } + New-SnipAnnotation @arguments +} + +function Copy-AnnotationList { + # Keep the historical non-enumerated ArrayList shape used by Preview history, + # while routing every member through the one canonical stable-record copier. + param([AllowNull()][AllowEmptyCollection()] $Annotations) + $copy = [System.Collections.ArrayList]::new() + if ($null -eq $Annotations) { return ,$copy } + foreach ($annotation in $Annotations) { + [void]$copy.Add((Copy-SnipAnnotation -Annotation $annotation)) + } + return ,$copy +} + +function New-SnipEditorSnapshot { + [CmdletBinding()] + param( + [AllowNull()][AllowEmptyCollection()] $Annotations, + [AllowNull()] $CropRectangle + ) + + $cropCopy = $null + if ($null -ne $CropRectangle) { + $cropCopy = [pscustomobject][ordered]@{ + X = [int](Get-SnipRecordValue -InputObject $CropRectangle -Name X -Required) + Y = [int](Get-SnipRecordValue -InputObject $CropRectangle -Name Y -Required) + Width = [int](Get-SnipRecordValue -InputObject $CropRectangle -Name Width -Required) + Height = [int](Get-SnipRecordValue -InputObject $CropRectangle -Name Height -Required) + } + } + [pscustomobject][ordered]@{ + Version = 1 + Annotations = Copy-AnnotationList -Annotations $Annotations + CropRectangle = $cropCopy + } +} + +function Test-SnipPointNearSegment { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [double]$PointX, + [Parameter(Mandatory)] [double]$PointY, + [Parameter(Mandatory)] [double]$StartX, + [Parameter(Mandatory)] [double]$StartY, + [Parameter(Mandatory)] [double]$EndX, + [Parameter(Mandatory)] [double]$EndY, + [Parameter(Mandatory)] [double]$Tolerance + ) + + $dx = $EndX - $StartX + $dy = $EndY - $StartY + $lengthSquared = ($dx * $dx) + ($dy * $dy) + if ($lengthSquared -le 0) { + $nearestX = $StartX + $nearestY = $StartY + } else { + $projection = ((($PointX - $StartX) * $dx) + (($PointY - $StartY) * $dy)) / $lengthSquared + $projection = [math]::Max(0.0, [math]::Min(1.0, $projection)) + $nearestX = $StartX + ($projection * $dx) + $nearestY = $StartY + ($projection * $dy) + } + $distanceX = $PointX - $nearestX + $distanceY = $PointY - $nearestY + (($distanceX * $distanceX) + ($distanceY * $distanceY)) -le ($Tolerance * $Tolerance) +} + +function Test-SnipAnnotationHit { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Annotation, + [Parameter(Mandatory)] [double]$ImageX, + [Parameter(Mandatory)] [double]$ImageY, + [Parameter(Mandatory)] [double]$Tolerance + ) + + $geometry = $Annotation.Geometry + if ($null -eq $geometry) { return $false } + $geometryType = [string](Get-SnipRecordValue -InputObject $geometry -Name Type) + switch ($geometryType.ToLowerInvariant()) { + { $_ -in 'bounds', 'textbounds', 'stepbounds' } { + $x = [double](Get-SnipRecordValue -InputObject $geometry -Name X -Required) + $y = [double](Get-SnipRecordValue -InputObject $geometry -Name Y -Required) + $width = [double](Get-SnipRecordValue -InputObject $geometry -Name Width -Required) + $height = [double](Get-SnipRecordValue -InputObject $geometry -Name Height -Required) + if ($width -le 0 -or $height -le 0) { return $false } + return ($ImageX -ge ($x - $Tolerance) -and + $ImageY -ge ($y - $Tolerance) -and + $ImageX -lt ($x + $width + $Tolerance) -and + $ImageY -lt ($y + $height + $Tolerance)) + } + 'line' { + $start = Get-SnipRecordValue -InputObject $geometry -Name Start -Required + $end = Get-SnipRecordValue -InputObject $geometry -Name End -Required + $startX = [double](Get-SnipRecordValue -InputObject $start -Name X -Required) + $startY = [double](Get-SnipRecordValue -InputObject $start -Name Y -Required) + $endX = [double](Get-SnipRecordValue -InputObject $end -Name X -Required) + $endY = [double](Get-SnipRecordValue -InputObject $end -Name Y -Required) + if ($startX -eq $endX -and $startY -eq $endY) { return $false } + return Test-SnipPointNearSegment -PointX $ImageX -PointY $ImageY ` + -StartX $startX -StartY $startY -EndX $endX -EndY $endY ` + -Tolerance $Tolerance + } + 'points' { + $points = @(Get-SnipRecordValue -InputObject $geometry -Name Points -Required) + if ($points.Count -eq 0) { return $false } + if ($points.Count -eq 1) { + $point = $points[0] + return Test-SnipPointNearSegment -PointX $ImageX -PointY $ImageY ` + -StartX (Get-SnipRecordValue -InputObject $point -Name X -Required) ` + -StartY (Get-SnipRecordValue -InputObject $point -Name Y -Required) ` + -EndX (Get-SnipRecordValue -InputObject $point -Name X -Required) ` + -EndY (Get-SnipRecordValue -InputObject $point -Name Y -Required) ` + -Tolerance $Tolerance + } + for ($index = 1; $index -lt $points.Count; $index++) { + $start = $points[$index - 1] + $end = $points[$index] + if (Test-SnipPointNearSegment -PointX $ImageX -PointY $ImageY ` + -StartX (Get-SnipRecordValue -InputObject $start -Name X -Required) ` + -StartY (Get-SnipRecordValue -InputObject $start -Name Y -Required) ` + -EndX (Get-SnipRecordValue -InputObject $end -Name X -Required) ` + -EndY (Get-SnipRecordValue -InputObject $end -Name Y -Required) ` + -Tolerance $Tolerance) { + return $true + } + } + return $false + } + default { return $false } + } +} + +function Get-SnipAnnotationHitView { + param([Parameter(Mandatory)] $Annotation) + + $kind = Get-SnipRecordValue -InputObject $Annotation -Name Kind + $geometry = Get-SnipRecordValue -InputObject $Annotation -Name Geometry + if (-not [string]::IsNullOrWhiteSpace([string]$kind) -and $null -ne $geometry) { + $id = [string](Get-SnipRecordValue -InputObject $Annotation -Name Id) + if ([string]::IsNullOrWhiteSpace($id)) { + throw [ArgumentException]::new( + 'A canonical annotation must own a nonempty stable Id.', 'Annotation') + } + return [pscustomobject]@{ + Geometry = $geometry + Z = [double](Get-SnipRecordValue -InputObject $Annotation -Name Z -Default 0) + } + } + + $legacyType = [string](Get-SnipRecordValue -InputObject $Annotation -Name Type -Required) + $x = [int](Get-SnipRecordValue -InputObject $Annotation -Name X -Required) + $y = [int](Get-SnipRecordValue -InputObject $Annotation -Name Y -Required) + $width = [int](Get-SnipRecordValue -InputObject $Annotation -Name W -Required) + $height = [int](Get-SnipRecordValue -InputObject $Annotation -Name H -Required) + $geometry = switch ($legacyType.ToLowerInvariant()) { + { $_ -in 'highlight', 'rect' } { + [pscustomobject]@{ Type='Bounds'; X=$x; Y=$y; Width=$width; Height=$height } + break + } + 'arrow' { + [pscustomobject]@{ + Type='Line'; Start=[pscustomobject]@{X=$x;Y=$y} + End=[pscustomobject]@{X=($x + $width);Y=($y + $height)} + } + break + } + 'text' { + [pscustomobject]@{ Type='TextBounds'; X=$x; Y=$y; Width=$width; Height=$height } + break + } + default { + throw [ArgumentException]::new( + "Unsupported legacy annotation type '$legacyType'.", 'Annotation') + } + } + [pscustomobject]@{ + Geometry = $geometry + Z = [double](Get-SnipRecordValue -InputObject $Annotation -Name Z -Default 0) + } +} + +function Find-SnipAnnotation { + [CmdletBinding()] + param( + [AllowNull()][AllowEmptyCollection()] $Annotations, + [Parameter(Mandatory)] [double]$ImageX, + [Parameter(Mandatory)] [double]$ImageY, + [double]$Tolerance = 6.0 + ) + + if ($null -eq $Annotations) { return $null } + $safeTolerance = [math]::Max(0.0, $Tolerance) + $bestSource = $null + $bestZ = [double]::NegativeInfinity + $bestIndex = -1 + $index = 0 + foreach ($annotation in $Annotations) { + try { + $hitView = Get-SnipAnnotationHitView -Annotation $annotation + if (-not (Test-SnipAnnotationHit -Annotation $hitView -ImageX $ImageX ` + -ImageY $ImageY -Tolerance $safeTolerance)) { + $index++ + continue + } + $z = [double]$hitView.Z + if ($null -eq $bestSource -or $z -gt $bestZ -or ($z -eq $bestZ -and $index -gt $bestIndex)) { + $bestSource = $annotation + $bestZ = $z + $bestIndex = $index + } + } catch { + # Malformed or unsupported records are not selectable; one bad + # compatibility record must not break selection for the whole list. + Write-Debug "Ignoring malformed annotation at list index $index." + } + $index++ + } + if ($null -eq $bestSource) { return $null } + try { + Copy-SnipAnnotation -Annotation $bestSource + } catch { + Write-Debug 'The selected annotation could not be normalized safely.' + return $null + } +} + +function Select-SnipAnnotation { + [CmdletBinding()] + param( + [AllowNull()][AllowEmptyCollection()] $Annotations, + [Parameter(Mandatory)] [double]$ImageX, + [Parameter(Mandatory)] [double]$ImageY, + [double]$Tolerance = 6.0 + ) + + $annotation = Find-SnipAnnotation -Annotations $Annotations -ImageX $ImageX ` + -ImageY $ImageY -Tolerance $Tolerance + if ($null -eq $annotation) { return $null } + [string]$annotation.Id +} + +function Move-SnipAnnotation { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Annotation, + [Parameter(Mandatory)] [int]$DeltaX, + [Parameter(Mandatory)] [int]$DeltaY, + [Parameter(Mandatory)] [int]$SourceWidth, + [Parameter(Mandatory)] [int]$SourceHeight + ) + + if ($SourceWidth -le 0) { + throw [ArgumentOutOfRangeException]::new('SourceWidth', 'Source width must be positive.') + } + if ($SourceHeight -le 0) { + throw [ArgumentOutOfRangeException]::new('SourceHeight', 'Source height must be positive.') + } + $copy = Copy-SnipAnnotation -Annotation $Annotation + $geometry = $copy.Geometry + switch ([string]$geometry.Type) { + { $_ -in 'Bounds', 'TextBounds', 'StepBounds' } { + if ([int]$geometry.Width -le 0 -or [int]$geometry.Height -le 0 -or + [int]$geometry.Width -gt $SourceWidth -or [int]$geometry.Height -gt $SourceHeight) { + throw [ArgumentException]::new( + 'Bounds geometry must have positive dimensions no larger than the source.', + 'Annotation') + } + $maxX = [math]::Max(0, $SourceWidth - [int]$geometry.Width) + $maxY = [math]::Max(0, $SourceHeight - [int]$geometry.Height) + $geometry.X = [int][math]::Max(0, [math]::Min($maxX, ([int]$geometry.X + $DeltaX))) + $geometry.Y = [int][math]::Max(0, [math]::Min($maxY, ([int]$geometry.Y + $DeltaY))) + } + 'Line' { + $points = @($geometry.Start, $geometry.End) + $minimumX = [int](($points | Measure-Object X -Minimum).Minimum) + $maximumX = [int](($points | Measure-Object X -Maximum).Maximum) + $minimumY = [int](($points | Measure-Object Y -Minimum).Minimum) + $maximumY = [int](($points | Measure-Object Y -Maximum).Maximum) + if (($maximumX - $minimumX) -gt ($SourceWidth - 1) -or + ($maximumY - $minimumY) -gt ($SourceHeight - 1)) { + throw [ArgumentException]::new( + 'Line geometry span is larger than the source.', 'Annotation') + } + $actualDeltaX = [math]::Max(-$minimumX, [math]::Min(($SourceWidth - 1) - $maximumX, $DeltaX)) + $actualDeltaY = [math]::Max(-$minimumY, [math]::Min(($SourceHeight - 1) - $maximumY, $DeltaY)) + foreach ($point in $points) { + $point.X = [int]$point.X + [int]$actualDeltaX + $point.Y = [int]$point.Y + [int]$actualDeltaY + } + } + 'Points' { + $points = @($geometry.Points) + if ($points.Count -gt 0) { + $minimumX = [int](($points | Measure-Object X -Minimum).Minimum) + $maximumX = [int](($points | Measure-Object X -Maximum).Maximum) + $minimumY = [int](($points | Measure-Object Y -Minimum).Minimum) + $maximumY = [int](($points | Measure-Object Y -Maximum).Maximum) + if (($maximumX - $minimumX) -gt ($SourceWidth - 1) -or + ($maximumY - $minimumY) -gt ($SourceHeight - 1)) { + throw [ArgumentException]::new( + 'Point geometry span is larger than the source.', 'Annotation') + } + $actualDeltaX = [math]::Max(-$minimumX, [math]::Min(($SourceWidth - 1) - $maximumX, $DeltaX)) + $actualDeltaY = [math]::Max(-$minimumY, [math]::Min(($SourceHeight - 1) - $maximumY, $DeltaY)) + foreach ($point in $points) { + $point.X = [int]$point.X + [int]$actualDeltaX + $point.Y = [int]$point.Y + [int]$actualDeltaY + } + } + } + } + $copy +} + +function Resize-SnipAnnotation { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Annotation, + [Parameter(Mandatory)] + [ValidateSet('TopLeft','Top','TopRight','Right','BottomRight','Bottom','BottomLeft','Left','Start','End')] + [string]$Handle, + [Parameter(Mandatory)] [int]$DeltaX, + [Parameter(Mandatory)] [int]$DeltaY, + [Parameter(Mandatory)] [int]$SourceWidth, + [Parameter(Mandatory)] [int]$SourceHeight + ) + + if ($SourceWidth -le 0) { + throw [ArgumentOutOfRangeException]::new('SourceWidth', 'Source width must be positive.') + } + if ($SourceHeight -le 0) { + throw [ArgumentOutOfRangeException]::new('SourceHeight', 'Source height must be positive.') + } + $copy = Copy-SnipAnnotation -Annotation $Annotation + $geometry = $copy.Geometry + $boundsHandles = @('TopLeft','Top','TopRight','Right','BottomRight','Bottom','BottomLeft','Left') + $clampCoordinate = { + param([long]$Value, [long]$Maximum) + [int][math]::Max(0L, [math]::Min($Maximum, $Value)) + }.GetNewClosure() + $normalizePointGeometry = { + param( + [AllowNull()][AllowEmptyCollection()][object[]]$Points, + [Parameter(Mandatory)][string]$Label + ) + + if ($null -eq $Points -or $Points.Count -eq 0) { + throw [ArgumentException]::new( + "$Label geometry must contain points.", 'Annotation') + } + $minimumX = [int]$Points[0].X + $maximumX = $minimumX + $minimumY = [int]$Points[0].Y + $maximumY = $minimumY + for ($index = 1; $index -lt $Points.Count; $index++) { + $pointX = [int]$Points[$index].X + $pointY = [int]$Points[$index].Y + $minimumX = [math]::Min($minimumX, $pointX) + $maximumX = [math]::Max($maximumX, $pointX) + $minimumY = [math]::Min($minimumY, $pointY) + $maximumY = [math]::Max($maximumY, $pointY) + } + $spanX = [long]$maximumX - [long]$minimumX + $spanY = [long]$maximumY - [long]$minimumY + if (($spanX -eq 0 -and $spanY -eq 0) -or + $spanX -gt ($SourceWidth - 1) -or $spanY -gt ($SourceHeight - 1)) { + throw [ArgumentException]::new( + "$Label geometry must have positive extent and fit inside the source.", + 'Annotation') + } + + $shiftX = 0L + if ($minimumX -lt 0) { + $shiftX = -[long]$minimumX + } elseif ($maximumX -gt ($SourceWidth - 1)) { + $shiftX = [long]($SourceWidth - 1) - [long]$maximumX + } + $shiftY = 0L + if ($minimumY -lt 0) { + $shiftY = -[long]$minimumY + } elseif ($maximumY -gt ($SourceHeight - 1)) { + $shiftY = [long]($SourceHeight - 1) - [long]$maximumY + } + foreach ($point in $Points) { + $point.X = [int]([long]$point.X + $shiftX) + $point.Y = [int]([long]$point.Y + $shiftY) + } + + [pscustomobject]@{ + Points = $Points + MinimumX = [int]([long]$minimumX + $shiftX) + MaximumX = [int]([long]$maximumX + $shiftX) + MinimumY = [int]([long]$minimumY + $shiftY) + MaximumY = [int]([long]$maximumY + $shiftY) + } + }.GetNewClosure() + + if ([string]$geometry.Type -in @('Bounds','TextBounds','StepBounds')) { + if ($Handle -notin $boundsHandles) { + throw [ArgumentException]::new("Handle '$Handle' cannot resize $($geometry.Type) geometry.", 'Handle') + } + $width = [int]$geometry.Width + $height = [int]$geometry.Height + if ($width -le 0 -or $height -le 0 -or + $width -gt $SourceWidth -or $height -gt $SourceHeight) { + throw [ArgumentException]::new( + 'Bounds geometry must have positive dimensions no larger than the source.', + 'Annotation') + } + # Shift the intact starting rectangle inside the source before applying + # a handle delta, including axes whose edges remain stationary. + $left = [int][math]::Max(0, [math]::Min($SourceWidth - $width, [int]$geometry.X)) + $top = [int][math]::Max(0, [math]::Min($SourceHeight - $height, [int]$geometry.Y)) + $right = $left + $width + $bottom = $top + $height + $movesLeft = $Handle -in @('TopLeft','Left','BottomLeft') + $movesRight = $Handle -in @('TopRight','Right','BottomRight') + $movesTop = $Handle -in @('TopLeft','Top','TopRight') + $movesBottom = $Handle -in @('BottomLeft','Bottom','BottomRight') + if ($movesLeft) { + $left = & $clampCoordinate ([long]$left + [long]$DeltaX) ([long]$SourceWidth) + } + if ($movesRight) { + $right = & $clampCoordinate ([long]$right + [long]$DeltaX) ([long]$SourceWidth) + } + if ($movesTop) { + $top = & $clampCoordinate ([long]$top + [long]$DeltaY) ([long]$SourceHeight) + } + if ($movesBottom) { + $bottom = & $clampCoordinate ([long]$bottom + [long]$DeltaY) ([long]$SourceHeight) + } + + if ($left -eq $right) { + if ($movesLeft) { + $normalizedLeft = [math]::Max(0, $right - 1) + $normalizedRight = $normalizedLeft + 1 + } else { + $normalizedLeft = [math]::Min($SourceWidth - 1, $left) + $normalizedRight = $normalizedLeft + 1 + } + } else { + $normalizedLeft = [math]::Min($left, $right) + $normalizedRight = [math]::Max($left, $right) + } + if ($top -eq $bottom) { + if ($movesTop) { + $normalizedTop = [math]::Max(0, $bottom - 1) + $normalizedBottom = $normalizedTop + 1 + } else { + $normalizedTop = [math]::Min($SourceHeight - 1, $top) + $normalizedBottom = $normalizedTop + 1 + } + } else { + $normalizedTop = [math]::Min($top, $bottom) + $normalizedBottom = [math]::Max($top, $bottom) + } + $geometry.X = [int]$normalizedLeft + $geometry.Y = [int]$normalizedTop + $geometry.Width = [int][math]::Max(1, $normalizedRight - $normalizedLeft) + $geometry.Height = [int][math]::Max(1, $normalizedBottom - $normalizedTop) + return $copy + } + + if ([string]$geometry.Type -eq 'Line') { + if ($Handle -notin @('Start','End')) { + throw [ArgumentException]::new("Handle '$Handle' cannot resize Line geometry.", 'Handle') + } + [void](& $normalizePointGeometry -Points @($geometry.Start, $geometry.End) -Label Line) + $point = if ($Handle -eq 'Start') { $geometry.Start } else { $geometry.End } + $point.X = & $clampCoordinate ([long]$point.X + [long]$DeltaX) ([long]($SourceWidth - 1)) + $point.Y = & $clampCoordinate ([long]$point.Y + [long]$DeltaY) ([long]($SourceHeight - 1)) + [void](& $normalizePointGeometry -Points @($geometry.Start, $geometry.End) -Label Line) + return $copy + } + + if ([string]$geometry.Type -eq 'Points') { + if ($Handle -notin $boundsHandles) { + throw [ArgumentException]::new("Handle '$Handle' cannot resize Points geometry.", 'Handle') + } + $normalized = & $normalizePointGeometry -Points @($geometry.Points) -Label Points + $points = @($normalized.Points) + $minimumX = [double]$normalized.MinimumX + $maximumX = [double]$normalized.MaximumX + $minimumY = [double]$normalized.MinimumY + $maximumY = [double]$normalized.MaximumY + $movesLeft = $Handle -in @('TopLeft','Left','BottomLeft') + $movesRight = $Handle -in @('TopRight','Right','BottomRight') + $movesTop = $Handle -in @('TopLeft','Top','TopRight') + $movesBottom = $Handle -in @('BottomLeft','Bottom','BottomRight') + + $anchorX = if ($movesLeft) { $maximumX } else { $minimumX } + $movingOriginalX = if ($movesLeft) { $minimumX } else { $maximumX } + $movingNewX = if ($movesLeft -or $movesRight) { + & $clampCoordinate ([long]$movingOriginalX + [long]$DeltaX) ([long]($SourceWidth - 1)) + } else { $movingOriginalX } + $anchorY = if ($movesTop) { $maximumY } else { $minimumY } + $movingOriginalY = if ($movesTop) { $minimumY } else { $maximumY } + $movingNewY = if ($movesTop -or $movesBottom) { + & $clampCoordinate ([long]$movingOriginalY + [long]$DeltaY) ([long]($SourceHeight - 1)) + } else { $movingOriginalY } + + foreach ($point in $points) { + if ($movesLeft -or $movesRight) { + $ratioX = if ($movingOriginalX -eq $anchorX) { 0.0 } else { + ([double]$point.X - $anchorX) / ($movingOriginalX - $anchorX) + } + $scaledX = $anchorX + ($ratioX * ($movingNewX - $anchorX)) + $point.X = [int][math]::Max(0, [math]::Min($SourceWidth - 1, + [math]::Round($scaledX, 0, [MidpointRounding]::AwayFromZero))) + } + if ($movesTop -or $movesBottom) { + $ratioY = if ($movingOriginalY -eq $anchorY) { 0.0 } else { + ([double]$point.Y - $anchorY) / ($movingOriginalY - $anchorY) + } + $scaledY = $anchorY + ($ratioY * ($movingNewY - $anchorY)) + $point.Y = [int][math]::Max(0, [math]::Min($SourceHeight - 1, + [math]::Round($scaledY, 0, [MidpointRounding]::AwayFromZero))) + } + } + [void](& $normalizePointGeometry -Points $points -Label Points) + return $copy + } + + # Unknown future geometry is copied but intentionally not transformed. + $copy +} + +function Get-SnipCropRectangle { + [CmdletBinding()] + param( + [AllowNull()] $Candidate, + [Parameter(Mandatory)] [int]$SourceWidth, + [Parameter(Mandatory)] [int]$SourceHeight, + [ValidateSet('Free','Original','1:1','4:3','16:9')] [string]$Preset = 'Free' + ) + + if ($SourceWidth -le 0) { + throw [ArgumentOutOfRangeException]::new('SourceWidth', 'Source width must be positive.') + } + if ($SourceHeight -le 0) { + throw [ArgumentOutOfRangeException]::new('SourceHeight', 'Source height must be positive.') + } + $toIntegerRectangle = { + param([double]$RectX, [double]$RectY, [double]$RectWidth, [double]$RectHeight) + $integerX = [int][math]::Round($RectX, 0, [MidpointRounding]::AwayFromZero) + $integerY = [int][math]::Round($RectY, 0, [MidpointRounding]::AwayFromZero) + $integerWidth = [int][math]::Round($RectWidth, 0, [MidpointRounding]::AwayFromZero) + $integerHeight = [int][math]::Round($RectHeight, 0, [MidpointRounding]::AwayFromZero) + if ($integerWidth -le 0 -or $integerHeight -le 0 -or + $integerX -ge $SourceWidth -or $integerY -ge $SourceHeight) { + return $null + } + $integerX = [int][math]::Max(0, $integerX) + $integerY = [int][math]::Max(0, $integerY) + $integerWidth = [int][math]::Min($integerWidth, $SourceWidth - $integerX) + $integerHeight = [int][math]::Min($integerHeight, $SourceHeight - $integerY) + if ($integerWidth -le 0 -or $integerHeight -le 0) { return $null } + [pscustomobject][ordered]@{ + X=$integerX; Y=$integerY; Width=$integerWidth; Height=$integerHeight + } + }.GetNewClosure() + + $usedFullSource = $null -eq $Candidate + if ($usedFullSource) { + $left = 0 + $top = 0 + $right = $SourceWidth + $bottom = $SourceHeight + } else { + $candidateX = [double](Get-SnipRecordValue -InputObject $Candidate -Name X -Required) + $candidateY = [double](Get-SnipRecordValue -InputObject $Candidate -Name Y -Required) + $candidateWidth = [double](Get-SnipRecordValue -InputObject $Candidate -Name Width -Required) + $candidateHeight = [double](Get-SnipRecordValue -InputObject $Candidate -Name Height -Required) + if ($candidateWidth -eq 0 -or $candidateHeight -eq 0) { return $null } + $candidateRight = $candidateX + $candidateWidth + $candidateBottom = $candidateY + $candidateHeight + # Keep the clamp in floating-point space. Mixing integer and double + # arguments makes PowerShell bind Math.Min/Max to an integer overload, + # which truncates midpoint coordinates before the explicit rounding step. + $left = [math]::Max(0.0, [math]::Min($candidateX, $candidateRight)) + $top = [math]::Max(0.0, [math]::Min($candidateY, $candidateBottom)) + $right = [math]::Min([double]$SourceWidth, [math]::Max($candidateX, $candidateRight)) + $bottom = [math]::Min([double]$SourceHeight, [math]::Max($candidateY, $candidateBottom)) + } + $width = [double]($right - $left) + $height = [double]($bottom - $top) + if ($width -le 0 -or $height -le 0) { return $null } + + if ($Preset -eq 'Free') { + return & $toIntegerRectangle $left $top $width $height + } + + $isPortrait = if ($usedFullSource) { + $SourceHeight -gt $SourceWidth + } else { + $height -gt $width + } + $targetAspect = switch ($Preset) { + 'Original' { [double]$SourceWidth / [double]$SourceHeight } + '1:1' { 1.0 } + '4:3' { if ($isPortrait) { 3.0 / 4.0 } else { 4.0 / 3.0 } } + '16:9' { if ($isPortrait) { 9.0 / 16.0 } else { 16.0 / 9.0 } } + } + $currentAspect = [double]$width / [double]$height + if ($currentAspect -gt $targetAspect) { + $targetHeight = $height + $targetWidth = [int][math]::Round($targetHeight * $targetAspect, 0, + [MidpointRounding]::AwayFromZero) + } else { + $targetWidth = $width + $targetHeight = [int][math]::Round($targetWidth / $targetAspect, 0, + [MidpointRounding]::AwayFromZero) + } + $targetWidth = [int][math]::Max(1, [math]::Min($width, $targetWidth)) + $targetHeight = [int][math]::Max(1, [math]::Min($height, $targetHeight)) + $offsetX = [int][math]::Round(($width - $targetWidth) / 2.0, 0, + [MidpointRounding]::AwayFromZero) + $offsetY = [int][math]::Round(($height - $targetHeight) / 2.0, 0, + [MidpointRounding]::AwayFromZero) + & $toIntegerRectangle ($left + $offsetX) ($top + $offsetY) $targetWidth $targetHeight +} + +function Set-SnipCrop { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [ValidateSet('Apply','Reset')] [string]$Action, + [AllowNull()] $Candidate, + [Parameter(Mandatory)] [int]$SourceWidth, + [Parameter(Mandatory)] [int]$SourceHeight, + [ValidateSet('Free','Original','1:1','4:3','16:9')] [string]$Preset = 'Free' + ) + + if ($Action -eq 'Reset') { return $null } + Get-SnipCropRectangle -Candidate $Candidate -SourceWidth $SourceWidth ` + -SourceHeight $SourceHeight -Preset $Preset +} + +function Get-TrimmedRecent { + # Keep only the top N items (for capping unbounded undo/redo stacks). + # $Items is expected in most-recent-first order, matching [Stack].ToArray(). + param( + [AllowNull()][AllowEmptyCollection()] $Items, + [int]$MaxDepth = 100 + ) + if ($null -eq $Items) { return @() } + $arr = @($Items) + if ($arr.Count -le $MaxDepth) { return $arr } + return $arr[0..($MaxDepth - 1)] +} + +function Test-IsSelfWindowHandle { + # True when $Hwnd is one of SnipIT's own registered window handles. + # Used by the window-capture path to avoid snapshotting our own UI + # when the foreground window is SnipIT (tray balloon, widget, preview). + param( + [AllowNull()] $Hwnd, + [AllowNull()][AllowEmptyCollection()] $SelfWindowHandles + ) + if ($null -eq $Hwnd) { return $false } + if ($Hwnd -is [IntPtr] -and $Hwnd -eq [IntPtr]::Zero) { return $false } + if ($null -eq $SelfWindowHandles) { return $false } + foreach ($h in @($SelfWindowHandles)) { + if ($null -eq $h) { continue } + if ($h -is [IntPtr] -and $h -eq [IntPtr]::Zero) { continue } + if ($h -eq $Hwnd) { return $true } + } + return $false +} + +function Resolve-WindowCaptureTarget { + # Pure decision layer for active-window capture. Returns the HWND we + # should capture, or $null to signal "skip this target, caller should + # fall back (typically to the full virtual desktop)". + # + # - No foreground window ([IntPtr]::Zero) => $null + # - Foreground belongs to SnipIT => $null (self-capture guard) + # - Anything else => the HWND unchanged + param( + [AllowNull()] $ForegroundHwnd, + [AllowNull()][AllowEmptyCollection()] $SelfWindowHandles + ) + if ($null -eq $ForegroundHwnd) { return $null } + if ($ForegroundHwnd -is [IntPtr] -and $ForegroundHwnd -eq [IntPtr]::Zero) { return $null } + if (Test-IsSelfWindowHandle -Hwnd $ForegroundHwnd -SelfWindowHandles $SelfWindowHandles) { + return $null + } + return $ForegroundHwnd +} + +function Invoke-CaptureLoop { + # Pure orchestration for the capture/preview/"New snip" loop. + # + # Contract (important — this encodes the RAN-14 invariant): + # The preview window takes ownership of the capture it receives and + # disposes it on close. The loop therefore MUST call $CaptureFactory + # on every iteration to produce a fresh capture. A disposed capture + # is never passed back into $PreviewHandler. + # + # Parameters: + # CaptureFactory scriptblock () -> capture handle (or $null to abort the loop) + # PreviewHandler scriptblock ($capture) -> $true to loop again, $false to exit + # MaxIterations safety cap in case PreviewHandler always returns $true + # + # Returns the number of preview iterations actually run. + param( + [Parameter(Mandatory)] [scriptblock]$CaptureFactory, + [Parameter(Mandatory)] [scriptblock]$PreviewHandler, + [int]$MaxIterations = 32 + ) + $iterations = 0 + while ($iterations -lt $MaxIterations) { + $capture = & $CaptureFactory + if ($null -eq $capture) { break } + $iterations++ + $again = & $PreviewHandler $capture + if (-not $again) { break } + } + 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 Get-SnipMonitorLayouts { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object[]]$MonitorDescriptors + ) + + if ($null -eq $MonitorDescriptors -or $MonitorDescriptors.Count -eq 0) { + throw [ArgumentException]::new( + 'At least one monitor descriptor is required.', 'MonitorDescriptors') + } + + $layouts = [System.Collections.Generic.List[object]]::new() + for ($index = 0; $index -lt $MonitorDescriptors.Count; $index++) { + $descriptor = $MonitorDescriptors[$index] + if ($null -eq $descriptor) { + throw [ArgumentException]::new( + "Monitor descriptor at index $index is null.", 'MonitorDescriptors') + } + foreach ($propertyName in 'Id','X','Y','Width','Height','DpiX','DpiY') { + if ($null -eq $descriptor.PSObject.Properties[$propertyName]) { + throw [ArgumentException]::new( + "Monitor descriptor at index $index is missing '$propertyName'.", + 'MonitorDescriptors') + } + } + + try { + $id = [string]$descriptor.Id + $physicalX = [double]$descriptor.X + $physicalY = [double]$descriptor.Y + $physicalWidth = [double]$descriptor.Width + $physicalHeight = [double]$descriptor.Height + $dpiX = [double]$descriptor.DpiX + $dpiY = [double]$descriptor.DpiY + } catch { + throw [ArgumentException]::new( + "Monitor descriptor at index $index contains a non-numeric coordinate, size, or DPI.", + 'MonitorDescriptors', $_.Exception) + } + + if ([string]::IsNullOrWhiteSpace($id)) { + throw [ArgumentException]::new( + "Monitor descriptor at index $index has an empty Id.", 'MonitorDescriptors') + } + if (-not [double]::IsFinite($physicalX)) { + throw [ArgumentOutOfRangeException]::new('X', 'X must be finite.') + } + if (-not [double]::IsFinite($physicalY)) { + throw [ArgumentOutOfRangeException]::new('Y', 'Y must be finite.') + } + if (-not [double]::IsFinite($physicalWidth) -or $physicalWidth -le 0) { + throw [ArgumentOutOfRangeException]::new('Width', 'Width must be greater than zero.') + } + if (-not [double]::IsFinite($physicalHeight) -or $physicalHeight -le 0) { + throw [ArgumentOutOfRangeException]::new('Height', 'Height must be greater than zero.') + } + if (-not [double]::IsFinite($dpiX) -or $dpiX -le 0) { + throw [ArgumentOutOfRangeException]::new('DpiX', 'DpiX must be greater than zero.') + } + if (-not [double]::IsFinite($dpiY) -or $dpiY -le 0) { + throw [ArgumentOutOfRangeException]::new('DpiY', 'DpiY must be greater than zero.') + } + + $physicalX = [int][math]::Round( + $physicalX, 0, [MidpointRounding]::AwayFromZero) + $physicalY = [int][math]::Round( + $physicalY, 0, [MidpointRounding]::AwayFromZero) + $physicalWidth = [int][math]::Round( + $physicalWidth, 0, [MidpointRounding]::AwayFromZero) + $physicalHeight = [int][math]::Round( + $physicalHeight, 0, [MidpointRounding]::AwayFromZero) + if ($physicalWidth -le 0) { + throw [ArgumentOutOfRangeException]::new( + 'Width', 'Width must normalize to at least one physical pixel.') + } + if ($physicalHeight -le 0) { + throw [ArgumentOutOfRangeException]::new( + 'Height', 'Height must normalize to at least one physical pixel.') + } + + $scaleX = $dpiX / 96.0 + $scaleY = $dpiY / 96.0 + $isPrimaryProperty = $descriptor.PSObject.Properties['IsPrimary'] + $layouts.Add([pscustomobject][ordered]@{ + Id = $id + Index = $index + IsPrimary = if ($null -ne $isPrimaryProperty) { + [bool]$isPrimaryProperty.Value + } else { + $false + } + PhysicalX = $physicalX + PhysicalY = $physicalY + PhysicalWidth = $physicalWidth + PhysicalHeight = $physicalHeight + PhysicalRight = $physicalX + $physicalWidth + PhysicalBottom = $physicalY + $physicalHeight + DpiX = $dpiX + DpiY = $dpiY + ScaleX = $scaleX + ScaleY = $scaleY + DipX = $physicalX / $scaleX + DipY = $physicalY / $scaleY + DipWidth = $physicalWidth / $scaleX + DipHeight = $physicalHeight / $scaleY + Descriptor = $descriptor + }) + } + + $layouts +} + +function Get-SnipOverlayIntersections { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Rectangle, + [Parameter(Mandatory)] [object[]]$MonitorLayouts + ) + + if ($null -eq $Rectangle) { + throw [ArgumentNullException]::new('Rectangle') + } + foreach ($propertyName in 'X','Y','Width','Height') { + if ($null -eq $Rectangle.PSObject.Properties[$propertyName]) { + throw [ArgumentException]::new( + "Selection rectangle is missing '$propertyName'.", 'Rectangle') + } + } + try { + $rectangleX = [double]$Rectangle.X + $rectangleY = [double]$Rectangle.Y + $rectangleWidth = [double]$Rectangle.Width + $rectangleHeight = [double]$Rectangle.Height + } catch { + throw [ArgumentException]::new( + 'Selection rectangle contains a non-numeric coordinate or size.', + 'Rectangle', $_.Exception) + } + if (-not [double]::IsFinite($rectangleX) -or + -not [double]::IsFinite($rectangleY) -or + -not [double]::IsFinite($rectangleWidth) -or + -not [double]::IsFinite($rectangleHeight)) { + throw [ArgumentException]::new( + 'Selection rectangle coordinates and size must be finite.', 'Rectangle') + } + if ($rectangleWidth -le 0 -or $rectangleHeight -le 0) { return } + + $rectangleRight = $rectangleX + $rectangleWidth + $rectangleBottom = $rectangleY + $rectangleHeight + foreach ($layout in @($MonitorLayouts)) { + if ($null -eq $layout) { continue } + foreach ($propertyName in 'Id','Index','PhysicalX','PhysicalY', + 'PhysicalWidth','PhysicalHeight','ScaleX','ScaleY') { + if ($null -eq $layout.PSObject.Properties[$propertyName]) { + throw [ArgumentException]::new( + "Monitor layout is missing '$propertyName'.", 'MonitorLayouts') + } + } + + $left = [math]::Max($rectangleX, [double]$layout.PhysicalX) + $top = [math]::Max($rectangleY, [double]$layout.PhysicalY) + $right = [math]::Min( + $rectangleRight, + [double]$layout.PhysicalX + [double]$layout.PhysicalWidth) + $bottom = [math]::Min( + $rectangleBottom, + [double]$layout.PhysicalY + [double]$layout.PhysicalHeight) + if ($right -le $left -or $bottom -le $top) { continue } + + $localPhysicalX = $left - [double]$layout.PhysicalX + $localPhysicalY = $top - [double]$layout.PhysicalY + $physicalWidth = $right - $left + $physicalHeight = $bottom - $top + [pscustomobject][ordered]@{ + MonitorId = [string]$layout.Id + MonitorIndex = [int]$layout.Index + PhysicalX = $left + PhysicalY = $top + PhysicalWidth = $physicalWidth + PhysicalHeight = $physicalHeight + LocalPhysicalX = $localPhysicalX + LocalPhysicalY = $localPhysicalY + DipX = $localPhysicalX / [double]$layout.ScaleX + DipY = $localPhysicalY / [double]$layout.ScaleY + DipWidth = $physicalWidth / [double]$layout.ScaleX + DipHeight = $physicalHeight / [double]$layout.ScaleY + } + } +} + +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 @($Modifiers).Count -eq 0 -and + $Key -in 'Space', 'Enter') { + return 'ActivateFocusedButton' + } + if ($null -ne $EditorState.Draft -and $Key -eq 'Escape') { return 'CancelDraft' } + if ($null -ne $EditorState.Draft -and $hasCtrl -and $Key -eq 'Z') { return $null } + if ($null -ne $EditorState.SelectionId -and $Key -eq 'Escape') { + return 'ClearSelection' + } + + # Annotation movement/deletion belongs exclusively to the canvas focus + # scope. Selection Escape is global after popup/editor/draft precedence so + # it cannot fall through to tool deactivation or window close. + if ($FocusedRole -eq 'Canvas' -and $null -ne $EditorState.SelectionId) { + 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' } + 'z' { if ($hasShift) { return 'Redo' } else { return 'Undo' } } + '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'." + ) +} + +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 + $dispatch = [pscustomobject]@{ Invoked = $false } + $work = { + $dispatch.Invoked = $true + # 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() + try { + $postOutput = @(& $Coordinator.Post $work) + $postValue = if ($postOutput.Count -gt 0) { $postOutput[-1] } else { $null } + if (-not $dispatch.Invoked -and + $postValue -is [bool] -and -not $postValue) { + throw [InvalidOperationException]::new('Capture pump dispatch was declined.') + } + } catch { + $Coordinator.LastError = $_ + if (-not $dispatch.Invoked) { + $Coordinator.PumpScheduled = $false + $Coordinator.PumpRescheduleRequested = $false + $Coordinator.Phase = if ($Coordinator.ShutdownRequested) { + 'ShuttingDown' + } else { + 'Recovering' + } + } + } +} + +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 ($Coordinator.Phase -eq 'Recovering') { + # A transient Post failure leaves scheduling flags clear. Retry once + # when new external work arrives; persistent failure waits for the next + # request instead of spinning, and PumpScheduled preserves latest-only. + Send-SnipCapturePump -Coordinator $Coordinator + return $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 Set-SnipAuxiliarySurface { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Coordinator, + [Parameter(Mandatory)] $Surface + ) + + $canPublish = -not $Coordinator.ShutdownRequested -and + $Coordinator.Phase -eq 'Idle' -and + $null -eq $Coordinator.ActiveSurface -and + $null -eq $Coordinator.PreviewWindow + if (-not $canPublish) { + $closeResult = if ($Coordinator.ShutdownRequested -or + $Coordinator.Phase -eq 'ShuttingDown') { 'Shutdown' } else { 'Preempted' } + try { + $closeProperty = $Surface.PSObject.Properties['Close'] + if ($null -ne $closeProperty -and $closeProperty.Value -is [scriptblock]) { + & $closeProperty.Value $closeResult + } elseif ($null -ne $Surface.PSObject.Methods['Close']) { + $requestedProperty = $Surface.PSObject.Properties['RequestedResult'] + if ($null -ne $requestedProperty) { $requestedProperty.Value = $closeResult } + $Surface.Close() + } + } catch { + $Coordinator.LastError = $_ + } + return $false + } + + $Coordinator.Phase = 'Auxiliary' + $Coordinator.ActiveSurface = $Surface + $Coordinator.SurfaceCloseRequested = $false + $true +} + +function Complete-SnipAuxiliarySurface { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Coordinator, + [Parameter(Mandatory)] $Surface, + [Parameter(Mandatory)] + [ValidateSet('Completed', 'UserCancelled', 'Preempted', 'Failed', 'Shutdown')] + [string]$Result + ) + + if ($Coordinator.Phase -ne 'Auxiliary' -or + $null -eq $Coordinator.ActiveSurface -or + -not [object]::ReferenceEquals($Coordinator.ActiveSurface, $Surface)) { + return $false + } + + Complete-SnipSurface -Coordinator $Coordinator -Result $Result -Operation Preview + $true +} + +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', 'CopyKeepOpen', 'Save')] + [string]$Operation = 'Preview' + ) + + if ($Result -eq 'Shutdown') { + $Coordinator.LastResult = $Result + Stop-SnipCaptureCoordinator -Coordinator $Coordinator + return + } + if ($Coordinator.ShutdownRequested -or $Coordinator.Phase -eq 'ShuttingDown') { + return + } + $Coordinator.LastResult = $Result + + # 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 -in @('CopyKeepOpen', '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', 'CopyKeepOpen', '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' } + } + $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) + $handoff = [pscustomobject]@{ + Coordinator = $coordinatorForPreview + Accept = $accept + } + $acceptAndRelinquish = { + # Preview calls this only after its Closed cleanup is installed. + # Relinquish synchronously so shutdown cannot dispose the same + # bitmap while the modal Preview is still open. + $handoff.Coordinator.OwnedBitmap = $null + & $handoff.Accept + }.GetNewClosure() + & $previewService $bitmap $acceptAndRelinquish ` + $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 + } +} + +if ($CoreOnly) { return } diff --git a/src/10-Bootstrap.ps1 b/src/10-Bootstrap.ps1 new file mode 100644 index 0000000..634d730 --- /dev/null +++ b/src/10-Bootstrap.ps1 @@ -0,0 +1,693 @@ +#requires -Version 7.5 +# Generated deterministically from the reviewed SnipIT.ps1 AST by scripts/Export-SnipITModules.ps1. + +$script:UndoStackMaxDepth = 100 + +$script:DiagRingSize = 200 + +$script:DiagRing = New-Object System.Collections.Generic.Queue[string] + +$script:SnipITAppVersion = '0.1.1' + +$script:UtilityContext = $null + +$script:SingleInstanceMutex = $null + +$script:SnipBootstrapInitializer = { + + param([ValidateSet('Bootstrap', 'Settings')][string]$Phase) + + if ($Phase -eq 'Bootstrap') { + +if ([System.Threading.Thread]::CurrentThread.GetApartmentState() -ne 'STA') { + $pwsh = (Get-Process -Id $PID).Path + Start-Process -FilePath $pwsh ` + -ArgumentList @('-Sta','-NoProfile','-WindowStyle','Hidden','-File',$script:SnipEntryPath) ` + -WindowStyle Hidden + return +} + +if (-not ('ConsoleHider' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class ConsoleHider { + [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); +} +'@ +} + +$h = [ConsoleHider]::GetConsoleWindow() + +if ($h -ne [IntPtr]::Zero) { + [ConsoleHider]::ShowWindow($h, 0) | Out-Null + # Track even though hidden — a future ShowWindow we don't control could + # bring it back, and the capture-target guard wants it in the self set. + $script:ConsoleHwnd = $h +} + +Add-Type -AssemblyName PresentationFramework + +Add-Type -AssemblyName PresentationCore + +Add-Type -AssemblyName WindowsBase + +Add-Type -AssemblyName System.Windows.Forms + +Add-Type -AssemblyName System.Drawing + +if (-not $env:SNIPIT_TEST_MODE) { + $script:SingleInstanceCreated = $false + $script:SingleInstanceMutex = New-Object System.Threading.Mutex( + $true, 'Local\SnipIT-SingleInstance-v1', [ref]$script:SingleInstanceCreated) + if (-not $script:SingleInstanceCreated) { + try { + [System.Windows.Forms.MessageBox]::Show( + '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 {} + return + } +} + +$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 +} + +try { [System.Windows.Application]::Current.ThemeMode = 'System' } catch {} + + return $true + + } + +$script:Settings = Get-SnipDefaultSettings + +$script:SnipFreshInstall = $false + +if (-not $env:SNIPIT_TEST_MODE) { + $script:Settings = Read-SnipSettings -Path $script:SettingsPath + Save-SnipSettings -Settings $script:Settings -Path $script:SettingsPath + $script:SnipFreshInstall = 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 +} + + $true + +} + +function Get-SnipXamlText { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Name + ) + + if ($null -ne $script:SnipEmbeddedXaml -and + $script:SnipEmbeddedXaml.Contains($Name)) { + return [string]$script:SnipEmbeddedXaml[$Name] + } + + $resolver = Get-Variable -Name SnipDevelopmentXamlResolver -Scope Script ` + -ValueOnly -ErrorAction Ignore + if ($resolver -is [scriptblock]) { + return & $resolver $Name + } + + throw ("Embedded XAML '$Name' is unavailable and no development XAML " + + 'resolver is configured.') +} + +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 { + [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 -ErrorAction Stop | Out-Null + } + Add-Content -LiteralPath $Path -Value $line -Encoding utf8 -ErrorAction Stop + } 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 -ErrorAction Stop)) { + Write-SnipDiag -Message "Settings file not found; using defaults: $Path" -Path $diagPath + return $defaults + } + + try { + $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 + } + + 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 -ErrorAction Stop | Out-Null + } + $temporaryPath = Join-Path $directory ('.{0}.{1}.tmp' -f ([IO.Path]::GetFileName($Path)), [guid]::NewGuid()) + try { + $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 -ErrorAction Stop) { + Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction Stop + } + } +} + +function New-SnipITIcon { + param([Parameter(Mandatory)] [string]$Path) + # Draw at 256x256 so the icon is sharp at every shortcut size. + $size = 256 + $bmp = New-Object System.Drawing.Bitmap $size, $size + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $g.Clear([System.Drawing.Color]::Transparent) + # Rounded square background in system accent + $bg = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 0, 120, 212)) + $rect = New-Object System.Drawing.Rectangle 24, 24, 208, 208 + $gp = New-Object System.Drawing.Drawing2D.GraphicsPath + $r = 36 + $gp.AddArc($rect.X, $rect.Y, $r, $r, 180, 90) + $gp.AddArc($rect.Right - $r, $rect.Y, $r, $r, 270, 90) + $gp.AddArc($rect.Right - $r, $rect.Bottom - $r, $r, $r, 0, 90) + $gp.AddArc($rect.X, $rect.Bottom - $r, $r, $r, 90, 90) + $gp.CloseFigure() + $g.FillPath($bg, $gp) + $bg.Dispose(); $gp.Dispose() + # White selection-corner brackets + $pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::White), 18 + $pen.StartCap = 'Round'; $pen.EndCap = 'Round' + $g.DrawLine($pen, 70, 70, 70, 110); $g.DrawLine($pen, 70, 70, 110, 70) + $g.DrawLine($pen, 186, 70, 146, 70); $g.DrawLine($pen, 186, 70, 186, 110) + $g.DrawLine($pen, 70, 186, 70, 146); $g.DrawLine($pen, 70, 186, 110, 186) + $g.DrawLine($pen, 186, 186, 186, 146); $g.DrawLine($pen, 186, 186, 146, 186) + $pen.Dispose(); $g.Dispose() + + # Encode bitmap as PNG, then wrap in a real PNG-embedded .ICO container. + $ms = New-Object System.IO.MemoryStream + $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) + $png = $ms.ToArray() + $ms.Dispose(); $bmp.Dispose() + + $fs = [System.IO.File]::Open($Path, 'Create') + $bw = New-Object System.IO.BinaryWriter $fs + try { + # ICONDIR (6 bytes) + $bw.Write([uint16]0) # reserved + $bw.Write([uint16]1) # type = icon + $bw.Write([uint16]1) # count + # ICONDIRENTRY (16 bytes) + $bw.Write([byte]0) # width (0 = 256) + $bw.Write([byte]0) # height (0 = 256) + $bw.Write([byte]0) # color count + $bw.Write([byte]0) # reserved + $bw.Write([uint16]1) # planes + $bw.Write([uint16]32) # bit count + $bw.Write([uint32]$png.Length) # bytes in resource + $bw.Write([uint32]22) # offset = 6 + 16 + # PNG payload + $bw.Write($png) + $bw.Flush() + } finally { + $bw.Close(); $fs.Close() + } + return $Path +} + +function Get-SnipITIconPath { + $p = Join-Path $script:AppHomeDir 'SnipIT.ico' + # Always regenerate so upgrades pick up icon changes + New-SnipITIcon -Path $p | Out-Null + return $p +} + +function Write-SnipITShortcuts { + [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 + ) + + $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 { + 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 the per-user app directory unless it is + # already the executing copy. + $runningFull = [System.IO.Path]::GetFullPath($script:SnipInstallSourcePath) + $targetFull = [System.IO.Path]::GetFullPath($target) + if ($runningFull -ne $targetFull) { + Copy-Item -LiteralPath $script:SnipInstallSourcePath -Destination $target -Force + } + + $iconPath = Get-SnipITIconPath + Write-SnipITShortcuts -Paths $Paths -IconPath $iconPath + + if ($fresh) { Set-Content -LiteralPath $marker -Value (Get-Date -Format o) } + return $fresh +} + +function Uninstall-SnipIT { + Remove-Item -Force -ErrorAction SilentlyContinue ` + $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 + } +} + +function New-SnipThemeResources { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [bool]$HighContrast, + [Nullable[bool]]$AnimationsEnabled = $null + ) + + $tokens = Get-SnipThemeTokens + $resources = [System.Windows.ResourceDictionary]::new() + $newBrush = { + param([Parameter(Mandatory)] [string]$Color) + $converted = [System.Windows.Media.ColorConverter]::ConvertFromString($Color) + $brush = [System.Windows.Media.SolidColorBrush]::new($converted) + $brush.Freeze() + $brush + } + $motionEnabled = if ($null -eq $AnimationsEnabled) { + [bool][System.Windows.SystemParameters]::ClientAreaAnimation + } else { + [bool]$AnimationsEnabled + } + + $resources['SnipIslandRadius'] = [double]$tokens.IslandRadius + $resources['SnipControlRadius'] = [double]$tokens.ControlRadius + $resources['SnipCornerHeight'] = [double]$tokens.CornerIslandHeight + $resources['SnipToolTarget'] = [double]$tokens.ToolTarget + $resources['SnipDockHeight'] = [double]$tokens.MainDockHeight + $resources['SnipPropertyHeight'] = [double]$tokens.PropertyIslandHeight + $resources['SnipHairlineThickness'] = [double]1 + $resources['SnipFocusThickness'] = [double]2 + $resources['SnipHighContrast'] = [bool]$HighContrast + $resources['SnipAnimationsEnabled'] = [bool]$motionEnabled + $resources['SnipPopupAnimation'] = if ($motionEnabled) { + [System.Windows.Controls.Primitives.PopupAnimation]::Fade + } else { + [System.Windows.Controls.Primitives.PopupAnimation]::None + } + $resources['SnipMotionDuration'] = if ($motionEnabled) { + [timespan]::FromMilliseconds(140) + } else { + [timespan]::Zero + } + $resources['SnipDisplayFont'] = [System.Windows.Media.FontFamily]::new('Segoe UI Variable Display') + $resources['SnipTextFont'] = [System.Windows.Media.FontFamily]::new('Segoe UI Variable Text') + $resources['SnipCodeFont'] = [System.Windows.Media.FontFamily]::new('Cascadia Code') + + if ($HighContrast) { + $resources['SnipPageBrush'] = [System.Windows.SystemColors]::WindowBrush + $resources['SnipCanvasBrush'] = [System.Windows.SystemColors]::WindowBrush + $resources['SnipGlassScrimBrush'] = [System.Windows.SystemColors]::WindowBrush + $resources['SnipGlassGradientBrush'] = [System.Windows.SystemColors]::WindowBrush + $resources['SnipPrimaryTextBrush'] = [System.Windows.SystemColors]::WindowTextBrush + $resources['SnipSecondaryTextBrush'] = [System.Windows.SystemColors]::WindowTextBrush + $resources['SnipMutedTextBrush'] = [System.Windows.SystemColors]::GrayTextBrush + $resources['SnipHairlineBrush'] = [System.Windows.SystemColors]::WindowTextBrush + $resources['SnipInnerHighlightBrush'] = [System.Windows.SystemColors]::WindowTextBrush + $resources['SnipHoverBrush'] = [System.Windows.SystemColors]::ControlBrush + $resources['SnipAccentBrush'] = [System.Windows.SystemColors]::HighlightBrush + $resources['SnipAccentTintBrush'] = [System.Windows.SystemColors]::HighlightBrush + $resources['SnipAccentTextBrush'] = [System.Windows.SystemColors]::HighlightTextBrush + $resources['SnipAccentInkBrush'] = [System.Windows.SystemColors]::HighlightTextBrush + $resources['SnipFocusBrush'] = [System.Windows.SystemColors]::WindowTextBrush + $resources['SnipCoralBrush'] = [System.Windows.SystemColors]::HighlightBrush + $resources['SnipCoralTintBrush'] = [System.Windows.SystemColors]::HighlightBrush + $resources['SnipMintBrush'] = [System.Windows.SystemColors]::HighlightBrush + $resources['SnipAmberBrush'] = [System.Windows.SystemColors]::HighlightBrush + $resources['SnipVioletBrush'] = [System.Windows.SystemColors]::HighlightBrush + $resources['SnipGlassLayers'] = [string[]]@('SystemBackground', 'SystemBorder') + } else { + $resources['SnipPageBrush'] = & $newBrush $tokens.PageBlack + $resources['SnipCanvasBrush'] = & $newBrush $tokens.CanvasBlack + $resources['SnipGlassScrimBrush'] = & $newBrush $tokens.GlassScrim + + $glass = [System.Windows.Media.LinearGradientBrush]::new() + $glass.StartPoint = [System.Windows.Point]::new(0, 0) + $glass.EndPoint = [System.Windows.Point]::new(0, 1) + $glass.GradientStops.Add([System.Windows.Media.GradientStop]::new( + [System.Windows.Media.ColorConverter]::ConvertFromString($tokens.GlassTop), 0)) + $glass.GradientStops.Add([System.Windows.Media.GradientStop]::new( + [System.Windows.Media.ColorConverter]::ConvertFromString($tokens.GlassBottom), 1)) + $glass.Freeze() + $resources['SnipGlassGradientBrush'] = $glass + + $resources['SnipPrimaryTextBrush'] = & $newBrush $tokens.PrimaryText + $resources['SnipSecondaryTextBrush'] = & $newBrush $tokens.SecondaryText + $resources['SnipMutedTextBrush'] = & $newBrush $tokens.MutedText + $resources['SnipHairlineBrush'] = & $newBrush $tokens.Hairline + $resources['SnipInnerHighlightBrush'] = & $newBrush $tokens.Hairline + $resources['SnipHoverBrush'] = & $newBrush $tokens.HoverFill + $resources['SnipAccentBrush'] = & $newBrush $tokens.Accent + $accentTintColor = [System.Windows.Media.ColorConverter]::ConvertFromString($tokens.Accent) + $accentTintColor.A = 0x2E + $accentTintBrush = [System.Windows.Media.SolidColorBrush]::new($accentTintColor) + $accentTintBrush.Freeze() + $resources['SnipAccentTintBrush'] = $accentTintBrush + $resources['SnipAccentTextBrush'] = & $newBrush $tokens.AccentText + $resources['SnipAccentInkBrush'] = & $newBrush $tokens.PageBlack + $resources['SnipFocusBrush'] = $resources['SnipAccentBrush'] + $resources['SnipCoralBrush'] = & $newBrush $tokens.Coral + $coralTintColor = [System.Windows.Media.ColorConverter]::ConvertFromString($tokens.Coral) + $coralTintColor.A = 0x2E + $coralTintBrush = [System.Windows.Media.SolidColorBrush]::new($coralTintColor) + $coralTintBrush.Freeze() + $resources['SnipCoralTintBrush'] = $coralTintBrush + $resources['SnipMintBrush'] = & $newBrush $tokens.Mint + $resources['SnipAmberBrush'] = & $newBrush $tokens.Amber + $resources['SnipVioletBrush'] = & $newBrush $tokens.Violet + + $shadow = [System.Windows.Media.Effects.DropShadowEffect]::new() + $shadow.Color = [System.Windows.Media.Colors]::Black + $shadow.BlurRadius = 18 + $shadow.ShadowDepth = 5 + $shadow.Direction = 270 + $shadow.Opacity = 0.58 + $shadow.Freeze() + $resources['SnipIslandShadow'] = $shadow + $resources['SnipGlassLayers'] = [string[]]@( + 'ContrastScrim', 'GlassGradient', 'Hairline', 'InnerHighlight', 'Shadow') + } + + $styleXaml = Get-SnipXamlText -Name 'ThemeResources' + $styleReader = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($styleXaml)) + try { + $styles = [System.Windows.Markup.XamlReader]::Load($styleReader) + } finally { + $styleReader.Dispose() + } + $resources.MergedDictionaries.Add($styles) + $resources +} + +function Add-SnipThemeResources { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Windows.FrameworkElement]$Root, + [Parameter(Mandatory)] [System.Windows.ResourceDictionary]$Resources + ) + + if (-not $Root.Resources.MergedDictionaries.Contains($Resources)) { + $Root.Resources.MergedDictionaries.Add($Resources) + } + $Root +} + +function Connect-SnipWindowLifecycle { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Windows.Window]$Window, + [scriptblock]$Register = { param($hwnd) Register-SelfWindowHandle -Hwnd $hwnd }, + [scriptblock]$Unregister = { param($hwnd) Unregister-SelfWindowHandle -Hwnd $hwnd } + ) + + $helper = [System.Windows.Interop.WindowInteropHelper]::new($Window) + $state = [pscustomobject]@{ + Window = $Window + Handle = [IntPtr]::Zero + Connected = $false + SourceInitializedHandler = $null + ClosedHandler = $null + } + $registerWindow = $Register + $unregisterWindow = $Unregister + $sourceInitialized = [EventHandler]{ + param($sender, $eventArgs) + $handle = $helper.Handle + if ($handle -eq [IntPtr]::Zero -or $state.Connected) { return } + & $registerWindow $handle + $state.Handle = $handle + $state.Connected = $true + }.GetNewClosure() + $closed = [EventHandler]{ + param($sender, $eventArgs) + try { + if ($state.Connected -and $state.Handle -ne [IntPtr]::Zero) { + & $unregisterWindow $state.Handle + } + } finally { + $state.Connected = $false + $Window.Remove_SourceInitialized($sourceInitialized) + $Window.Remove_Closed($state.ClosedHandler) + } + }.GetNewClosure() + $state.SourceInitializedHandler = $sourceInitialized + $state.ClosedHandler = $closed + $Window.Add_SourceInitialized($sourceInitialized) + $Window.Add_Closed($closed) + $state +} + +function Convert-BitmapToBitmapSource { + param([System.Drawing.Bitmap]$Bitmap) + # DeleteObject lives on the main [Native] class defined at startup — no per-call JIT. + $hbmp = [IntPtr]::Zero + try { + $hbmp = $Bitmap.GetHbitmap() + $src = [System.Windows.Interop.Imaging]::CreateBitmapSourceFromHBitmap( + $hbmp, [IntPtr]::Zero, + [System.Windows.Int32Rect]::Empty, + [System.Windows.Media.Imaging.BitmapSizeOptions]::FromEmptyOptions()) + $src.Freeze() + return $src + } finally { + if ($hbmp -ne [IntPtr]::Zero) { [Native]::DeleteObject($hbmp) | Out-Null } + } +} + +function Save-CaptureToFile { + param([System.Drawing.Bitmap]$Bitmap) + $defaultDir = Join-Path ([Environment]::GetFolderPath('MyPictures')) 'Snips' + New-Item -ItemType Directory -Force -Path $defaultDir | Out-Null + $dlg = New-Object Microsoft.Win32.SaveFileDialog + $dlg.Filter = 'PNG image (*.png)|*.png|JPEG (*.jpg)|*.jpg|Bitmap (*.bmp)|*.bmp' + $dlg.FileName = Get-DefaultSnipFilename + $dlg.InitialDirectory = $defaultDir + if ($dlg.ShowDialog()) { + $filterFormat = switch ($dlg.FilterIndex) { + 2 { 'Jpeg' } + 3 { 'Bmp' } + default { 'Png' } + } + $savePath = Resolve-SaveImagePath -Path $dlg.FileName -FilterFormat $filterFormat + $fmt = switch (Get-ImageFormatNameFromPath $savePath) { + 'Jpeg' { [System.Drawing.Imaging.ImageFormat]::Jpeg } + 'Bmp' { [System.Drawing.Imaging.ImageFormat]::Bmp } + default { [System.Drawing.Imaging.ImageFormat]::Png } + } + $Bitmap.Save($savePath, $fmt) + return $savePath + } + return $null +} diff --git a/src/20-Native.ps1 b/src/20-Native.ps1 new file mode 100644 index 0000000..4f15d3d --- /dev/null +++ b/src/20-Native.ps1 @@ -0,0 +1,265 @@ +#requires -Version 7.5 +# Generated deterministically from the reviewed SnipIT.ps1 AST by scripts/Export-SnipITModules.ps1. + +$script:SnipNativeInitializer = { + +$pinvoke = @' +using System; +using System.Runtime.InteropServices; +using System.Drawing; + +public static class Native { + // DPI awareness + [DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); + public static readonly IntPtr DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = new IntPtr(-4); + + // Hotkey + [DllImport("user32.dll")] public static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk); + [DllImport("user32.dll")] public static extern bool UnregisterHotKey(IntPtr hWnd, int id); + + // Window discovery + [StructLayout(LayoutKind.Sequential)] + public struct POINT { public int X; public int Y; } + [StructLayout(LayoutKind.Sequential)] + public struct RECT { public int Left, Top, Right, Bottom; } + + [DllImport("user32.dll")] public static extern IntPtr WindowFromPoint(POINT p); + [DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr hWnd, uint flags); + [DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr hWnd, uint command); + [DllImport("user32.dll")] public static extern IntPtr MonitorFromPoint(POINT point, uint flags); + public const uint GA_ROOT = 2; + public const uint GW_HWNDNEXT = 2; + public const uint MONITOR_DEFAULTTONEAREST = 2; + + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); + + // Visibility + show/hide for SnipIT-owned windows. We hide our chrome + // around CopyFromScreen so widget / preview UI doesn't get baked into + // captures, then SW_SHOWNA back without stealing focus. + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll", SetLastError = true)] + public static extern bool SetWindowPos(IntPtr hWnd, IntPtr insertAfter, + int x, int y, int width, int height, uint flags); + public const int SW_HIDE = 0; + public const int SW_SHOWNA = 8; // show without activating (don't steal focus) + public static readonly IntPtr HWND_TOPMOST = new IntPtr(-1); + public const uint SWP_NOZORDER = 0x0004; + public const uint SWP_NOACTIVATE = 0x0010; + public const uint SWP_SHOWWINDOW = 0x0040; + + [DllImport("shcore.dll")] + public static extern int GetDpiForMonitor(IntPtr monitor, int dpiType, + out uint dpiX, out uint dpiY); + public const int MDT_EFFECTIVE_DPI = 0; + + // DWM extended frame bounds (no drop shadow) + [DllImport("dwmapi.dll")] + public static extern int DwmGetWindowAttribute(IntPtr hWnd, int attr, out RECT rect, int size); + public const int DWMWA_EXTENDED_FRAME_BOUNDS = 9; + + // Mica backdrop (Win11) + [DllImport("dwmapi.dll")] + public static extern int DwmSetWindowAttribute(IntPtr hWnd, int attr, ref int value, int size); + public const int DWMWA_SYSTEMBACKDROP_TYPE = 38; + public const int DWMSBT_MAINWINDOW = 2; // Mica + public const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; + + // Cursor pos in screen pixels + [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p); + + // GDI cleanup for handles returned by Bitmap.GetHbitmap() + [DllImport("gdi32.dll")] public static extern bool DeleteObject(IntPtr hObject); +} +'@ + +if (-not ('Native' -as [type])) { + Add-Type -TypeDefinition $pinvoke -ReferencedAssemblies ([System.Drawing.Bitmap].Assembly.Location) +} + +[Native]::SetProcessDpiAwarenessContext([Native]::DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) | Out-Null + +$script:SelfWindowHandles = New-Object System.Collections.Generic.List[IntPtr] + +$script:ActivePreviewContext = $null + +} + +function Get-VirtualScreenBounds { + $vs = [System.Windows.Forms.SystemInformation]::VirtualScreen + [pscustomobject]@{ X=$vs.X; Y=$vs.Y; Width=$vs.Width; Height=$vs.Height } +} + +function Get-SnipMonitorDescriptors { + [CmdletBinding()] + param() + + foreach ($screen in [System.Windows.Forms.Screen]::AllScreens) { + $bounds = $screen.Bounds + $workingArea = $screen.WorkingArea + $point = [Native+POINT]::new() + $point.X = [int]($bounds.Left + [math]::Floor($bounds.Width / 2)) + $point.Y = [int]($bounds.Top + [math]::Floor($bounds.Height / 2)) + $monitor = [Native]::MonitorFromPoint( + $point, [Native]::MONITOR_DEFAULTTONEAREST) + [uint32]$dpiX = 96 + [uint32]$dpiY = 96 + if ($monitor -ne [IntPtr]::Zero) { + try { + if ([Native]::GetDpiForMonitor( + $monitor, [Native]::MDT_EFFECTIVE_DPI, + [ref]$dpiX, [ref]$dpiY) -ne 0) { + $dpiX = 96 + $dpiY = 96 + } + } catch { + $dpiX = 96 + $dpiY = 96 + } + } + [pscustomobject][ordered]@{ + Id = [string]$screen.DeviceName + X = [int]$bounds.X + Y = [int]$bounds.Y + Width = [int]$bounds.Width + Height = [int]$bounds.Height + WorkX = [int]$workingArea.X + WorkY = [int]$workingArea.Y + WorkWidth = [int]$workingArea.Width + WorkHeight = [int]$workingArea.Height + DpiX = [double]$dpiX + DpiY = [double]$dpiY + IsPrimary = [bool]$screen.Primary + } + } +} + +function Get-SnipWindowAtPhysicalPoint { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Point, + [object[]]$OwnHandles = @() + ) + + $nativePoint = [Native+POINT]::new() + $nativePoint.X = [int]$Point.X + $nativePoint.Y = [int]$Point.Y + $candidate = [Native]::WindowFromPoint($nativePoint) + if ($candidate -eq [IntPtr]::Zero) { return [IntPtr]::Zero } + $candidate = [Native]::GetAncestor($candidate, [Native]::GA_ROOT) + $own = [System.Collections.Generic.HashSet[Int64]]::new() + foreach ($handle in @($OwnHandles)) { + if ($null -ne $handle -and [IntPtr]$handle -ne [IntPtr]::Zero) { + [void]$own.Add(([IntPtr]$handle).ToInt64()) + } + } + + for ($attempt = 0; $attempt -lt 256 -and + $candidate -ne [IntPtr]::Zero; $attempt++) { + if (-not $own.Contains($candidate.ToInt64())) { return $candidate } + $candidate = [Native]::GetWindow($candidate, [Native]::GW_HWNDNEXT) + while ($candidate -ne [IntPtr]::Zero) { + if ([Native]::IsWindowVisible($candidate)) { + $bounds = [Native+RECT]::new() + if ([Native]::GetWindowRect($candidate, [ref]$bounds) -and + $nativePoint.X -ge $bounds.Left -and $nativePoint.X -lt $bounds.Right -and + $nativePoint.Y -ge $bounds.Top -and $nativePoint.Y -lt $bounds.Bottom) { + break + } + } + $candidate = [Native]::GetWindow($candidate, [Native]::GW_HWNDNEXT) + } + } + [IntPtr]::Zero +} + +function Get-SnipWindowBounds { + [CmdletBinding()] + param([Parameter(Mandatory)] [IntPtr]$Hwnd) + + if ($Hwnd -eq [IntPtr]::Zero) { return $null } + $bounds = [Native+RECT]::new() + $ok = ([Native]::DwmGetWindowAttribute( + $Hwnd, [Native]::DWMWA_EXTENDED_FRAME_BOUNDS, [ref]$bounds, 16) -eq 0) + if (-not $ok) { $ok = [Native]::GetWindowRect($Hwnd, [ref]$bounds) } + $width = $bounds.Right - $bounds.Left + $height = $bounds.Bottom - $bounds.Top + if (-not $ok -or $width -le 0 -or $height -le 0) { return $null } + [pscustomobject][ordered]@{ + X = [int]$bounds.Left + Y = [int]$bounds.Top + Width = [int]$width + Height = [int]$height + } +} + +function Register-SelfWindowHandle { + # Tracks an HWND we own (widget, preview, hotkey form, console) so the + # capture path can exclude it via Resolve-WindowCaptureTarget and the + # snapshot path can hide it via Hide-OwnSnipITWindowsForCapture. + param([IntPtr]$Hwnd) + if ($Hwnd -eq [IntPtr]::Zero) { return } + if (-not $script:SelfWindowHandles.Contains($Hwnd)) { + [void]$script:SelfWindowHandles.Add($Hwnd) + } +} + +function Unregister-SelfWindowHandle { + param([IntPtr]$Hwnd) + [void]$script:SelfWindowHandles.Remove($Hwnd) +} + +function Hide-OwnSnipITWindowsForCapture { + # Hides every registered SnipIT-owned hwnd that is currently visible so + # our widget / preview / etc. don't get baked into a desktop snapshot. + # Returns the list of hidden hwnds; pass it to + # Show-OwnSnipITWindowsForCapture to restore them without stealing focus. + [OutputType([System.Collections.Generic.List[IntPtr]])] + [CmdletBinding()] + param() + + if ($null -ne $script:ActivePreviewContext -and + $null -ne $script:ActivePreviewContext.CommandRouter) { + try { & $script:ActivePreviewContext.CommandRouter.CloseTransientMenus } + catch { Write-SnipDiag -Message 'Preview transient-menu cleanup failed' -ErrorRecord $_ } + } + $hidden = New-Object System.Collections.Generic.List[IntPtr] + foreach ($h in @($script:SelfWindowHandles)) { + if ($h -eq [IntPtr]::Zero) { continue } + if (-not [Native]::IsWindowVisible($h)) { continue } + if ([Native]::ShowWindow($h, [Native]::SW_HIDE)) { $hidden.Add($h) } + } + if ($hidden.Count -gt 0) { + # Pump pending UI work and yield briefly so DWM composes a frame + # without our chrome before CopyFromScreen samples the desktop. + try { [System.Windows.Forms.Application]::DoEvents() } catch {} + Start-Sleep -Milliseconds 80 + } + # Wrap in a single-element array so PowerShell does not unroll the List + # across the output stream. + ,$hidden +} + +function Show-OwnSnipITWindowsForCapture { + param($Hidden) + if (-not $Hidden -or $Hidden.Count -eq 0) { return } + foreach ($h in $Hidden) { + # SW_SHOWNA = show without activating, so we don't yank focus from + # whatever window the user was on while the snapshot ran. + [void][Native]::ShowWindow([IntPtr]$h, [Native]::SW_SHOWNA) + } +} + +function Set-MicaBackdrop { + param([System.Windows.Window]$Window) + try { + $helper = New-Object System.Windows.Interop.WindowInteropHelper $Window + $hwnd = $helper.Handle + if ($hwnd -eq [IntPtr]::Zero) { return } + $val = [Native]::DWMSBT_MAINWINDOW + [Native]::DwmSetWindowAttribute($hwnd, [Native]::DWMWA_SYSTEMBACKDROP_TYPE, [ref]$val, 4) | Out-Null + $dark = 1 + [Native]::DwmSetWindowAttribute($hwnd, [Native]::DWMWA_USE_IMMERSIVE_DARK_MODE, [ref]$dark, 4) | Out-Null + } catch {} +} diff --git a/src/30-Capture.ps1 b/src/30-Capture.ps1 new file mode 100644 index 0000000..d07d934 --- /dev/null +++ b/src/30-Capture.ps1 @@ -0,0 +1,1187 @@ +#requires -Version 7.5 +# Generated deterministically from the reviewed SnipIT.ps1 AST by scripts/Export-SnipITModules.ps1. + +$script:CaptureCoordinator = $null + +function New-ScreenBitmap { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [int]$X, + [Parameter(Mandatory)] [int]$Y, + [Parameter(Mandatory)] [int]$Width, + [Parameter(Mandatory)] [int]$Height, + [scriptblock]$BitmapFactory = { + param($width,$height,$pixelFormat) + [System.Drawing.Bitmap]::new($width, $height, $pixelFormat) + }, + [scriptblock]$GraphicsFactory = { + param($bitmap) + [System.Drawing.Graphics]::FromImage($bitmap) + }, + [scriptblock]$CopyPixels = { + param($graphics,$x,$y,$width,$height) + $graphics.CopyFromScreen( + $x, $y, 0, 0, + [System.Drawing.Size]::new($width, $height), + [System.Drawing.CopyPixelOperation]::SourceCopy) + } + ) + + $bmp = $null + $graphics = $null + $captureSucceeded = $false + $graphicsCleanupFailed = $false + try { + $bmp = & $BitmapFactory $Width $Height ` + ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + $graphics = & $GraphicsFactory $bmp + & $CopyPixels $graphics $X $Y $Width $Height | Out-Null + $captureSucceeded = $true + } finally { + try { + if ($null -ne $graphics) { + Invoke-SnipResourceDispose -Resource $graphics + } + } catch { + # A bitmap cannot be transferred when its Graphics cleanup failed. + $graphicsCleanupFailed = $true + throw + } finally { + if ((-not $captureSucceeded -or $graphicsCleanupFailed) -and + $null -ne $bmp) { + Invoke-SnipResourceDispose -Resource $bmp + } + } + } + + return $bmp +} + +function Get-SnipOverlayService { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Services, + [Parameter(Mandatory)] [string]$Name + ) + + $property = $Services.PSObject.Properties[$Name] + if ($null -eq $property -or $null -eq $property.Value) { + throw [ArgumentException]::new( + "Smart overlay services are missing '$Name'.", 'Services') + } + $property.Value +} + +function New-SnipOverlayContext { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Snapshot, + $SnapshotSource, + [Parameter(Mandatory)] $VirtualBounds, + [Parameter(Mandatory)] [object[]]$MonitorLayouts, + [Parameter(Mandatory)] $Services, + [Parameter(Mandatory)] [System.Windows.ResourceDictionary]$Resources + ) + + $context = [pscustomobject][ordered]@{ + Snapshot = $Snapshot + SnapshotSource = $SnapshotSource + VirtualBounds = $VirtualBounds + MonitorLayouts = @($MonitorLayouts) + Services = $Services + Resources = $Resources + Overlays = [System.Collections.ArrayList]::new() + Dragging = $false + AnchorPhysical = $null + PointerPhysical = $null + Selection = $null + HoverHwnd = [IntPtr]::Zero + HoverBounds = $null + ClickCandidateBounds = $null + PendingOverlay = $null + PointerDirty = $false + RenderCount = 0 + CaptureOverlay = $null + SurfaceResult = 'UserCancelled' + ErrorRecord = $null + Closing = $false + RenderingHandler = $null + RenderingAttached = $false + Actions = $null + } + + $readCursor = { + $getCursor = Get-SnipOverlayService -Services $context.Services ` + -Name GetCursorPosition + $output = @(& $getCursor) + $point = if ($output.Count -gt 0) { $output[-1] } else { $null } + if ($null -eq $point -or $null -eq $point.PSObject.Properties['X'] -or + $null -eq $point.PSObject.Properties['Y']) { + throw [InvalidOperationException]::new( + 'GetCursorPosition returned no physical point.') + } + [pscustomobject][ordered]@{ X=[int]$point.X; Y=[int]$point.Y } + }.GetNewClosure() + $close = { + param( + [ValidateSet('Completed','UserCancelled','Preempted','Failed','Shutdown')] + [string]$Result = 'UserCancelled' + ) + if ($context.Closing) { return } + $context.Closing = $true + $context.SurfaceResult = $Result + if ($Result -ne 'Completed') { $context.Selection = $null } + if ($null -ne $context.CaptureOverlay) { + try { $context.CaptureOverlay.Window.ReleaseMouseCapture() } catch {} + } + $context.Dragging = $false + foreach ($overlay in @($context.Overlays)) { + if ($null -ne $overlay.Window) { try { $overlay.Window.Close() } catch {} } + } + }.GetNewClosure() + $queuePointer = { + param($Overlay) + if ($context.Closing) { return } + $context.PendingOverlay = $Overlay + $context.PointerDirty = $true + }.GetNewClosure() + $beginDrag = { + param($Overlay) + if ($context.Closing) { return } + $point = & $readCursor + # Flush the newest queued hover against this exact physical point so + # a fast move/down/up cannot commit the previous frame's target. + $context.PendingOverlay = $Overlay + $context.PointerDirty = $true + Invoke-SnipOverlayRenderTick -Context $context -PhysicalPoint $point + $context.PointerPhysical = $point + $context.AnchorPhysical = $point + $context.Selection = [pscustomobject][ordered]@{ + X=$point.X; Y=$point.Y; Width=0; Height=0 + } + $context.ClickCandidateBounds = $context.HoverBounds + $context.Dragging = $true + $context.HoverHwnd = [IntPtr]::Zero + $context.HoverBounds = $null + $context.CaptureOverlay = $Overlay + $context.PointerDirty = $true + foreach ($item in @($context.Overlays)) { + $item.HintBorder.Visibility = 'Collapsed' + } + try { $Overlay.Window.CaptureMouse() | Out-Null } catch {} + }.GetNewClosure() + $completeDrag = { + if (-not $context.Dragging -or $context.Closing) { return } + $point = & $readCursor + $context.PointerPhysical = $point + $kind = Test-IsClickVsDrag ` + -AnchorX $context.AnchorPhysical.X -AnchorY $context.AnchorPhysical.Y ` + -CurrentX $point.X -CurrentY $point.Y + if ($kind -eq 'click') { + $context.Selection = $context.ClickCandidateBounds + } else { + $context.Selection = Get-DragRectangle ` + -AnchorX $context.AnchorPhysical.X -AnchorY $context.AnchorPhysical.Y ` + -CurrentX $point.X -CurrentY $point.Y + } + try { $context.CaptureOverlay.Window.ReleaseMouseCapture() } catch {} + $context.Dragging = $false + $valid = $null -ne $context.Selection -and + (Test-CaptureRectValid -Width ([int]$context.Selection.Width) ` + -Height ([int]$context.Selection.Height)) + & $close $(if ($valid) { 'Completed' } else { 'UserCancelled' }) + }.GetNewClosure() + $cancel = { & $close 'UserCancelled' }.GetNewClosure() + $context.Actions = [pscustomobject][ordered]@{ + ReadCursor = $readCursor + Close = $close + QueuePointer = $queuePointer + BeginDrag = $beginDrag + CompleteDrag = $completeDrag + Cancel = $cancel + } + $context +} + +function Remove-SnipOverlayWindowEvents { + [CmdletBinding()] + param([Parameter(Mandatory)] $Overlay) + + if (-not $Overlay.EventsAttached) { return } + $Overlay.EventsAttached = $false + try { $Overlay.Window.Remove_MouseMove($Overlay.MouseMoveHandler) } catch {} + try { $Overlay.Window.Remove_MouseLeftButtonDown($Overlay.MouseDownHandler) } catch {} + try { $Overlay.Window.Remove_MouseLeftButtonUp($Overlay.MouseUpHandler) } catch {} + try { $Overlay.Window.Remove_MouseRightButtonDown($Overlay.RightDownHandler) } catch {} + try { $Overlay.Window.Remove_KeyDown($Overlay.KeyDownHandler) } catch {} + try { $Overlay.Window.Remove_SourceInitialized($Overlay.PositionHandler) } catch {} + try { $Overlay.Window.Remove_Closed($Overlay.ClosedHandler) } catch {} +} + +function New-SnipOverlayWindow { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Context, + [Parameter(Mandatory)] $MonitorLayout + ) + + [xml]$xaml = [xml](Get-SnipXamlText -Name 'SmartOverlay') + $reader = [System.Xml.XmlNodeReader]::new($xaml) + $window = [System.Windows.Markup.XamlReader]::Load($reader) + Add-SnipThemeResources -Root $window -Resources $Context.Resources | Out-Null + $window.Left = [double]$MonitorLayout.DipX + $window.Top = [double]$MonitorLayout.DipY + $window.Width = [double]$MonitorLayout.DipWidth + $window.Height = [double]$MonitorLayout.DipHeight + + $bgImage = $window.FindName('BgImage') + $bgImage.Width = [double]$MonitorLayout.DipWidth + $bgImage.Height = [double]$MonitorLayout.DipHeight + if ($Context.SnapshotSource -is [System.Windows.Media.Imaging.BitmapSource]) { + $sourceX = [int][math]::Round( + [double]$MonitorLayout.PhysicalX - [double]$Context.VirtualBounds.X, + 0, [MidpointRounding]::AwayFromZero) + $sourceY = [int][math]::Round( + [double]$MonitorLayout.PhysicalY - [double]$Context.VirtualBounds.Y, + 0, [MidpointRounding]::AwayFromZero) + $sourceWidth = [int][math]::Round( + [double]$MonitorLayout.PhysicalWidth, + 0, [MidpointRounding]::AwayFromZero) + $sourceHeight = [int][math]::Round( + [double]$MonitorLayout.PhysicalHeight, + 0, [MidpointRounding]::AwayFromZero) + $monitorSource = [System.Windows.Media.Imaging.CroppedBitmap]::new( + $Context.SnapshotSource, + [System.Windows.Int32Rect]::new( + $sourceX, $sourceY, $sourceWidth, $sourceHeight)) + $monitorSource.Freeze() + $bgImage.Source = $monitorSource + } + + $overlay = [pscustomobject][ordered]@{ + Context = $Context + Layout = $MonitorLayout + Window = $window + Lifecycle = $null + BackgroundImage = $bgImage + Dimmer = $window.FindName('Dimmer') + HoverRect = $window.FindName('HoverRect') + DragRect = $window.FindName('DragRect') + HintBorder = $window.FindName('HintBorder') + HintText = $window.FindName('HintText') + LoupeBorder = $window.FindName('LoupeBorder') + LoupeImage = $window.FindName('LoupeImage') + LoupeText = $window.FindName('LoupeText') + LoupeBounds = $null + RenderedSelection = $null + RenderedHover = $null + Closed = $false + EventsAttached = $false + MouseMoveHandler = $null + MouseDownHandler = $null + MouseUpHandler = $null + RightDownHandler = $null + KeyDownHandler = $null + PositionHandler = $null + ClosedHandler = $null + } + $overlay.HoverRect.Stroke = $Context.Resources['SnipAccentBrush'] + $overlay.DragRect.Stroke = $Context.Resources['SnipMintBrush'] + if ([bool]$Context.Resources['SnipHighContrast']) { + $overlay.Dimmer.Fill = [System.Windows.SystemColors]::WindowBrush + $overlay.DragRect.Fill = [System.Windows.SystemColors]::HighlightBrush + } + if ($null -ne $Context.Resources['SnipIslandShadow']) { + $overlay.HintBorder.Effect = $Context.Resources['SnipIslandShadow'] + $overlay.LoupeBorder.Effect = $Context.Resources['SnipIslandShadow'] + } + [System.Windows.Automation.AutomationProperties]::SetName( + $window, "Smart capture overlay on $($MonitorLayout.Id)") + + $register = Get-SnipOverlayService -Services $Context.Services -Name RegisterWindow + $unregister = Get-SnipOverlayService -Services $Context.Services -Name UnregisterWindow + $overlay.Lifecycle = Connect-SnipWindowLifecycle -Window $window ` + -Register $register -Unregister $unregister + $positionState = [pscustomobject]@{ Overlay=$overlay } + $position = [EventHandler]{ + param($sender,$eventArgs) + $item = $positionState.Overlay + $handle = [System.Windows.Interop.WindowInteropHelper]::new($item.Window).Handle + if ($handle -eq [IntPtr]::Zero) { return } + try { + $positionWindow = Get-SnipOverlayService ` + -Services $item.Context.Services -Name PositionWindow + $positionOutput = @(& $positionWindow $handle $item.Layout) + $positioned = $positionOutput.Count -gt 0 -and + $positionOutput[-1] -is [bool] -and [bool]$positionOutput[-1] + if (-not $positioned) { + throw [ComponentModel.Win32Exception]::new( + "SetWindowPos returned false for monitor '$($item.Layout.Id)'.") + } + } catch { + throw [InvalidOperationException]::new( + "SetWindowPos failed for monitor '$($item.Layout.Id)'.", + $_.Exception) + } + }.GetNewClosure() + $mouseMove = [System.Windows.Input.MouseEventHandler]{ + param($sender,$eventArgs) + & $overlay.Context.Actions.QueuePointer $overlay | Out-Null + }.GetNewClosure() + $mouseDown = [System.Windows.Input.MouseButtonEventHandler]{ + param($sender,$eventArgs) + if ($eventArgs.ChangedButton -eq [System.Windows.Input.MouseButton]::Left) { + & $overlay.Context.Actions.BeginDrag $overlay | Out-Null + $eventArgs.Handled = $true + } + }.GetNewClosure() + $mouseUp = [System.Windows.Input.MouseButtonEventHandler]{ + param($sender,$eventArgs) + if ($eventArgs.ChangedButton -eq [System.Windows.Input.MouseButton]::Left) { + & $overlay.Context.Actions.CompleteDrag | Out-Null + $eventArgs.Handled = $true + } + }.GetNewClosure() + $rightDown = [System.Windows.Input.MouseButtonEventHandler]{ + param($sender,$eventArgs) + & $overlay.Context.Actions.Cancel | Out-Null + $eventArgs.Handled = $true + }.GetNewClosure() + $keyDown = [System.Windows.Input.KeyEventHandler]{ + param($sender,$eventArgs) + if ($eventArgs.Key -eq [System.Windows.Input.Key]::Escape) { + & $overlay.Context.Actions.Cancel | Out-Null + $eventArgs.Handled = $true + } + }.GetNewClosure() + $closedState = [pscustomobject]@{ Overlay=$overlay } + $closed = [EventHandler]{ + param($sender,$eventArgs) + $closedState.Overlay.Closed = $true + Remove-SnipOverlayWindowEvents -Overlay $closedState.Overlay + }.GetNewClosure() + $overlay.PositionHandler = $position + $overlay.MouseMoveHandler = $mouseMove + $overlay.MouseDownHandler = $mouseDown + $overlay.MouseUpHandler = $mouseUp + $overlay.RightDownHandler = $rightDown + $overlay.KeyDownHandler = $keyDown + $overlay.ClosedHandler = $closed + $window.Add_SourceInitialized($position) + $window.Add_MouseMove($mouseMove) + $window.Add_MouseLeftButtonDown($mouseDown) + $window.Add_MouseLeftButtonUp($mouseUp) + $window.Add_MouseRightButtonDown($rightDown) + $window.Add_KeyDown($keyDown) + $window.Add_Closed($closed) + $overlay.EventsAttached = $true + [void]$Context.Overlays.Add($overlay) + $overlay +} + +function Set-SnipOverlayRectangleVisual { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $RectangleControl, + $Intersection + ) + + if ($null -eq $Intersection) { + $RectangleControl.Visibility = 'Collapsed' + return + } + [System.Windows.Controls.Canvas]::SetLeft( + $RectangleControl, [double]$Intersection.DipX) + [System.Windows.Controls.Canvas]::SetTop( + $RectangleControl, [double]$Intersection.DipY) + $RectangleControl.Width = [double]$Intersection.DipWidth + $RectangleControl.Height = [double]$Intersection.DipHeight + $RectangleControl.Visibility = 'Visible' +} + +function Invoke-SnipOverlayRenderTick { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Context, + $PhysicalPoint + ) + + if ($Context.Closing -or -not $Context.PointerDirty) { return } + $Context.PointerDirty = $false + $point = if ($PSBoundParameters.ContainsKey('PhysicalPoint')) { + [pscustomobject][ordered]@{ + X = [int]$PhysicalPoint.X + Y = [int]$PhysicalPoint.Y + } + } else { + & $Context.Actions.ReadCursor + } + $Context.PointerPhysical = $point + + if ($Context.Dragging) { + $Context.Selection = Get-DragRectangle ` + -AnchorX $Context.AnchorPhysical.X -AnchorY $Context.AnchorPhysical.Y ` + -CurrentX $point.X -CurrentY $point.Y + $Context.HoverHwnd = [IntPtr]::Zero + $Context.HoverBounds = $null + } else { + $ownHandles = [System.Collections.Generic.List[IntPtr]]::new() + $ownValues = [System.Collections.Generic.HashSet[Int64]]::new() + foreach ($handle in @( + $Context.Overlays | ForEach-Object { $_.Lifecycle.Handle })) { + $nativeHandle = [IntPtr]$handle + if ($nativeHandle -ne [IntPtr]::Zero -and + $ownValues.Add($nativeHandle.ToInt64())) { + $ownHandles.Add($nativeHandle) + } + } + $getOwnHandles = Get-SnipOverlayService -Services $Context.Services ` + -Name GetOwnWindowHandles + $registeredOutput = @(& $getOwnHandles) + foreach ($handle in $registeredOutput) { + if ($null -eq $handle) { continue } + $nativeHandle = [IntPtr]$handle + if ($nativeHandle -ne [IntPtr]::Zero -and + $ownValues.Add($nativeHandle.ToInt64())) { + $ownHandles.Add($nativeHandle) + } + } + $getWindow = Get-SnipOverlayService -Services $Context.Services -Name GetWindowAtPoint + $windowOutput = @(& $getWindow $point ([IntPtr[]]$ownHandles.ToArray())) + $hwnd = if ($windowOutput.Count -gt 0) { + [IntPtr]$windowOutput[-1] + } else { + [IntPtr]::Zero + } + $Context.HoverHwnd = $hwnd + $Context.HoverBounds = $null + if ($hwnd -ne [IntPtr]::Zero) { + $getBounds = Get-SnipOverlayService -Services $Context.Services -Name GetWindowBounds + $boundsOutput = @(& $getBounds $hwnd) + $bounds = if ($boundsOutput.Count -gt 0) { $boundsOutput[-1] } else { $null } + if ($null -ne $bounds -and + $null -ne $bounds.PSObject.Properties['X'] -and + $null -ne $bounds.PSObject.Properties['Y'] -and + $null -ne $bounds.PSObject.Properties['Width'] -and + $null -ne $bounds.PSObject.Properties['Height'] -and + [double]$bounds.Width -gt 0 -and [double]$bounds.Height -gt 0) { + $Context.HoverBounds = [pscustomobject][ordered]@{ + X=[double]$bounds.X; Y=[double]$bounds.Y + Width=[double]$bounds.Width; Height=[double]$bounds.Height + } + } + } + } + + $selectionParts = if ($Context.Dragging -and $null -ne $Context.Selection) { + @(Get-SnipOverlayIntersections -Rectangle $Context.Selection ` + -MonitorLayouts $Context.MonitorLayouts) + } else { + @() + } + $hoverParts = if (-not $Context.Dragging -and $null -ne $Context.HoverBounds) { + @(Get-SnipOverlayIntersections -Rectangle $Context.HoverBounds ` + -MonitorLayouts $Context.MonitorLayouts) + } else { + @() + } + + foreach ($overlay in @($Context.Overlays)) { + $selectionPart = @($selectionParts | Where-Object { + $_.MonitorIndex -eq $overlay.Layout.Index + } | Select-Object -First 1) + $selectionPart = if ($selectionPart.Count) { $selectionPart[0] } else { $null } + $overlay.RenderedSelection = $selectionPart + Set-SnipOverlayRectangleVisual -RectangleControl $overlay.DragRect ` + -Intersection $selectionPart + if ($null -ne $selectionPart) { + $overlay.HintText.Text = ('{0} × {1} px' -f + [int]$Context.Selection.Width, [int]$Context.Selection.Height) + } + + $hoverPart = @($hoverParts | Where-Object { + $_.MonitorIndex -eq $overlay.Layout.Index + } | Select-Object -First 1) + $hoverPart = if ($hoverPart.Count) { $hoverPart[0] } else { $null } + $overlay.RenderedHover = $hoverPart + Set-SnipOverlayRectangleVisual -RectangleControl $overlay.HoverRect ` + -Intersection $hoverPart + } + + $pointerOverlay = @($Context.Overlays | Where-Object { + $point.X -ge $_.Layout.PhysicalX -and + $point.X -lt ($_.Layout.PhysicalX + $_.Layout.PhysicalWidth) -and + $point.Y -ge $_.Layout.PhysicalY -and + $point.Y -lt ($_.Layout.PhysicalY + $_.Layout.PhysicalHeight) + } | Select-Object -First 1) + foreach ($overlay in @($Context.Overlays)) { + if ($pointerOverlay.Count -eq 0 -or + -not [object]::ReferenceEquals($overlay, $pointerOverlay[0])) { + $overlay.LoupeBorder.Visibility = 'Collapsed' + $overlay.LoupeBounds = $null + continue + } + + $loupeWidth = [int][math]::Round( + $overlay.LoupeBorder.Width * $overlay.Layout.ScaleX, + 0, [MidpointRounding]::AwayFromZero) + $loupeHeight = [int][math]::Round( + $overlay.LoupeBorder.Height * $overlay.Layout.ScaleY, + 0, [MidpointRounding]::AwayFromZero) + $position = Get-LoupePosition -MouseX $point.X -MouseY $point.Y ` + -VsX ([int]$overlay.Layout.PhysicalX) ` + -VsY ([int]$overlay.Layout.PhysicalY) ` + -VsWidth ([int]$overlay.Layout.PhysicalWidth) ` + -VsHeight ([int]$overlay.Layout.PhysicalHeight) ` + -LoupeWidth $loupeWidth -LoupeHeight $loupeHeight + $pointerLocalX = $point.X - [double]$overlay.Layout.PhysicalX + $pointerLocalY = $point.Y - [double]$overlay.Layout.PhysicalY + $candidates = @( + [pscustomobject]@{ X=$position.X; Y=$position.Y }, + [pscustomobject]@{ + X=$pointerLocalX - $loupeWidth - 14 + Y=$pointerLocalY - $loupeHeight - 10 + }, + [pscustomobject]@{ + X=$pointerLocalX + 24 + Y=$pointerLocalY - $loupeHeight - 10 + }, + [pscustomobject]@{ + X=$pointerLocalX - $loupeWidth - 14 + Y=$pointerLocalY + 24 + }, + [pscustomobject]@{ X=$pointerLocalX + 24; Y=$pointerLocalY + 24 } + ) + $localX = 0 + $localY = 0 + $bestOverlap = [double]::PositiveInfinity + foreach ($candidate in $candidates) { + $candidateX = [math]::Max(0, [math]::Min( + [double]$overlay.Layout.PhysicalWidth - $loupeWidth, + [double]$candidate.X)) + $candidateY = [math]::Max(0, [math]::Min( + [double]$overlay.Layout.PhysicalHeight - $loupeHeight, + [double]$candidate.Y)) + $overlap = 0.0 + if ($Context.Dragging -and $null -ne $Context.Selection) { + $candidateGlobalX = [double]$overlay.Layout.PhysicalX + $candidateX + $candidateGlobalY = [double]$overlay.Layout.PhysicalY + $candidateY + $overlapWidth = [math]::Min( + $candidateGlobalX + $loupeWidth, + [double]$Context.Selection.X + [double]$Context.Selection.Width) - + [math]::Max($candidateGlobalX, [double]$Context.Selection.X) + $overlapHeight = [math]::Min( + $candidateGlobalY + $loupeHeight, + [double]$Context.Selection.Y + [double]$Context.Selection.Height) - + [math]::Max($candidateGlobalY, [double]$Context.Selection.Y) + if ($overlapWidth -gt 0 -and $overlapHeight -gt 0) { + $overlap = $overlapWidth * $overlapHeight + } + } + if ($overlap -lt $bestOverlap) { + $bestOverlap = $overlap + $localX = $candidateX + $localY = $candidateY + if ($bestOverlap -eq 0) { break } + } + } + [System.Windows.Controls.Canvas]::SetLeft( + $overlay.LoupeBorder, $localX / $overlay.Layout.ScaleX) + [System.Windows.Controls.Canvas]::SetTop( + $overlay.LoupeBorder, $localY / $overlay.Layout.ScaleY) + $overlay.LoupeBounds = [pscustomobject][ordered]@{ + X = [double]$overlay.Layout.PhysicalX + $localX + Y = [double]$overlay.Layout.PhysicalY + $localY + Width = $loupeWidth + Height = $loupeHeight + } + if ($Context.SnapshotSource -is [System.Windows.Media.Imaging.BitmapSource]) { + $source = Get-LoupeSourceRect -MouseX $point.X -MouseY $point.Y ` + -VsX ([int]$Context.VirtualBounds.X) -VsY ([int]$Context.VirtualBounds.Y) ` + -VsWidth ([int]$Context.VirtualBounds.Width) ` + -VsHeight ([int]$Context.VirtualBounds.Height) -Size 18 + $loupeSource = [System.Windows.Media.Imaging.CroppedBitmap]::new( + $Context.SnapshotSource, + [System.Windows.Int32Rect]::new($source.X, $source.Y, $source.Size, $source.Size)) + $loupeSource.Freeze() + $overlay.LoupeImage.Width = 144 + $overlay.LoupeImage.Height = 144 + $overlay.LoupeImage.Source = $loupeSource + } + $overlay.LoupeText.Text = ('{0} , {1}' -f $point.X, $point.Y) + $overlay.LoupeBorder.Visibility = 'Visible' + } + $Context.RenderCount++ +} + +function Show-SmartOverlaySet { + [CmdletBinding()] + param( + [scriptblock]$OnSurfaceReady, + $Services, + [scriptblock]$TestAction, + [scriptblock]$HideWindows, + [scriptblock]$RestoreWindows, + [scriptblock]$CaptureFactory, + [scriptblock]$BitmapSourceFactory + ) + + $snapshot = $null + $context = $null + $overlayServices = $Services + try { + if ($null -eq $overlayServices) { + $overlayServices = [pscustomobject][ordered]@{ + GetMonitorDescriptors = { @(Get-SnipMonitorDescriptors) } + GetVirtualBounds = { Get-VirtualScreenBounds } + HideWindows = $HideWindows + RestoreWindows = $RestoreWindows + CaptureSnapshot = $CaptureFactory + ConvertSnapshotSource = $BitmapSourceFactory + GetCursorPosition = { + $point = [Native+POINT]::new() + if (-not [Native]::GetCursorPos([ref]$point)) { + throw [InvalidOperationException]::new('GetCursorPos failed.') + } + [pscustomobject][ordered]@{ X=$point.X; Y=$point.Y } + } + GetWindowAtPoint = { + param($point,$ownHandles) + Get-SnipWindowAtPhysicalPoint -Point $point -OwnHandles $ownHandles + } + GetWindowBounds = { param($hwnd) Get-SnipWindowBounds -Hwnd $hwnd } + GetOwnWindowHandles = { @($script:SelfWindowHandles) } + PositionWindow = { + param($hwnd,$layout) + if (-not [Native]::SetWindowPos( + $hwnd, [Native]::HWND_TOPMOST, + [int]$layout.PhysicalX, [int]$layout.PhysicalY, + [int]$layout.PhysicalWidth, [int]$layout.PhysicalHeight, + [Native]::SWP_NOACTIVATE -bor [Native]::SWP_SHOWWINDOW)) { + $nativeError = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + throw [ComponentModel.Win32Exception]::new( + $nativeError, + "SetWindowPos failed for monitor '$($layout.Id)'.") + } + $true + } + AddRenderingHandler = { + param($handler) + [System.Windows.Media.CompositionTarget]::add_Rendering($handler) + } + RemoveRenderingHandler = { + param($handler) + [System.Windows.Media.CompositionTarget]::remove_Rendering($handler) + } + RegisterWindow = { param($hwnd) Register-SelfWindowHandle -Hwnd $hwnd } + UnregisterWindow = { param($hwnd) Unregister-SelfWindowHandle -Hwnd $hwnd } + Resources = New-SnipThemeResources ` + -HighContrast ([System.Windows.SystemParameters]::HighContrast) + } + } + + foreach ($name in 'GetMonitorDescriptors','GetVirtualBounds','HideWindows', + 'RestoreWindows','CaptureSnapshot','ConvertSnapshotSource','GetCursorPosition', + 'GetWindowAtPoint','GetWindowBounds','GetOwnWindowHandles','PositionWindow', + 'AddRenderingHandler', + 'RemoveRenderingHandler','RegisterWindow','UnregisterWindow','Resources') { + [void](Get-SnipOverlayService -Services $overlayServices -Name $name) + } + $getDescriptors = Get-SnipOverlayService -Services $overlayServices ` + -Name GetMonitorDescriptors + $descriptors = @(& $getDescriptors) + $layouts = @(Get-SnipMonitorLayouts -MonitorDescriptors $descriptors) + $getVirtualBounds = Get-SnipOverlayService -Services $overlayServices ` + -Name GetVirtualBounds + $virtualOutput = @(& $getVirtualBounds) + $virtualBounds = if ($virtualOutput.Count) { $virtualOutput[-1] } else { $null } + if ($null -eq $virtualBounds) { + throw [InvalidOperationException]::new('Virtual screen bounds are unavailable.') + } + + $hide = Get-SnipOverlayService -Services $overlayServices -Name HideWindows + $restore = Get-SnipOverlayService -Services $overlayServices -Name RestoreWindows + $capture = Get-SnipOverlayService -Services $overlayServices -Name CaptureSnapshot + $convert = Get-SnipOverlayService -Services $overlayServices -Name ConvertSnapshotSource + $hidden = & $hide + try { + $captureOutput = @(& $capture $virtualBounds) + $snapshot = if ($captureOutput.Count) { $captureOutput[-1] } else { $null } + } finally { + & $restore $hidden | Out-Null + } + if ($null -eq $snapshot) { + throw [InvalidOperationException]::new('Virtual snapshot creation returned null.') + } + $sourceOutput = @(& $convert $snapshot) + $snapshotSource = if ($sourceOutput.Count) { $sourceOutput[-1] } else { $null } + $resources = Get-SnipOverlayService -Services $overlayServices -Name Resources + $context = New-SnipOverlayContext -Snapshot $snapshot ` + -SnapshotSource $snapshotSource -VirtualBounds $virtualBounds ` + -MonitorLayouts $layouts -Services $overlayServices -Resources $resources + foreach ($layout in $layouts) { + New-SnipOverlayWindow -Context $context -MonitorLayout $layout | Out-Null + } + + $renderState = [pscustomobject]@{ Context=$context } + $renderHandler = [EventHandler]{ + param($sender,$eventArgs) + try { + Invoke-SnipOverlayRenderTick -Context $renderState.Context + } catch { + $renderState.Context.ErrorRecord = $_ + & $renderState.Context.Actions.Close 'Failed' | Out-Null + } + }.GetNewClosure() + $context.RenderingHandler = $renderHandler + $surface = [pscustomobject][ordered]@{ + Kind = 'Selecting' + Window = $context.Overlays[0].Window + Windows = @($context.Overlays | ForEach-Object Window) + Context = $context + Close = { + param([string]$result) + & $context.Actions.Close $result + }.GetNewClosure() + } + $showSurface = $true + if ($OnSurfaceReady) { + $readyOutput = @(& $OnSurfaceReady $surface) + $readyValue = if ($readyOutput.Count) { $readyOutput[-1] } else { $null } + if ($readyValue -is [bool] -and -not $readyValue) { $showSurface = $false } + } + + if ($showSurface) { + $addRendering = Get-SnipOverlayService -Services $overlayServices ` + -Name AddRenderingHandler + # Treat subscription as owned before invoking the injected seam so + # a service that attaches and then throws is still detached. + $context.RenderingAttached = $true + & $addRendering $renderHandler | Out-Null + if ($TestAction) { + foreach ($overlay in @($context.Overlays)) { + $overlay.Window.Opacity = 0 + $overlay.Window.ShowActivated = $false + $overlay.Window.Show() + $overlay.Window.UpdateLayout() + } + $kit = [pscustomobject][ordered]@{ + Context = $context + Overlays = @($context.Overlays) + QueuePointer = $context.Actions.QueuePointer + BeginDrag = $context.Actions.BeginDrag + CompleteDrag = $context.Actions.CompleteDrag + Cancel = $context.Actions.Cancel + Close = $context.Actions.Close + RenderTick = { Invoke-SnipOverlayRenderTick -Context $context }.GetNewClosure() + } + & $TestAction $kit | Out-Null + if (-not $context.Closing) { & $context.Actions.Cancel | Out-Null } + } else { + for ($index = 1; $index -lt $context.Overlays.Count; $index++) { + $context.Overlays[$index].Window.Show() + } + $context.Overlays[0].Window.ShowDialog() | Out-Null + } + } + + if ($context.SurfaceResult -eq 'Completed') { + $validSelection = $null -ne $context.Selection + if ($validSelection) { + foreach ($propertyName in 'X','Y','Width','Height') { + if ($null -eq $context.Selection.PSObject.Properties[$propertyName]) { + $validSelection = $false + break + } + } + } + if ($validSelection) { + try { + $selection = [pscustomobject][ordered]@{ + X = [int][math]::Round([double]$context.Selection.X, 0, + [MidpointRounding]::AwayFromZero) + Y = [int][math]::Round([double]$context.Selection.Y, 0, + [MidpointRounding]::AwayFromZero) + Width = [int][math]::Round([double]$context.Selection.Width, 0, + [MidpointRounding]::AwayFromZero) + Height = [int][math]::Round([double]$context.Selection.Height, 0, + [MidpointRounding]::AwayFromZero) + } + $validSelection = Test-CaptureRectValid ` + -Width $selection.Width -Height $selection.Height + } catch { + $validSelection = $false + } + } + if (-not $validSelection) { + $context.SurfaceResult = 'UserCancelled' + $selection = $null + } + } else { + $selection = $null + } + [pscustomobject][ordered]@{ + Result = $context.SurfaceResult + Selection = $selection + Bitmap = $null + ErrorRecord = $context.ErrorRecord + } + } catch { + [pscustomobject][ordered]@{ + Result = 'Failed' + Selection = $null + Bitmap = $null + ErrorRecord = $_ + } + } finally { + if ($null -ne $context) { + if ($context.RenderingAttached) { + try { + $removeRendering = Get-SnipOverlayService ` + -Services $context.Services -Name RemoveRenderingHandler + & $removeRendering $context.RenderingHandler | Out-Null + } catch {} + $context.RenderingAttached = $false + } + foreach ($overlay in @($context.Overlays)) { + try { $overlay.Window.Close() } catch {} + Remove-SnipOverlayWindowEvents -Overlay $overlay + } + } + if ($null -ne $snapshot) { + try { Invoke-SnipResourceDispose -Resource $snapshot } catch {} + } + } +} + +function Show-SmartOverlay { + [CmdletBinding()] + param( + [scriptblock]$OnSurfaceReady, + $Services, + [scriptblock]$TestAction, + [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 + } + ) + + Show-SmartOverlaySet -OnSurfaceReady $OnSurfaceReady ` + -Services $Services -TestAction $TestAction ` + -HideWindows $HideWindows -RestoreWindows $RestoreWindows ` + -CaptureFactory $CaptureFactory -BitmapSourceFactory $BitmapSourceFactory +} + +function New-SnipRuntimeCaptureServices { + [CmdletBinding()] + param( + [scriptblock]$SmartOverlay = { + param($onSurfaceReady,$coordinator,$request) + Show-SmartOverlay -OnSurfaceReady $onSurfaceReady + }, + [scriptblock]$GetVirtualBounds = { Get-VirtualScreenBounds }, + [scriptblock]$ResolveWindow = { + $foreground = [Native]::GetForegroundWindow() + $target = Resolve-WindowCaptureTarget -ForegroundHwnd $foreground ` + -SelfWindowHandles $script:SelfWindowHandles + if ($null -eq $target) { return $null } + + $bounds = New-Object Native+RECT + $gotBounds = ([Native]::DwmGetWindowAttribute( + $target, [Native]::DWMWA_EXTENDED_FRAME_BOUNDS, [ref]$bounds, 16) -eq 0) + if (-not $gotBounds) { + $gotBounds = [Native]::GetWindowRect($target, [ref]$bounds) + } + $width = $bounds.Right - $bounds.Left + $height = $bounds.Bottom - $bounds.Top + if (-not $gotBounds -or $width -le 0 -or $height -le 0) { + return $null + } + [pscustomobject][ordered]@{ + Hwnd = $target + X = $bounds.Left + Y = $bounds.Top + Width = $width + Height = $height + } + }, + [scriptblock]$HideWindows = { Hide-OwnSnipITWindowsForCapture }, + [scriptblock]$RestoreWindows = { + param($hidden) + Show-OwnSnipITWindowsForCapture -Hidden $hidden + }, + [scriptblock]$CaptureRectangle = { + param($bounds) + New-ScreenBitmap -X $bounds.X -Y $bounds.Y ` + -Width $bounds.Width -Height $bounds.Height + } + ) + + $smartOverlayForCapture = $SmartOverlay + $getVirtualBoundsForCapture = $GetVirtualBounds + $resolveWindowForCapture = $ResolveWindow + $hideWindowsForCapture = $HideWindows + $restoreWindowsForCapture = $RestoreWindows + $captureRectangleForCapture = $CaptureRectangle + $captureBounds = { + param($bounds) + $hidden = & $hideWindowsForCapture + try { + $captured = & $captureRectangleForCapture $bounds + if ($captured -is [System.Drawing.Image]) { + $captured.Tag = [pscustomobject][ordered]@{ + X=[int]$bounds.X; Y=[int]$bounds.Y + Width=[int]$bounds.Width; Height=[int]$bounds.Height + } + } + $captured + } finally { + & $restoreWindowsForCapture $hidden | Out-Null + } + }.GetNewClosure() + + $fullCapture = { + param($coordinator,$request) + # Resolve virtual bounds inside the transaction so monitor changes and + # negative-origin layouts are never served by stale coordinates. + $vs = & $getVirtualBoundsForCapture + & $captureBounds $vs + }.GetNewClosure() + + $windowCapture = { + param($coordinator,$request) + # Foreground HWND and bounds are deliberately resolved for every fresh + # Window request, immediately before that request's snapshot. + $descriptor = & $resolveWindowForCapture + $validDescriptor = $null -ne $descriptor + $captureDescriptor = $null + if ($validDescriptor) { + foreach ($propertyName in 'X','Y','Width','Height') { + if ($null -eq $descriptor.PSObject.Properties[$propertyName]) { + $validDescriptor = $false + break + } + } + } + if ($validDescriptor) { + try { + $x = [int]$descriptor.X + $y = [int]$descriptor.Y + $width = [int]$descriptor.Width + $height = [int]$descriptor.Height + $validDescriptor = ($width -gt 0 -and $height -gt 0) + if ($validDescriptor) { + $hwndProperty = $descriptor.PSObject.Properties['Hwnd'] + $captureDescriptor = [pscustomobject][ordered]@{ + Hwnd = if ($null -ne $hwndProperty) { $hwndProperty.Value } else { $null } + X = $x + Y = $y + Width = $width + Height = $height + } + } + } catch { + $validDescriptor = $false + } + } + if (-not $validDescriptor) { + return (& $fullCapture $coordinator $request) + } + & $captureBounds $captureDescriptor + }.GetNewClosure() + + $smartCapture = { + param($coordinator,$request) + $surfaceContext = [pscustomobject]@{ + Coordinator = $coordinator + Request = $request + } + $surfaceReady = { + param($surface) + $owner = $surfaceContext.Coordinator + + if ($owner.ShutdownRequested -or $owner.Phase -eq 'ShuttingDown') { + $owner.ActiveSurface = $surface + $owner.SurfaceCloseRequested = $false + $owner.Phase = 'ShuttingDown' + Close-SnipActiveSurface -Coordinator $owner -Result Shutdown | Out-Null + return $false + } + + if ($null -ne $owner.PendingRequest -or + $null -eq $owner.ActiveRequest -or + $owner.ActiveRequest.Id -ne $surfaceContext.Request.Id) { + # Register only long enough to close the stale surface. Its + # transaction never exposes Selecting. + $owner.ActiveSurface = $surface + $owner.SurfaceCloseRequested = $false + Close-SnipActiveSurface -Coordinator $owner -Result Preempted | Out-Null + return $false + } + + # The current request publishes its closable surface and Selecting + # atomically on this UI-thread callback. + $owner.ActiveSurface = $surface + $owner.SurfaceCloseRequested = $false + $owner.Phase = 'Selecting' + return $true + }.GetNewClosure() + $overlayOutput = @(& $smartOverlayForCapture $surfaceReady $coordinator $request) + $overlayResult = if ($overlayOutput.Count) { $overlayOutput[-1] } else { $null } + if ($null -eq $overlayResult) { + return [pscustomobject][ordered]@{ + Result='UserCancelled'; Bitmap=$null; ErrorRecord=$null + } + } + $resultProperty = $overlayResult.PSObject.Properties['Result'] + $resultName = if ($null -ne $resultProperty) { + [string]$resultProperty.Value + } else { + 'UserCancelled' + } + if ($resultName -ne 'Completed') { return $overlayResult } + + $selectionProperty = $overlayResult.PSObject.Properties['Selection'] + $selection = if ($null -ne $selectionProperty) { + $selectionProperty.Value + } else { + $null + } + $validSelection = $null -ne $selection + if ($validSelection) { + foreach ($propertyName in 'X','Y','Width','Height') { + if ($null -eq $selection.PSObject.Properties[$propertyName]) { + $validSelection = $false + break + } + } + } + if ($validSelection) { + try { + $selectionBounds = [pscustomobject][ordered]@{ + X = [int]$selection.X + Y = [int]$selection.Y + Width = [int]$selection.Width + Height = [int]$selection.Height + } + $validSelection = Test-CaptureRectValid ` + -Width $selectionBounds.Width -Height $selectionBounds.Height + } catch { + $validSelection = $false + } + } + if (-not $validSelection) { + return [pscustomobject][ordered]@{ + Result='UserCancelled'; Bitmap=$null; ErrorRecord=$null + } + } + + # Overlay returns only physical geometry. The coordinator-owned + # capture service creates the fresh crop after all overlay HWNDs close. + & $captureBounds $selectionBounds + }.GetNewClosure() + + $preview = { + param($bitmap,$accept,$coordinator,$request) + $coordinatorForPreview = $coordinator + $surfaceReady = { + param($surface) + $coordinatorForPreview.ActiveSurface = $surface + $coordinatorForPreview.PreviewWindow = $surface.Window + $coordinatorForPreview.SurfaceCloseRequested = $false + }.GetNewClosure() + $newSnip = { + Request-SnipCapture -Coordinator $coordinatorForPreview ` + -Mode Smart -Source PreviewNew | Out-Null + }.GetNewClosure() + $outputStarting = { + param($kind) + if ($coordinatorForPreview.Phase -eq 'Previewing') { + $coordinatorForPreview.Phase = 'Completing' + } + }.GetNewClosure() + $outputCompleted = { + param($kind,$success) + $result = if ($success) { 'Completed' } else { 'UserCancelled' } + $operation = switch ($kind) { + 'CopyAndClose' { 'Copy' } + 'CopyKeepOpen' { 'CopyKeepOpen' } + default { 'Save' } + } + Complete-SnipSurface -Coordinator $coordinatorForPreview ` + -Result $result -Operation $operation | Out-Null + }.GetNewClosure() + Show-PreviewWindow -Bitmap $bitmap ` + -OnOwnershipAccepted $accept ` + -OnSurfaceReady $surfaceReady ` + -OnNewSnip $newSnip ` + -OnOutputStarting $outputStarting ` + -OnOutputCompleted $outputCompleted + }.GetNewClosure() + + $startDelay = { + param($delay,$callback,$request,$coordinator) + $timer = New-Object System.Windows.Forms.Timer + $timer.Interval = [math]::Max(1, [int][math]::Ceiling($delay.TotalMilliseconds)) + $timer.Add_Tick({ + $timer.Stop() + # The timer continuation re-enters the one public request API; + # Request-SnipCapture owns cancellation and disposal of this timer. + & $callback + }.GetNewClosure()) + $timer.Start() + $timer + }.GetNewClosure() + + $cancelDelay = { + param($timer) + try { $timer.Stop() } finally { $timer.Dispose() } + } + + [pscustomobject]@{ + SmartCapture = $smartCapture + FullCapture = $fullCapture + WindowCapture = $windowCapture + Preview = $preview + StartDelay = $startDelay + CancelDelay = $cancelDelay + } +} + +function Invoke-SmartCapture { + Request-SnipCapture -Coordinator $script:CaptureCoordinator ` + -Mode Smart -Source Legacy | Out-Null +} + +function Invoke-FullScreenCapture { + Request-SnipCapture -Coordinator $script:CaptureCoordinator ` + -Mode Full -Source Legacy | Out-Null +} + +function Invoke-WindowCapture { + Request-SnipCapture -Coordinator $script:CaptureCoordinator ` + -Mode Window -Source Legacy | Out-Null +} + +function Start-DelayedCapture { + [CmdletBinding()] + param([int]$Seconds, [ValidateSet('smart','full','window')] [string]$Type) + $plural = if ($Seconds -ne 1) { 's' } else { '' } + try { + $script:tray.BalloonTipTitle = 'SnipIT' + $script:tray.BalloonTipText = "Capturing ($Type) in $Seconds second$plural..." + $script:tray.ShowBalloonTip(1500) + } catch {} + Request-SnipCapture -Coordinator $script:CaptureCoordinator ` + -Mode $Type -Delay ([timespan]::FromSeconds($Seconds)) -Source TrayDelay | Out-Null +} diff --git a/src/40-Preview.ps1 b/src/40-Preview.ps1 new file mode 100644 index 0000000..be5d2b7 --- /dev/null +++ b/src/40-Preview.ps1 @@ -0,0 +1,3907 @@ +#requires -Version 7.5 +# Generated deterministically from the reviewed SnipIT.ps1 AST by scripts/Export-SnipITModules.ps1. + +$script:CurrentPreviewWindow = $null + +function New-SnipPreviewContext { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Drawing.Bitmap]$Bitmap, + [System.Windows.Media.Imaging.BitmapSource]$BitmapSource, + $CaptureBounds, + [object[]]$MonitorDescriptors, + [System.Windows.ResourceDictionary]$Resources, + [scriptblock]$RegisterWindow = { param($hwnd) Register-SelfWindowHandle -Hwnd $hwnd }, + [scriptblock]$UnregisterWindow = { param($hwnd) Unregister-SelfWindowHandle -Hwnd $hwnd }, + [scriptblock]$SetWindowPosition = { + param($hwnd, $bounds) + [Native]::SetWindowPos( + $hwnd, [IntPtr]::Zero, + [int]$bounds.X, [int]$bounds.Y, + [int]$bounds.Width, [int]$bounds.Height, + [Native]::SWP_NOZORDER -bor [Native]::SWP_NOACTIVATE) + } + ) + + if ($null -eq $Resources) { + $Resources = New-SnipThemeResources ` + -HighContrast ([bool][System.Windows.SystemParameters]::HighContrast) + } + if ($null -eq $MonitorDescriptors -or $MonitorDescriptors.Count -eq 0) { + $MonitorDescriptors = @(Get-SnipMonitorDescriptors) + } + if ($null -eq $BitmapSource) { + $BitmapSource = Convert-BitmapToBitmapSource $Bitmap + } + if (-not $BitmapSource.IsFrozen -and $BitmapSource.CanFreeze) { + $BitmapSource.Freeze() + } + if ($null -eq $CaptureBounds) { + $tag = $Bitmap.Tag + if ($null -ne $tag -and + $null -ne $tag.PSObject.Properties['X'] -and + $null -ne $tag.PSObject.Properties['Y'] -and + $null -ne $tag.PSObject.Properties['Width'] -and + $null -ne $tag.PSObject.Properties['Height']) { + $CaptureBounds = $tag + } elseif ($MonitorDescriptors.Count -gt 0) { + $fallback = @($MonitorDescriptors | Where-Object IsPrimary | Select-Object -First 1) + if ($fallback.Count -eq 0) { $fallback = @($MonitorDescriptors[0]) } + $CaptureBounds = [pscustomobject]@{ + X = [double]$fallback[0].X + Y = [double]$fallback[0].Y + Width = [double]$fallback[0].Width + Height = [double]$fallback[0].Height + } + } else { + $CaptureBounds = [pscustomobject]@{ X=0; Y=0; Width=$Bitmap.Width; Height=$Bitmap.Height } + } + } + + $captureLeft = [double]$CaptureBounds.X + $captureTop = [double]$CaptureBounds.Y + $captureRight = $captureLeft + [double]$CaptureBounds.Width + $captureBottom = $captureTop + [double]$CaptureBounds.Height + $captureCenterX = $captureLeft + ([double]$CaptureBounds.Width / 2) + $captureCenterY = $captureTop + ([double]$CaptureBounds.Height / 2) + $captureMonitor = $null + $largestIntersection = -1.0 + foreach ($monitor in @($MonitorDescriptors)) { + $left = [double]$monitor.X + $top = [double]$monitor.Y + $right = $left + [double]$monitor.Width + $bottom = $top + [double]$monitor.Height + $intersectionWidth = [math]::Max(0.0, [math]::Min($captureRight, $right) - [math]::Max($captureLeft, $left)) + $intersectionHeight = [math]::Max(0.0, [math]::Min($captureBottom, $bottom) - [math]::Max($captureTop, $top)) + $intersection = $intersectionWidth * $intersectionHeight + if ($intersection -gt $largestIntersection) { + $captureMonitor = $monitor + $largestIntersection = $intersection + } + } + if ($null -eq $captureMonitor -and $MonitorDescriptors.Count -gt 0) { + $captureMonitor = $MonitorDescriptors[0] + } + + if ($null -ne $captureMonitor) { + $workX = if ($null -ne $captureMonitor.PSObject.Properties['WorkX']) { + [double]$captureMonitor.WorkX + } else { [double]$captureMonitor.X } + $workY = if ($null -ne $captureMonitor.PSObject.Properties['WorkY']) { + [double]$captureMonitor.WorkY + } else { [double]$captureMonitor.Y } + $workWidth = if ($null -ne $captureMonitor.PSObject.Properties['WorkWidth']) { + [double]$captureMonitor.WorkWidth + } else { [double]$captureMonitor.Width } + $workHeight = if ($null -ne $captureMonitor.PSObject.Properties['WorkHeight']) { + [double]$captureMonitor.WorkHeight + } else { [double]$captureMonitor.Height } + } else { + $workX = 0.0; $workY = 0.0; $workWidth = 1200.0; $workHeight = 700.0 + } + $dpiX = if ($null -ne $captureMonitor -and [double]$captureMonitor.DpiX -gt 0) { + [double]$captureMonitor.DpiX + } else { 96.0 } + $dpiY = if ($null -ne $captureMonitor -and [double]$captureMonitor.DpiY -gt 0) { + [double]$captureMonitor.DpiY + } else { 96.0 } + $scaleX = $dpiX / 96.0 + $scaleY = $dpiY / 96.0 + $initialPhysicalWidth = [math]::Min( + $workWidth, [math]::Round(1180.0 * $scaleX)) + $initialPhysicalHeight = [math]::Min( + $workHeight, [math]::Round(760.0 * $scaleY)) + $initialPhysicalWidth = [math]::Max( + [math]::Min($workWidth, [math]::Round(760.0 * $scaleX)), + $initialPhysicalWidth) + $initialPhysicalHeight = [math]::Max( + [math]::Min($workHeight, [math]::Round(540.0 * $scaleY)), + $initialPhysicalHeight) + $initialX = [math]::Max($workX, [math]::Min( + $captureCenterX - ($initialPhysicalWidth / 2), + $workX + $workWidth - $initialPhysicalWidth)) + $initialY = [math]::Max($workY, [math]::Min( + $captureCenterY - ($initialPhysicalHeight / 2), + $workY + $workHeight - $initialPhysicalHeight)) + $initialWidth = $initialPhysicalWidth / $scaleX + $initialHeight = $initialPhysicalHeight / $scaleY + + [pscustomobject][ordered]@{ + Bitmap = $Bitmap + BitmapSource = $BitmapSource + Annotations = [System.Collections.ArrayList]::new() + SelectedAnnotationId = $null + CropRectangle = $null + Draft = $null + Interaction = $null + AnnotationDraftClearCount = 0 + UndoStack = [System.Collections.Stack]::new() + RedoStack = [System.Collections.Stack]::new() + ActiveTool = 'Select' + ToolProperties = [ordered]@{ + Crop = [pscustomobject]@{ Preset='Free' } + } + CaptureBounds = $CaptureBounds + CaptureMonitor = $captureMonitor + InitialBounds = [pscustomobject]@{ + X=($initialX / $scaleX); Y=($initialY / $scaleY) + Width=$initialWidth; Height=$initialHeight + } + InitialPhysicalBounds = [pscustomobject]@{ + X=[int][math]::Round($initialX) + Y=[int][math]::Round($initialY) + Width=[int][math]::Round($initialPhysicalWidth) + Height=[int][math]::Round($initialPhysicalHeight) + } + Resources = $Resources + RegisterWindow = $RegisterWindow + UnregisterWindow = $UnregisterWindow + SetWindowPosition = $SetWindowPosition + GetKeyboardModifiers = { [System.Windows.Input.Keyboard]::Modifiers } + ClipboardSetter = { param($image) [System.Windows.Clipboard]::SetImage($image) } + SaveBitmap = { param($image) Save-CaptureToFile -Bitmap $image } + RetryDelay = { param($milliseconds) [System.Threading.Thread]::Sleep($milliseconds) } + PlacementState = [pscustomobject]@{ + HandlersAttached=$false + IsApplied=$false + } + Window = $null + Chrome = $null + Shell = $null + EditorState = $null + PropertyControls = [ordered]@{} + PropertyBindings = [System.Collections.ArrayList]::new() + CropAspectMenuControl = $null + SelectCropPreset = $null + ApplyCrop = $null + ResetCrop = $null + CancelDraft = $null + DeleteSelection = $null + DuplicateSelection = $null + LastHitRoute = $null + EditingProperty = $false + ApplySelectionProperty = $null + ModeState = [pscustomobject]@{ Value = 'Wide' } + ActivePropertyTool = 'Select' + RecentTools = [System.Collections.ArrayList]@('Highlight','ArrowLine') + ToolMetadata = [ordered]@{ + Select=[pscustomobject]@{ Icon='↖'; DisplayName='Select' } + Crop=[pscustomobject]@{ Icon='⌗'; DisplayName='Crop' } + Pen=[pscustomobject]@{ Icon='✎'; DisplayName='Pen' } + Highlight=[pscustomobject]@{ Icon='▰'; DisplayName='Highlight' } + ArrowLine=[pscustomobject]@{ Icon='↗'; DisplayName='Arrow/Line' } + RectangleEllipse=[pscustomobject]@{ Icon='□'; DisplayName='Rectangle/Ellipse' } + Text=[pscustomobject]@{ Icon='T'; DisplayName='Text' } + Steps=[pscustomobject]@{ Icon='①'; DisplayName='Steps' } + BlurPixelate=[pscustomobject]@{ Icon='▒'; DisplayName='Blur/Pixelate' } + Color=[pscustomobject]@{ Icon='●'; DisplayName='Color' } + } + ToolControls = [ordered]@{} + ToolOrder = [System.Collections.ArrayList]::new() + MoreState = [pscustomobject]@{ + IsActive=$false; Tool=$null; Name='More'; Icon='…' + } + StatusState = [pscustomobject]@{ Kind='Idle'; Text='' } + SetStatus = $null + ClearStatus = $null + PropertyState = [pscustomobject]@{ + Tool='Select'; Visible=[string[]]@(); Overflow=[string[]]@() + HorizontalScrollBarVisibility=[System.Windows.Controls.ScrollBarVisibility]::Disabled + } + SplitControls = [ordered]@{} + TransientMenus = [System.Collections.ArrayList]::new() + PropertyMenuControl = $null + AnnotationMenuControl = $null + CommandRouter = $null + RoutePreviewKeyDown = $null + PopupRouteMenu = $null + } +} + +function Set-SnipPreviewMenuStyle { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Windows.Controls.ContextMenu]$Menu, + [Parameter(Mandatory)] $Context + ) + + Add-SnipThemeResources -Root $Menu -Resources $Context.Resources | Out-Null + $Menu.Style = $Context.Resources['SnipContextMenuStyle'] + $Menu.Background = $Context.Resources['SnipCanvasBrush'] + $Menu.Foreground = $Context.Resources['SnipPrimaryTextBrush'] + $Menu.BorderBrush = $Context.Resources['SnipHairlineBrush'] + $Menu.BorderThickness = [System.Windows.Thickness]::new(1) + $Menu.Padding = [System.Windows.Thickness]::new(2) + $implicitScrollStyle = [System.Windows.Style]::new( + [System.Windows.Controls.Primitives.ScrollBar]) + $implicitScrollStyle.BasedOn = $Context.Resources['SnipMenuScrollBarStyle'] + $Menu.Resources[[System.Windows.Controls.Primitives.ScrollBar]] = $implicitScrollStyle + $style = $Context.Resources['SnipMenuItemStyle'] + $pending = [System.Collections.Generic.Queue[System.Windows.Controls.MenuItem]]::new() + $menuItems = [System.Collections.ArrayList]::new() + foreach ($rootItem in @($Menu.Items)) { + if ($rootItem -is [System.Windows.Controls.MenuItem]) { + $pending.Enqueue($rootItem) + } + } + while ($pending.Count -gt 0) { + $item = $pending.Dequeue() + $menuItems.Add($item) | Out-Null + Add-SnipThemeResources -Root $item -Resources $Context.Resources | Out-Null + $item.ClearValue([System.Windows.Controls.Control]::BackgroundProperty) + $item.ClearValue([System.Windows.Controls.Control]::ForegroundProperty) + $item.ClearValue([System.Windows.Controls.Control]::BorderBrushProperty) + $item.Style = $style + foreach ($childItem in @($item.Items)) { + if ($childItem -is [System.Windows.Controls.MenuItem]) { + $pending.Enqueue($childItem) + } + } + } + + $existingLifecycle = $Menu.Tag + if ($null -ne $existingLifecycle -and + $null -ne $existingLifecycle.PSObject.Properties['Kind'] -and + $existingLifecycle.Kind -eq 'SnipPreviewMenuLifecycle' -and + $existingLifecycle.HandlersAttached) { + return $Menu + } + + $nestedStates = [System.Collections.ArrayList]::new() + $lifecycle = [pscustomobject][ordered]@{ + Kind = 'SnipPreviewMenuLifecycle' + Menu = $Menu + NestedStates = $nestedStates + CloseNestedPopups = $null + Disconnect = $null + WindowClosedHandler = $null + PopupKeyRouteHandler = $null + PopupKeyRouteAttached = $false + PopupKeyRouteAttachCount = 0 + PopupKeyRouteInvocationCount = 0 + LastPopupKeyOrigin = $null + LastPopupRouteCommand = $null + LastPopupRouteHandled = $false + HandlersAttached = $true + } + $popupKeyRouteHandler = [System.Windows.Input.KeyEventHandler]({ + param($sender,$eventArgs) + $lifecycle.PopupKeyRouteInvocationCount++ + $lifecycle.LastPopupKeyOrigin = $eventArgs.OriginalSource + $previousRouteMenu = $Context.PopupRouteMenu + $Context.PopupRouteMenu = $Menu + try { + if ($null -ne $Context.RoutePreviewKeyDown) { + & $Context.RoutePreviewKeyDown $eventArgs + } + $lifecycle.LastPopupRouteCommand = if ($null -ne $Context.CommandRouter) { + $Context.CommandRouter.LastCommand + } else { $null } + } finally { + $lifecycle.LastPopupRouteHandled = [bool]$eventArgs.Handled + $Context.PopupRouteMenu = $previousRouteMenu + } + }.GetNewClosure()) + $lifecycle.PopupKeyRouteHandler = $popupKeyRouteHandler + $Menu.AddHandler( + [System.Windows.Input.Keyboard]::PreviewKeyDownEvent, + $popupKeyRouteHandler, + $true) + $lifecycle.PopupKeyRouteAttached = $true + $lifecycle.PopupKeyRouteAttachCount++ + $unregisterNested = { + param($NestedState) + if ($NestedState.IsRegistered -and $NestedState.Handle -ne [IntPtr]::Zero) { + & $Context.UnregisterWindow $NestedState.Handle + } + $NestedState.Handle = [IntPtr]::Zero + $NestedState.IsRegistered = $false + }.GetNewClosure() + foreach ($menuItem in @($menuItems)) { + $menuItem.ApplyTemplate() | Out-Null + $popup = $menuItem.Template.FindName('PART_Popup',$menuItem) + if ($popup -isnot [System.Windows.Controls.Primitives.Popup]) { continue } + $popup.PopupAnimation = [System.Windows.Controls.Primitives.PopupAnimation]( + $Context.Resources['SnipPopupAnimation']) + $nestedState = [pscustomobject][ordered]@{ + Item = $menuItem + Popup = $popup + Handle = [IntPtr]::Zero + IsRegistered = $false + OpenedHandler = $null + ClosedHandler = $null + HandlersAttached = $true + } + $popupForHandler = $popup + $stateForHandler = $nestedState + $openedHandler = { + $source = if ($null -ne $popupForHandler.Child) { + [System.Windows.PresentationSource]::FromVisual($popupForHandler.Child) + } else { $null } + if ($source -is [System.Windows.Interop.HwndSource] -and + $source.Handle -ne [IntPtr]::Zero -and + -not $stateForHandler.IsRegistered) { + & $Context.RegisterWindow $source.Handle + $stateForHandler.Handle = $source.Handle + $stateForHandler.IsRegistered = $true + } + }.GetNewClosure() + $closedHandler = { + & $unregisterNested $stateForHandler + }.GetNewClosure() + $nestedState.OpenedHandler = $openedHandler + $nestedState.ClosedHandler = $closedHandler + $popup.Tag = $nestedState + $popup.Add_Opened($openedHandler) + $popup.Add_Closed($closedHandler) + $nestedStates.Add($nestedState) | Out-Null + } + $closeNestedPopups = { + for ($index = $nestedStates.Count - 1; $index -ge 0; $index--) { + $nestedState = $nestedStates[$index] + if ($nestedState.Item.IsSubmenuOpen) { + $nestedState.Item.IsSubmenuOpen = $false + } + & $unregisterNested $nestedState + } + }.GetNewClosure() + $disconnect = { + & $closeNestedPopups + if ($lifecycle.PopupKeyRouteAttached) { + $Menu.RemoveHandler( + [System.Windows.Input.Keyboard]::PreviewKeyDownEvent, + $lifecycle.PopupKeyRouteHandler) + $lifecycle.PopupKeyRouteAttached = $false + } + foreach ($nestedState in @($nestedStates)) { + if (-not $nestedState.HandlersAttached) { continue } + $nestedState.Popup.Remove_Opened($nestedState.OpenedHandler) + $nestedState.Popup.Remove_Closed($nestedState.ClosedHandler) + $nestedState.HandlersAttached = $false + } + if ($null -ne $Context.Window -and + $null -ne $lifecycle.WindowClosedHandler) { + $Context.Window.Remove_Closed($lifecycle.WindowClosedHandler) + } + $lifecycle.HandlersAttached = $false + }.GetNewClosure() + $lifecycle.CloseNestedPopups = $closeNestedPopups + $lifecycle.Disconnect = $disconnect + if ($null -ne $Context.Window) { + $windowClosedHandler = { & $lifecycle.Disconnect }.GetNewClosure() + $lifecycle.WindowClosedHandler = $windowClosedHandler + $Context.Window.Add_Closed($windowClosedHandler) + } + $Menu.Tag = $lifecycle + $Menu +} + +function Connect-SnipPreviewMenuButton { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Windows.Controls.Button]$Button, + [Parameter(Mandatory)] [System.Windows.Controls.ContextMenu]$Menu, + [Parameter(Mandatory)] $Context, + [Parameter(Mandatory)] [string]$Name + ) + + Set-SnipPreviewMenuStyle -Menu $Menu -Context $Context | Out-Null + $Menu.PlacementTarget = $Button + $Button.ContextMenu = $Menu + [System.Windows.Automation.AutomationProperties]::SetItemStatus($Button, 'Collapsed') + $state = [pscustomobject]@{ + IsExpanded=$false; MenuHandle=[IntPtr]::Zero; IsMenuWindowRegistered=$false + HandlersAttached=$false + } + $open = { + $Menu.PlacementTarget = $Button + $Menu.IsOpen = $true + }.GetNewClosure() + $close = { + if ($null -ne $Menu.Tag -and $null -ne $Menu.Tag.CloseNestedPopups) { + & $Menu.Tag.CloseNestedPopups + } + if ($Menu.IsOpen) { $Menu.IsOpen = $false } + if ($state.IsMenuWindowRegistered -and $state.MenuHandle -ne [IntPtr]::Zero) { + & $Context.UnregisterWindow $state.MenuHandle + } + $state.IsExpanded = $false + $state.MenuHandle = [IntPtr]::Zero + $state.IsMenuWindowRegistered = $false + [System.Windows.Automation.AutomationProperties]::SetItemStatus($Button, 'Collapsed') + try { $Button.Focus() | Out-Null } catch {} + }.GetNewClosure() + $openedHandler = { + $state.IsExpanded = $true + [System.Windows.Automation.AutomationProperties]::SetItemStatus($Button, 'Expanded') + $source = [System.Windows.PresentationSource]::FromVisual($Menu) + if ($source -is [System.Windows.Interop.HwndSource] -and + $source.Handle -ne [IntPtr]::Zero -and -not $state.IsMenuWindowRegistered) { + & $Context.RegisterWindow $source.Handle + $state.MenuHandle = $source.Handle + $state.IsMenuWindowRegistered = $true + } + }.GetNewClosure() + $closedHandler = { & $close }.GetNewClosure() + $buttonClickHandler = { & $open }.GetNewClosure() + $disconnect = $null + $windowClosedHandler = $null + $disconnect = { + & $close + if ($state.HandlersAttached) { + $Menu.Remove_Opened($openedHandler) + $Menu.Remove_Closed($closedHandler) + $Button.Remove_Click($buttonClickHandler) + if ($null -ne $Context.Window -and $null -ne $windowClosedHandler) { + $Context.Window.Remove_Closed($windowClosedHandler) + } + $state.HandlersAttached = $false + } + if ($null -ne $Menu.Tag -and $null -ne $Menu.Tag.Disconnect) { + & $Menu.Tag.Disconnect + } + }.GetNewClosure() + $windowClosedHandler = { & $disconnect }.GetNewClosure() + $Menu.Add_Opened($openedHandler) + $Menu.Add_Closed($closedHandler) + $Button.Add_Click($buttonClickHandler) + if ($null -ne $Context.Window) { $Context.Window.Add_Closed($windowClosedHandler) } + $state.HandlersAttached = $true + $control = [pscustomobject]@{ + Name=$Name; State=$state; Menu=$Menu; Button=$Button + OpenOptions=$open; CloseOptions=$close; Disconnect=$disconnect + } + $Context.TransientMenus.Add($control) | Out-Null + $control +} + +function Connect-SnipPreviewTransientContextMenu { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Windows.Controls.ContextMenu]$Menu, + [Parameter(Mandatory)] [System.Windows.FrameworkElement]$PlacementTarget, + [Parameter(Mandatory)] $Context, + [Parameter(Mandatory)] [string]$Name, + [scriptblock]$Cleanup = {} + ) + + Set-SnipPreviewMenuStyle -Menu $Menu -Context $Context | Out-Null + $Menu.PlacementTarget = $PlacementTarget + $state = [pscustomobject][ordered]@{ + IsExpanded = $false + MenuHandle = [IntPtr]::Zero + IsMenuWindowRegistered = $false + HandlersAttached = $false + CleanupRan = $false + } + $open = { + $Menu.PlacementTarget = $PlacementTarget + $Menu.IsOpen = $true + }.GetNewClosure() + $close = { + if ($null -ne $Menu.Tag -and $null -ne $Menu.Tag.CloseNestedPopups) { + & $Menu.Tag.CloseNestedPopups + } + if ($Menu.IsOpen) { $Menu.IsOpen = $false } + if ($state.IsMenuWindowRegistered -and $state.MenuHandle -ne [IntPtr]::Zero) { + & $Context.UnregisterWindow $state.MenuHandle + } + $state.IsExpanded = $false + $state.MenuHandle = [IntPtr]::Zero + $state.IsMenuWindowRegistered = $false + try { $PlacementTarget.Focus() | Out-Null } catch {} + }.GetNewClosure() + $registerMenuWindow = { + if (-not $Menu.IsOpen) { return } + $source = [System.Windows.PresentationSource]::FromVisual($Menu) + if ($source -is [System.Windows.Interop.HwndSource] -and + $source.Handle -ne [IntPtr]::Zero -and + -not $state.IsMenuWindowRegistered) { + & $Context.RegisterWindow $source.Handle + $state.MenuHandle = $source.Handle + $state.IsMenuWindowRegistered = $true + } + }.GetNewClosure() + $openedHandler = { + $state.IsExpanded = $true + & $registerMenuWindow + if (-not $state.IsMenuWindowRegistered) { + $retryRegistration = [Action]{ & $registerMenuWindow }.GetNewClosure() + [void]$Menu.Dispatcher.BeginInvoke( + [System.Windows.Threading.DispatcherPriority]::Render, + $retryRegistration) + } + }.GetNewClosure() + $closedHandler = { & $close }.GetNewClosure() + $disconnect = $null + $windowClosedHandler = $null + $disconnect = { + & $close + if ($state.HandlersAttached) { + $Menu.Remove_Opened($openedHandler) + $Menu.Remove_Closed($closedHandler) + if ($null -ne $Context.Window -and $null -ne $windowClosedHandler) { + $Context.Window.Remove_Closed($windowClosedHandler) + } + $state.HandlersAttached = $false + } + if ($null -ne $Menu.Tag -and $null -ne $Menu.Tag.Disconnect) { + & $Menu.Tag.Disconnect + } + if (-not $state.CleanupRan) { + & $Cleanup + $state.CleanupRan = $true + } + }.GetNewClosure() + $windowClosedHandler = { & $disconnect }.GetNewClosure() + $Menu.Add_Opened($openedHandler) + $Menu.Add_Closed($closedHandler) + if ($null -ne $Context.Window) { $Context.Window.Add_Closed($windowClosedHandler) } + $state.HandlersAttached = $true + $control = [pscustomobject][ordered]@{ + Name = $Name + State = $state + Menu = $Menu + PlacementTarget = $PlacementTarget + OpenOptions = $open + CloseOptions = $close + Disconnect = $disconnect + } + $Context.TransientMenus.Add($control) | Out-Null + $control +} + +function New-SnipSplitControl { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string]$Name, + [Parameter(Mandatory)] [string]$DefaultCommand, + [Parameter(Mandatory)] [string]$PrimaryText, + [Parameter(Mandatory)] [string]$PrimaryAutomationName, + [Parameter(Mandatory)] [string]$OptionsAutomationName, + [Parameter(Mandatory)] [System.Collections.IDictionary]$Options, + [Parameter(Mandatory)] $Context, + [string]$Glyph = '', + [double]$PrimaryWidth = 38, + [double]$OptionsWidth = 22, + [scriptblock]$PrimaryAction = {} + ) + + $root = [System.Windows.Controls.Border]::new() + $root.CornerRadius = [System.Windows.CornerRadius]::new(10) + $root.BorderThickness = [System.Windows.Thickness]::new(1) + $root.Margin = [System.Windows.Thickness]::new(2,0,2,0) + $root.SetResourceReference( + [System.Windows.Controls.Border]::BorderBrushProperty, 'SnipHairlineBrush') + $root.SetResourceReference( + [System.Windows.Controls.Border]::BackgroundProperty, 'SnipGlassGradientBrush') + + $grid = [System.Windows.Controls.Grid]::new() + $grid.ColumnDefinitions.Add([System.Windows.Controls.ColumnDefinition]::new()) + $grid.ColumnDefinitions.Add([System.Windows.Controls.ColumnDefinition]::new()) + $grid.ColumnDefinitions[0].Width = [System.Windows.GridLength]::new($PrimaryWidth) + $grid.ColumnDefinitions[1].Width = [System.Windows.GridLength]::new($OptionsWidth) + $root.Child = $grid + + $primaryButton = [System.Windows.Controls.Button]::new() + $primaryButton.Width = $PrimaryWidth + $primaryButton.Height = 38 + $primaryButton.Padding = [System.Windows.Thickness]::new(4,0,4,0) + $primaryButton.IsTabStop = $true + $primaryButton.SetResourceReference( + [System.Windows.FrameworkElement]::StyleProperty, 'SnipButtonStyle') + [System.Windows.Automation.AutomationProperties]::SetName( + $primaryButton, $PrimaryAutomationName) + $primaryButton.ToolTip = if ($Name -eq 'CopyAndClose') { + 'Copy and close (Ctrl+Enter)' + } else { "$PrimaryAutomationName (Enter)" } + [System.Windows.Controls.Grid]::SetColumn($primaryButton, 0) + + $primaryContent = [System.Windows.Controls.StackPanel]::new() + $primaryContent.Orientation = [System.Windows.Controls.Orientation]::Horizontal + $primaryContent.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center + if (-not [string]::IsNullOrEmpty($Glyph)) { + $icon = [System.Windows.Controls.TextBlock]::new() + $icon.Text = $Glyph + $icon.FontFamily = [System.Windows.Media.FontFamily]::new('Segoe Fluent Icons') + $icon.Margin = [System.Windows.Thickness]::new(0,0,5,0) + $primaryContent.Children.Add($icon) | Out-Null + } + $label = [System.Windows.Controls.TextBlock]::new() + $label.Text = $PrimaryText + $label.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + $primaryContent.Children.Add($label) | Out-Null + $primaryButton.Content = $primaryContent + + $optionsButton = [System.Windows.Controls.Button]::new() + $optionsButton.Width = $OptionsWidth + $optionsButton.Height = 38 + $optionsButton.Padding = [System.Windows.Thickness]::new(0) + $optionsButton.Content = [char]0xE70D + $optionsButton.FontFamily = [System.Windows.Media.FontFamily]::new('Segoe Fluent Icons') + $optionsButton.IsTabStop = $true + $optionsButton.SetResourceReference( + [System.Windows.FrameworkElement]::StyleProperty, 'SnipButtonStyle') + [System.Windows.Automation.AutomationProperties]::SetName( + $optionsButton, $OptionsAutomationName) + $optionsButton.ToolTip = switch ($Name) { + 'CopyAndClose' { 'Copy options (Alt+Down)' } + 'ArrowLine' { 'Arrow/Line options (Alt+Down)' } + 'RectangleEllipse' { 'Rectangle/Ellipse options (Alt+Down)' } + 'BlurPixelate' { 'Blur/Pixelate options (Alt+Down)' } + default { "$OptionsAutomationName (Alt+Down)" } + } + [System.Windows.Automation.AutomationProperties]::SetItemStatus( + $optionsButton, 'Collapsed') + [System.Windows.Controls.Grid]::SetColumn($optionsButton, 1) + $grid.Children.Add($primaryButton) | Out-Null + $grid.Children.Add($optionsButton) | Out-Null + + $menu = [System.Windows.Controls.ContextMenu]::new() + $menuItems = [ordered]@{} + foreach ($optionName in @($Options.Keys)) { + $item = [System.Windows.Controls.MenuItem]::new() + $item.Header = switch ([string]$optionName) { + 'CopyKeepOpen' { 'Copy and keep open' } + 'Arrow' { 'Arrow' } + 'Line' { 'Line' } + 'Rectangle' { 'Rectangle' } + 'Ellipse' { 'Ellipse' } + 'Blur' { 'Blur' } + 'Pixelate' { 'Pixelate' } + default { [string]$optionName } + } + [System.Windows.Automation.AutomationProperties]::SetName($item, [string]$item.Header) + $callback = $Options[$optionName] + $item.Add_Click({ & $callback }.GetNewClosure()) + $menu.Items.Add($item) | Out-Null + $menuItems[[string]$optionName] = $item + } + Set-SnipPreviewMenuStyle -Menu $menu -Context $Context | Out-Null + $menu.PlacementTarget = $optionsButton + + $state = [pscustomobject]@{ + IsExpanded = $false + MenuHandle = [IntPtr]::Zero + IsMenuWindowRegistered = $false + LastOpener = $optionsButton + HandlersAttached = $false + } + $registerWindow = $Context.RegisterWindow + $unregisterWindow = $Context.UnregisterWindow + $openOptions = { + param($opener) + if ($null -eq $opener) { $opener = $optionsButton } + $state.LastOpener = $opener + $menu.PlacementTarget = $opener + $menu.IsOpen = $true + }.GetNewClosure() + $closeOptions = { + if ($null -ne $menu.Tag -and $null -ne $menu.Tag.CloseNestedPopups) { + & $menu.Tag.CloseNestedPopups + } + if ($menu.IsOpen) { $menu.IsOpen = $false } + # ContextMenu.Closed is dispatcher-delayed on some WPF paths. Keep the + # registry and accessibility state synchronous for capture preflight. + if ($state.IsMenuWindowRegistered -and $state.MenuHandle -ne [IntPtr]::Zero) { + & $unregisterWindow $state.MenuHandle + } + $state.IsExpanded = $false + $state.MenuHandle = [IntPtr]::Zero + $state.IsMenuWindowRegistered = $false + [System.Windows.Automation.AutomationProperties]::SetItemStatus( + $optionsButton, 'Collapsed') + try { $state.LastOpener.Focus() | Out-Null } catch {} + }.GetNewClosure() + $handleKey = { + param([string]$Key, [bool]$Alt, $Opener) + if ($Alt -and $Key -eq 'Down') { + & $openOptions $Opener + return $true + } + if ($Key -eq 'Escape' -and $state.IsExpanded) { & $closeOptions; return $true } + if (-not $state.IsExpanded) { return $false } + $items = @($menu.Items | Where-Object { $_ -is [System.Windows.Controls.MenuItem] }) + if ($items.Count -eq 0) { return $false } + switch ($Key) { + 'Home' { $items[0].Focus() | Out-Null; return $true } + 'End' { $items[-1].Focus() | Out-Null; return $true } + 'Down' { $items[0].Focus() | Out-Null; return $true } + 'Up' { $items[-1].Focus() | Out-Null; return $true } + 'Enter' { + $focused = [System.Windows.Input.Keyboard]::FocusedElement + if ($focused -is [System.Windows.Controls.MenuItem]) { + $focused.RaiseEvent([System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.MenuItem]::ClickEvent)) + return $true + } + } + } + return $false + }.GetNewClosure() + + $menuOpenedHandler = { + $state.IsExpanded = $true + [System.Windows.Automation.AutomationProperties]::SetItemStatus( + $optionsButton, 'Expanded') + $source = [System.Windows.PresentationSource]::FromVisual($menu) + if ($source -is [System.Windows.Interop.HwndSource]) { + $handle = $source.Handle + if ($handle -ne [IntPtr]::Zero -and -not $state.IsMenuWindowRegistered) { + & $registerWindow $handle + $state.MenuHandle = $handle + $state.IsMenuWindowRegistered = $true + } + } + }.GetNewClosure() + $menuClosedHandler = { + try { + if ($state.IsMenuWindowRegistered -and $state.MenuHandle -ne [IntPtr]::Zero) { + & $unregisterWindow $state.MenuHandle + } + } finally { + $state.IsExpanded = $false + $state.MenuHandle = [IntPtr]::Zero + $state.IsMenuWindowRegistered = $false + [System.Windows.Automation.AutomationProperties]::SetItemStatus( + $optionsButton, 'Collapsed') + try { $state.LastOpener.Focus() | Out-Null } catch {} + } + }.GetNewClosure() + $menu.Add_Opened($menuOpenedHandler) + $menu.Add_Closed($menuClosedHandler) + $primaryClickHandler = { & $PrimaryAction }.GetNewClosure() + $primaryButton.Add_Click($primaryClickHandler) + $optionsClickHandler = { & $openOptions $optionsButton }.GetNewClosure() + $optionsButton.Add_Click($optionsClickHandler) + $splitKeyDownHandler = { + $modifiers = & $Context.GetKeyboardModifiers + $alt = ($modifiers -band + [System.Windows.Input.ModifierKeys]::Alt) -ne 0 + if (& $handleKey ([string]$_.Key) $alt $_.Source) { + $_.Handled = $true + } + }.GetNewClosure() + $primaryButton.Add_PreviewKeyDown($splitKeyDownHandler) + $optionsButton.Add_PreviewKeyDown($splitKeyDownHandler) + $splitClosedHandler = { + & $closeOptions + $menu.Remove_Opened($menuOpenedHandler) + $menu.Remove_Closed($menuClosedHandler) + $primaryButton.Remove_Click($primaryClickHandler) + $primaryButton.Remove_PreviewKeyDown($splitKeyDownHandler) + $optionsButton.Remove_PreviewKeyDown($splitKeyDownHandler) + $optionsButton.Remove_Click($optionsClickHandler) + if ($null -ne $menu.Tag -and $null -ne $menu.Tag.Disconnect) { + & $menu.Tag.Disconnect + } + $Context.Window.Remove_Closed($splitClosedHandler) + $state.HandlersAttached = $false + }.GetNewClosure() + $Context.Window.Add_Closed($splitClosedHandler) + $state.HandlersAttached = $true + + $split = [pscustomobject][ordered]@{ + Name = $Name + DefaultCommand = $DefaultCommand + OptionOrder = [string[]]@($Options.Keys) + Root = $root + PrimaryButton = $primaryButton + OptionsButton = $optionsButton + Menu = $menu + MenuItems = $menuItems + Label = $label + State = $state + OpenOptions = $openOptions + CloseOptions = $closeOptions + HandleKey = $handleKey + } + $root.Tag = $split + $Context.TransientMenus.Add($split) | Out-Null + $split +} + +function Set-SnipPropertyIsland { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Context, + [Parameter(Mandatory)] + [ValidateSet('Select','Crop','Pen','Highlight','ArrowLine','RectangleEllipse','Text','Steps','BlurPixelate')] + [string]$Tool + ) + + $definitions = [ordered]@{ + Select = @('Position','Size','Duplicate','Delete') + Crop = @('Aspect','Reset','Apply') + Pen = @('Color','Width','Opacity') + Highlight = @('Color','Width','Opacity') + ArrowLine = @('Color','Width','Opacity','StartStyle','EndStyle') + RectangleEllipse = @('StrokeColor','Fill','Width','Opacity') + Text = @('Color','FontSize','Weight','Background') + Steps = @('Color','Size','ResetSequence') + BlurPixelate = @('Mode','Strength','Feathering') + } + $Context.ActivePropertyTool = $Tool + foreach ($binding in @($Context.PropertyBindings)) { + $bindingEvent = if ($null -ne $binding.PSObject.Properties['Event']) { + [string]$binding.Event + } else { 'Click' } + switch ($bindingEvent) { + 'GotKeyboardFocus' { $binding.Target.Remove_GotKeyboardFocus($binding.Handler) } + 'LostKeyboardFocus' { $binding.Target.Remove_LostKeyboardFocus($binding.Handler) } + 'KeyDown' { $binding.Target.Remove_KeyDown($binding.Handler) } + default { $binding.Target.Remove_Click($binding.Handler) } + } + } + $Context.PropertyBindings.Clear() + foreach ($controlName in @('PropertyMenuControl','CropAspectMenuControl')) { + $control = $Context.$controlName + if ($null -eq $control) { continue } + if ($null -ne $control.Disconnect) { & $control.Disconnect } + try { & $control.CloseOptions } catch {} + [void]$Context.TransientMenus.Remove($control) + $Context.$controlName = $null + } + $Context.PropertyControls.Clear() + $hasSelection = $null -ne $Context.SelectedAnnotationId + if ($Tool -eq 'Select' -and -not $hasSelection) { + $Context.PropertyState.Tool = 'Select' + $Context.PropertyState.Visible = [string[]]@() + $Context.PropertyState.Overflow = [string[]]@() + if ($null -ne $Context.Shell -and $null -ne $Context.Shell.PropertyIsland) { + $Context.Shell.PropertyPanel.Children.Clear() + $Context.Shell.PropertyIsland.Visibility = [System.Windows.Visibility]::Collapsed + } + return $Context.PropertyState + } + if ($null -ne $Context.Shell -and $null -ne $Context.Shell.PropertyIsland) { + $Context.Shell.PropertyIsland.Visibility = [System.Windows.Visibility]::Visible + } + $mode = [string]$Context.ModeState.Value + $all = [string[]]@($definitions[$Tool]) + [string[]]$visible = @() + [string[]]$overflow = @() + if ($mode -eq 'Wide') { + $visible = $all + $overflow = @() + } else { + $primaryCount = [math]::Min(2, $all.Count) + $visible = @($all[0..($primaryCount - 1)]) + $overflow = if ($all.Count -gt $primaryCount) { + @($all[$primaryCount..($all.Count - 1)]) + } else { @() } + if ($overflow.Count -gt 0) { $visible = @($visible + 'MoreProperties') } + } + $Context.PropertyState.Tool = $Tool + $Context.PropertyState.Visible = $visible + $Context.PropertyState.Overflow = $overflow + $panel = $Context.Shell.PropertyPanel + if ($null -ne $panel) { + $panel.Children.Clear() + foreach ($propertyName in $visible) { + if ($Tool -eq 'Select' -and $propertyName -in @('Position','Size')) { + $selectedRecords = @($Context.Annotations | Where-Object { + [string]$_.Id -eq [string]$Context.SelectedAnnotationId + } | Select-Object -First 1) + $selectedRecord = if($selectedRecords.Count -gt 0){ + $selectedRecords[0] + }else{$null} + $geometry = if ($null -ne $selectedRecord) { + $sourceGeometry=$selectedRecord.Geometry + if($sourceGeometry.Type -in @('Bounds','TextBounds','StepBounds')){ + $sourceGeometry + }elseif($sourceGeometry.Type -eq 'Line'){ + [pscustomobject]@{ + X=[math]::Min($sourceGeometry.Start.X,$sourceGeometry.End.X) + Y=[math]::Min($sourceGeometry.Start.Y,$sourceGeometry.End.Y) + Width=[math]::Max(1,[math]::Abs($sourceGeometry.End.X-$sourceGeometry.Start.X)) + Height=[math]::Max(1,[math]::Abs($sourceGeometry.End.Y-$sourceGeometry.Start.Y)) + } + }elseif($sourceGeometry.Type -eq 'Points' -and @($sourceGeometry.Points).Count -gt 0){ + $xs=@($sourceGeometry.Points|ForEach-Object X) + $ys=@($sourceGeometry.Points|ForEach-Object Y) + $minimumX=($xs|Measure-Object -Minimum).Minimum + $minimumY=($ys|Measure-Object -Minimum).Minimum + [pscustomobject]@{ + X=$minimumX;Y=$minimumY + Width=[math]::Max(1,($xs|Measure-Object -Maximum).Maximum-$minimumX) + Height=[math]::Max(1,($ys|Measure-Object -Maximum).Maximum-$minimumY) + } + }else{$null} + } else { $null } + $editor = [System.Windows.Controls.TextBox]::new() + $editor.Height=36; $editor.MinWidth=96 + $editor.Margin=[System.Windows.Thickness]::new(2,0,2,0) + $editor.Padding=[System.Windows.Thickness]::new(8,6,8,4) + $editor.Background=$Context.Resources['SnipCanvasBrush'] + $editor.Foreground=$Context.Resources['SnipPrimaryTextBrush'] + $editor.BorderBrush=$Context.Resources['SnipHairlineBrush'] + $editor.BorderThickness=[System.Windows.Thickness]::new(1) + $editor.Text = if ($null -eq $geometry) { '' } elseif ($propertyName -eq 'Position') { + "$([int]$geometry.X), $([int]$geometry.Y)" + } else { "$([int]$geometry.Width) × $([int]$geometry.Height)" } + $editor.Tag=[pscustomobject]@{ + Role='PropertyEditor';Name=$propertyName;OriginalText=$editor.Text + } + [System.Windows.Automation.AutomationProperties]::SetName( + $editor,"Selected annotation $propertyName") + $commitProperty={ + if($Context.ApplySelectionProperty){ + & $Context.ApplySelectionProperty $propertyName $editor.Text + } + $Context.EditingProperty=$false + $editor.Tag.OriginalText=$editor.Text + }.GetNewClosure() + $focusHandler={ + $Context.EditingProperty=$true + $editor.Tag.OriginalText=$editor.Text + }.GetNewClosure() + $lostFocusHandler={ + if($Context.EditingProperty){& $commitProperty} + }.GetNewClosure() + $editorKeyHandler={ + if($_.Key -eq [System.Windows.Input.Key]::Enter){ + & $commitProperty; $_.Handled=$true + }elseif($_.Key -eq [System.Windows.Input.Key]::Escape){ + $editor.Text=[string]$editor.Tag.OriginalText + $Context.EditingProperty=$false + $_.Handled=$true + } + }.GetNewClosure() + $editor.Add_GotKeyboardFocus($focusHandler) + $editor.Add_LostKeyboardFocus($lostFocusHandler) + $editor.Add_KeyDown($editorKeyHandler) + foreach($bindingSpec in @( + [pscustomobject]@{Event='GotKeyboardFocus';Handler=$focusHandler}, + [pscustomobject]@{Event='LostKeyboardFocus';Handler=$lostFocusHandler}, + [pscustomobject]@{Event='KeyDown';Handler=$editorKeyHandler})){ + $Context.PropertyBindings.Add([pscustomobject]@{ + Target=$editor;Event=$bindingSpec.Event;Handler=$bindingSpec.Handler + })|Out-Null + } + $Context.PropertyControls[$propertyName]=[pscustomobject][ordered]@{ + Name=$propertyName;Element=$editor;Button=$null + Menu=$null;MenuItems=$null;Controller=$null + } + $panel.Children.Add($editor)|Out-Null + continue + } + $button = [System.Windows.Controls.Button]::new() + $button.Height = 36 + $button.MinWidth = if ($mode -eq 'Narrow') { 68 } else { 76 } + $button.Margin = [System.Windows.Thickness]::new(2,0,2,0) + $button.Padding = [System.Windows.Thickness]::new(8,4,8,4) + $button.Content = switch ($propertyName) { + 'MoreProperties' { 'More properties' } + 'StartStyle' { 'Start' } + 'EndStyle' { 'End' } + 'StrokeColor' { 'Stroke' } + 'ResetSequence' { 'Reset' } + default { $propertyName } + } + [System.Windows.Automation.AutomationProperties]::SetName( + $button, [string]$button.Content) + $button.SetResourceReference( + [System.Windows.FrameworkElement]::StyleProperty, 'SnipButtonStyle') + if ($propertyName -eq 'MoreProperties') { + $propertyMenu = [System.Windows.Controls.ContextMenu]::new() + foreach ($overflowName in $overflow) { + $propertyItem = [System.Windows.Controls.MenuItem]::new() + $propertyItem.Header = switch ($overflowName) { + 'StartStyle' { 'Start' } + 'EndStyle' { 'End' } + 'StrokeColor' { 'Stroke' } + 'ResetSequence' { 'Reset' } + default { $overflowName } + } + [System.Windows.Automation.AutomationProperties]::SetName( + $propertyItem, [string]$propertyItem.Header) + $propertyMenu.Items.Add($propertyItem) | Out-Null + $overflowEntry = [pscustomobject][ordered]@{ + Name=$overflowName; Element=$propertyItem; Button=$null + Menu=$null; MenuItems=$null; Controller=$null + } + $Context.PropertyControls[$overflowName] = $overflowEntry + if (($Tool -eq 'Crop' -and $overflowName -in @('Reset','Apply')) -or + ($Tool -eq 'Select' -and + $overflowName -in @('Duplicate','Delete'))) { + $semanticName = $overflowName + $semanticHandler = { + switch ("$Tool/$semanticName") { + 'Crop/Reset' { if ($Context.ResetCrop) { & $Context.ResetCrop } } + 'Crop/Apply' { if ($Context.ApplyCrop) { & $Context.ApplyCrop } } + 'Select/Duplicate' { if ($Context.DuplicateSelection) { & $Context.DuplicateSelection } } + 'Select/Delete' { if ($Context.DeleteSelection) { & $Context.DeleteSelection } } + } + if ($null -ne $Context.PropertyMenuControl) { + & $Context.PropertyMenuControl.CloseOptions + } + }.GetNewClosure() + $propertyItem.Add_Click($semanticHandler) + $Context.PropertyBindings.Add([pscustomobject]@{ + Target=$propertyItem; Handler=$semanticHandler + }) | Out-Null + } + } + $Context.PropertyMenuControl = Connect-SnipPreviewMenuButton ` + -Button $button -Menu $propertyMenu -Context $Context -Name MoreProperties + $Context.PropertyControls.MoreProperties = [pscustomobject][ordered]@{ + Name='MoreProperties'; Element=$button; Button=$button + Menu=$propertyMenu; MenuItems=$null + Controller=$Context.PropertyMenuControl + } + } elseif ($Tool -eq 'Crop' -and $propertyName -eq 'Aspect') { + $aspectMenu = [System.Windows.Controls.ContextMenu]::new() + $aspectItems = [ordered]@{} + foreach ($presetName in @('Free','Original','1:1','4:3','16:9')) { + $presetItem = [System.Windows.Controls.MenuItem]::new() + $presetItem.Header = $presetName + $presetItem.IsCheckable = $true + $presetItem.IsChecked = $Context.ToolProperties.Crop.Preset -eq $presetName + [System.Windows.Automation.AutomationProperties]::SetName( + $presetItem, "Crop aspect $presetName") + $selectedPreset = $presetName + $presetHandler = { + if ($Context.SelectCropPreset) { + & $Context.SelectCropPreset $selectedPreset + } + if ($null -ne $Context.CropAspectMenuControl) { + & $Context.CropAspectMenuControl.CloseOptions + } + }.GetNewClosure() + $presetItem.Add_Click($presetHandler) + $Context.PropertyBindings.Add([pscustomobject]@{ + Target=$presetItem; Handler=$presetHandler + }) | Out-Null + $aspectMenu.Items.Add($presetItem) | Out-Null + $aspectItems[$presetName] = $presetItem + } + $Context.CropAspectMenuControl = Connect-SnipPreviewMenuButton ` + -Button $button -Menu $aspectMenu -Context $Context -Name CropAspect + $Context.PropertyControls.Aspect = [pscustomobject][ordered]@{ + Name='Aspect'; Element=$button; Button=$button + Menu=$aspectMenu; MenuItems=$aspectItems + Controller=$Context.CropAspectMenuControl + } + } else { + $Context.PropertyControls[$propertyName] = [pscustomobject][ordered]@{ + Name=$propertyName; Element=$button; Button=$button + Menu=$null; MenuItems=$null; Controller=$null + } + if (($Tool -eq 'Crop' -and $propertyName -in @('Reset','Apply')) -or + ($Tool -eq 'Select' -and + $propertyName -in @('Duplicate','Delete'))) { + $semanticName = $propertyName + $semanticHandler = { + switch ("$Tool/$semanticName") { + 'Crop/Reset' { if ($Context.ResetCrop) { & $Context.ResetCrop } } + 'Crop/Apply' { if ($Context.ApplyCrop) { & $Context.ApplyCrop } } + 'Select/Duplicate' { if ($Context.DuplicateSelection) { & $Context.DuplicateSelection } } + 'Select/Delete' { if ($Context.DeleteSelection) { & $Context.DeleteSelection } } + } + }.GetNewClosure() + $button.Add_Click($semanticHandler) + $Context.PropertyBindings.Add([pscustomobject]@{ + Target=$button; Handler=$semanticHandler + }) | Out-Null + } + } + $panel.Children.Add($button) | Out-Null + } + $Context.Shell.PropertyIsland.Width = if ($mode -eq 'Wide') { 470 } elseif ($mode -eq 'Compact') { 350 } else { 286 } + } + $Context.PropertyState +} + +function Set-SnipPreviewStatusPresentation { + [CmdletBinding()] + param([Parameter(Mandatory)] $Context) + + if ($null -eq $Context.Shell -or $null -eq $Context.Shell.StatusText) { return } + $mode = [string]$Context.ModeState.Value + $statusText = $Context.Shell.StatusText + $statusIsland = $Context.Shell.StatusIsland + if ($Context.StatusState.Kind -eq 'Idle') { + $statusText.Text = if ($mode -eq 'Narrow') { '●' } else { 'Non-destructive edit' } + $statusText.TextTrimming = [System.Windows.TextTrimming]::None + $statusText.ToolTip = $null + [System.Windows.Automation.AutomationProperties]::SetHelpText($statusText, '') + $statusIsland.Width = if ($mode -eq 'Narrow') { 42 } else { 150 } + $statusIsland.Padding = if ($mode -eq 'Narrow') { + [System.Windows.Thickness]::new(0) + } else { [System.Windows.Thickness]::new(10,0,10,0) } + return + } + + $fullText = [string]$Context.StatusState.Text + $desiredWidth = [math]::Max(150.0, 34.0 + $fullText.Length * 7.0) + $statusText.Text = $fullText + $statusText.ToolTip = $fullText + [System.Windows.Automation.AutomationProperties]::SetHelpText($statusText, $fullText) + $statusIsland.Padding = [System.Windows.Thickness]::new(10,0,10,0) + switch ($mode) { + 'Compact' { + # The centered tool dock reaches into the lower-right lane at 900 DIPs. + # Keep the status capsule at its collision-safe compact width and expose + # the complete message through ToolTip and UI Automation HelpText. + $statusIsland.Width = 150 + $statusText.TextTrimming = [System.Windows.TextTrimming]::CharacterEllipsis + } + 'Narrow' { + $windowWidth = if ($null -ne $Context.Window -and + $Context.Window.ActualWidth -gt 0) { + [double]$Context.Window.ActualWidth + } else { 760.0 } + $statusIsland.Width = [math]::Min($desiredWidth, [math]::Max(150.0, $windowWidth - 60.0)) + $statusText.TextTrimming = if ($statusIsland.Width -lt $desiredWidth) { + [System.Windows.TextTrimming]::CharacterEllipsis + } else { [System.Windows.TextTrimming]::None } + } + default { + $statusIsland.Width = $desiredWidth + $statusText.TextTrimming = [System.Windows.TextTrimming]::None + } + } +} + +function Set-SnipPreviewResponsiveMode { + [CmdletBinding()] + param( + [Parameter(Mandatory)] $Context, + [Parameter(Mandatory)] [double]$Width, + [Parameter(Mandatory)] [double]$Height + ) + + $mode = Get-PreviewResponsiveMode -Width $Width -Height $Height + $Context.ModeState.Value = $mode + $recent = [string[]]@($Context.RecentTools | Select-Object -First 2) + if ($recent.Count -lt 2) { $recent = [string[]]@('Highlight','ArrowLine') } + $order = switch ($mode) { + 'Wide' { [string[]]@('Select','Crop','Pen','Highlight','ArrowLine','RectangleEllipse','Text','Steps','BlurPixelate','Color','Undo','Redo') } + 'Compact' { [string[]]@('Select','Crop','Pen',$recent[0],$recent[1],'Text','BlurPixelate','Color','More','Undo','Redo') } + default { [string[]]@('Select','Crop','Pen','Text','BlurPixelate','More','Undo','Redo') } + } + $Context.ToolOrder.Clear() + foreach ($name in $order) { $Context.ToolOrder.Add($name) | Out-Null } + $panel = $Context.Shell.ToolPanel + $panel.Children.Clear() + foreach ($name in $order) { + $control = $Context.ToolControls[$name] + if ($null -ne $control) { + $control.Margin = if ($mode -eq 'Wide') { + [System.Windows.Thickness]::new(2,0,2,0) + } else { [System.Windows.Thickness]::new(0) } + $panel.Children.Add($control) | Out-Null + } + } + + $activeTool = if (-not [string]::IsNullOrWhiteSpace([string]$Context.ActiveTool)) { + [string]$Context.ActiveTool + } else { 'Select' } + $visible = @($order) -contains $activeTool + $Context.MoreState.IsActive = -not $visible -and $activeTool -notin @('Select','') + $Context.MoreState.Tool = if ($Context.MoreState.IsActive) { $activeTool } else { $null } + $activeMetadata = if ($Context.MoreState.IsActive -and + $null -ne $Context.ToolMetadata[$activeTool]) { + $Context.ToolMetadata[$activeTool] + } else { $null } + $Context.MoreState.Name = if ($null -ne $activeMetadata) { + $activeMetadata.DisplayName + } else { 'More' } + $Context.MoreState.Icon = if ($null -ne $activeMetadata) { + $activeMetadata.Icon + } else { '…' } + if ($null -ne $Context.Shell.MoreButton) { + $Context.Shell.MoreName.Text = $Context.MoreState.Name + $Context.Shell.MoreIcon.Text = $Context.MoreState.Icon + $Context.Shell.MoreIndicator.Visibility = if ($Context.MoreState.IsActive) { + [System.Windows.Visibility]::Visible + } else { [System.Windows.Visibility]::Collapsed } + if ($Context.MoreState.IsActive) { + # Remove the previous fixed constraint for one canonical WPF measure; + # otherwise a substituted label inherits the stale 48-DIP slot. + $Context.Shell.MoreButton.Width = [double]::NaN + $Context.Shell.MoreButton.InvalidateMeasure() + $Context.Shell.MoreButton.Measure([System.Windows.Size]::new( + [double]::PositiveInfinity, [double]::PositiveInfinity)) + $Context.Shell.MoreButton.Width = [math]::Max( + 48.0, [math]::Ceiling($Context.Shell.MoreButton.DesiredSize.Width)) + } else { + $Context.Shell.MoreButton.Width = 48 + } + $Context.Shell.MoreButton.BorderBrush = if ($Context.MoreState.IsActive) { + $Context.Resources['SnipAccentBrush'] + } else { $Context.Resources['SnipHairlineBrush'] } + $Context.Shell.MoreButton.Background = if ($Context.MoreState.IsActive) { + $Context.Resources['SnipAccentTintBrush'] + } else { [System.Windows.Media.Brushes]::Transparent } + [System.Windows.Automation.AutomationProperties]::SetName( + $Context.Shell.MoreButton, $Context.MoreState.Name) + } + if ($null -ne $Context.Shell.PSObject.Properties['MoreMenuItems']) { + foreach ($secondaryTool in @('Highlight','ArrowLine','RectangleEllipse','Steps','Color')) { + $Context.Shell.MoreMenuItems[$secondaryTool].Visibility = if (@($order) -contains $secondaryTool) { + [System.Windows.Visibility]::Collapsed + } else { [System.Windows.Visibility]::Visible } + } + } + + $Context.Shell.ToolDock.Margin = if ($mode -eq 'Narrow') { + [System.Windows.Thickness]::new(0,0,0,96) + } else { [System.Windows.Thickness]::new(0,0,0,35) } + $Context.Shell.PropertyIsland.Margin = if ($mode -eq 'Narrow') { + [System.Windows.Thickness]::new(0,0,0,155) + } else { [System.Windows.Thickness]::new(0,0,0,94) } + $Context.Shell.CoordinateText.Visibility = if ($mode -eq 'Narrow') { + [System.Windows.Visibility]::Collapsed + } else { [System.Windows.Visibility]::Visible } + $Context.Shell.ZoomOutButton.Visibility = if ($mode -eq 'Narrow') { + [System.Windows.Visibility]::Collapsed + } else { [System.Windows.Visibility]::Visible } + $Context.Shell.ZoomInButton.Visibility = if ($mode -eq 'Narrow') { + [System.Windows.Visibility]::Collapsed + } else { [System.Windows.Visibility]::Visible } + Set-SnipPreviewStatusPresentation -Context $Context + Set-SnipPropertyIsland -Context $Context -Tool $Context.ActivePropertyTool | Out-Null + $mode +} + +function Initialize-SnipPreviewAnnotations { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [System.Collections.IList]$Annotations + ) + + for ($index = 0; $index -lt $Annotations.Count; $index++) { + $annotation = $Annotations[$index] + $kind = Get-SnipRecordValue -InputObject $annotation -Name Kind + $geometry = Get-SnipRecordValue -InputObject $annotation -Name Geometry + $id = [string](Get-SnipRecordValue -InputObject $annotation -Name Id) + if (-not [string]::IsNullOrWhiteSpace([string]$kind) -and + $null -ne $geometry -and + -not [string]::IsNullOrWhiteSpace($id)) { + continue + } + $Annotations[$index] = Copy-SnipAnnotation -Annotation $annotation + } + $Annotations +} + +function New-SnipPreviewWindow { + [CmdletBinding()] + param([Parameter(Mandatory)] $Context) + + Initialize-SnipPreviewAnnotations -Annotations $Context.Annotations | Out-Null + + [xml]$xaml = [xml](Get-SnipXamlText -Name 'PreviewWindow') + $reader = [System.Xml.XmlNodeReader]::new($xaml) + $window = [System.Windows.Markup.XamlReader]::Load($reader) + Add-SnipThemeResources -Root $window -Resources $Context.Resources | Out-Null + $window.Left = [double]$Context.InitialBounds.X + $window.Top = [double]$Context.InitialBounds.Y + $window.Width = [double]$Context.InitialBounds.Width + $window.Height = [double]$Context.InitialBounds.Height + $placementState = $Context.PlacementState + $setWindowPosition = $Context.SetWindowPosition + $initialPhysicalBounds = $Context.InitialPhysicalBounds + $placementHelper = [System.Windows.Interop.WindowInteropHelper]::new($window) + $sourceInitializedHandler = { + if ($placementHelper.Handle -ne [IntPtr]::Zero) { + $placementState.IsApplied = [bool](& $setWindowPosition ` + $placementHelper.Handle $initialPhysicalBounds) + } + }.GetNewClosure() + $closedPlacementHandler = $null + $closedPlacementHandler = { + $window.Remove_SourceInitialized($sourceInitializedHandler) + $placementState.HandlersAttached = $false + }.GetNewClosure() + $window.Add_SourceInitialized($sourceInitializedHandler) + $window.Add_Closed($closedPlacementHandler) + $placementState.HandlersAttached = $true + $chrome = [System.Windows.Shell.WindowChrome]::new() + $chrome.CaptionHeight = 0 + $chrome.ResizeBorderThickness = [System.Windows.Thickness]::new(6) + $chrome.GlassFrameThickness = [System.Windows.Thickness]::new(0) + $chrome.CornerRadius = [System.Windows.CornerRadius]::new(0) + $chrome.UseAeroCaptionButtons = $false + [System.Windows.Shell.WindowChrome]::SetWindowChrome($window, $chrome) + $Context.Window = $window + $Context.Chrome = $chrome + + $actionsPanel = $window.FindName('ActionsPanel') + $toolPanel = $window.FindName('ToolPanel') + $newButton = { + param([string]$Name,[string]$Text,[string]$AutomationName,[double]$Width=38) + $button = [System.Windows.Controls.Button]::new() + $button.Name = $Name; $button.Content = $Text; $button.Width = $Width; $button.Height = 38 + $button.Margin = [System.Windows.Thickness]::new(2,0,2,0) + $button.Padding = [System.Windows.Thickness]::new(6,0,6,0) + $button.SetResourceReference([System.Windows.FrameworkElement]::StyleProperty, 'SnipButtonStyle') + [System.Windows.Automation.AutomationProperties]::SetName($button, $AutomationName) + $window.RegisterName($Name, $button) + $button + }.GetNewClosure() + $newToggle = { + param([string]$Name,[string]$Text,[string]$AutomationName) + $button = [System.Windows.Controls.Primitives.ToggleButton]::new() + $button.Name = $Name; $button.Content = $Text + $button.SetResourceReference([System.Windows.FrameworkElement]::StyleProperty, 'StudioToolToggleStyle') + [System.Windows.Automation.AutomationProperties]::SetName($button, $AutomationName) + $window.RegisterName($Name, $button) + $button + }.GetNewClosure() + + $pin = & $newToggle 'PinBtn' ([char]0xE718) 'Pin window on top' + $save = & $newButton 'SaveBtn' 'Save' 'Save snip' 62 + $pin.ToolTip = 'Keep preview on top' + $save.ToolTip = 'Save (Ctrl+S)' + $copyOptions = [ordered]@{ CopyKeepOpen = {} } + $copy = New-SnipSplitControl -Name CopyAndClose -DefaultCommand CopyAndClose ` + -PrimaryText 'Copy & close' -PrimaryAutomationName 'Copy and close' ` + -OptionsAutomationName 'Copy options' -Options $copyOptions -Context $Context ` + -Glyph ([char]0xE8C8) -PrimaryWidth 122 -OptionsWidth 28 + $copy.PrimaryButton.SetResourceReference( + [System.Windows.FrameworkElement]::StyleProperty, 'SnipPrimaryButtonStyle') + $copy.Root.Margin = [System.Windows.Thickness]::new(2,0,2,0) + $copy.PrimaryButton.Name = 'CopyBtn' + $window.RegisterName('CopyBtn', $copy.PrimaryButton) + $close = & $newButton 'CloseBtn' ([char]0xE711) 'Close preview' 38 + $closeStyle = [System.Windows.Style]::new([System.Windows.Controls.Button]) + $closeStyle.BasedOn = $Context.Resources['SnipButtonStyle'] + $closeStyle.Setters.Add([System.Windows.Setter]::new( + [System.Windows.Controls.Control]::BackgroundProperty, + [System.Windows.Media.Brushes]::Transparent)) + foreach ($triggerProperty in @( + [System.Windows.UIElement]::IsMouseOverProperty, + [System.Windows.UIElement]::IsKeyboardFocusedProperty)) { + $trigger = [System.Windows.Trigger]::new() + $trigger.Property = $triggerProperty + $trigger.Value = $true + $trigger.Setters.Add([System.Windows.Setter]::new( + [System.Windows.Controls.Control]::BackgroundProperty, + $Context.Resources['SnipCoralTintBrush'])) + $trigger.Setters.Add([System.Windows.Setter]::new( + [System.Windows.Controls.Control]::ForegroundProperty, + $Context.Resources['SnipAccentTextBrush'])) + $closeStyle.Triggers.Add($trigger) + } + $close.Style = $closeStyle + $close.ToolTip = 'Close preview (Alt+F4)' + $pin.FontFamily = [System.Windows.Media.FontFamily]::new('Segoe Fluent Icons') + $close.FontFamily = [System.Windows.Media.FontFamily]::new('Segoe Fluent Icons') + foreach ($control in @($pin,$save,$copy.Root,$close)) { $actionsPanel.Children.Add($control) | Out-Null } + + $select = & $newButton 'SelectToolBtn' '↖' 'Select tool' + $crop = & $newButton 'CropToolBtn' '⌗' 'Crop tool' + $pen = & $newButton 'PenToolBtn' '✎' 'Pen tool' + $highlight = & $newToggle 'HighlightBtn' '▰' 'Highlight tool' + $text = & $newToggle 'TextBtn' 'T' 'Text tool' + $steps = & $newButton 'StepsToolBtn' '①' 'Numbered steps tool' + $color = & $newButton 'ColorToolBtn' '●' 'Annotation color' + $colorMenu = [System.Windows.Controls.ContextMenu]::new() + $colorMenuItems = [ordered]@{} + foreach ($colorName in @('Yellow','Green','Pink','Blue','Orange','Red')) { + $colorItem = [System.Windows.Controls.MenuItem]::new() + $colorItem.Header = $colorName + [System.Windows.Automation.AutomationProperties]::SetName($colorItem, $colorName) + $colorMenu.Items.Add($colorItem) | Out-Null + $colorMenuItems[$colorName] = $colorItem + } + $colorMenuControl = Connect-SnipPreviewMenuButton ` + -Button $color -Menu $colorMenu -Context $Context -Name Color + $undo = & $newButton 'UndoBtn' '↶' 'Undo' + $redo = & $newButton 'RedoBtn' '↷' 'Redo' + $undo.ToolTip = 'Undo (Ctrl+Z)' + $redo.ToolTip = 'Redo (Ctrl+Shift+Z)' + $more = & $newButton 'MoreBtn' 'More' 'More tools' 48 + $more.Width = 48 + $moreContent = [System.Windows.Controls.StackPanel]::new() + $moreContent.Orientation = [System.Windows.Controls.Orientation]::Horizontal + $moreContent.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center + $moreIcon = [System.Windows.Controls.TextBlock]::new() + $moreIcon.Name = 'MoreIconText'; $moreIcon.Text = '…' + $moreIcon.Margin = [System.Windows.Thickness]::new(0,0,4,0) + $moreName = [System.Windows.Controls.TextBlock]::new() + $moreName.Name = 'MoreNameText'; $moreName.Text = 'More' + $moreIndicator = [System.Windows.Controls.Border]::new() + $moreIndicator.Name = 'MoreActiveIndicator' + $moreIndicator.Width = 12; $moreIndicator.Height = 2 + $moreIndicator.CornerRadius = [System.Windows.CornerRadius]::new(1) + $moreIndicator.Margin = [System.Windows.Thickness]::new(5,0,0,0) + $moreIndicator.VerticalAlignment = [System.Windows.VerticalAlignment]::Center + $moreIndicator.Background = $Context.Resources['SnipAccentBrush'] + $moreIndicator.Visibility = [System.Windows.Visibility]::Collapsed + $moreContent.Children.Add($moreIcon) | Out-Null + $moreContent.Children.Add($moreName) | Out-Null + $moreContent.Children.Add($moreIndicator) | Out-Null + $more.Content = $moreContent + $window.RegisterName('MoreIconText', $moreIcon) + $window.RegisterName('MoreNameText', $moreName) + $window.RegisterName('MoreActiveIndicator', $moreIndicator) + + $arrow = New-SnipSplitControl -Name ArrowLine -DefaultCommand Arrow ` + -PrimaryText '↗' -PrimaryAutomationName 'Arrow tool, selected subtype Arrow' ` + -OptionsAutomationName 'Arrow or Line options' -Options ([ordered]@{ Arrow={}; Line={} }) ` + -Context $Context -PrimaryWidth 38 -OptionsWidth 22 + $rectangle = New-SnipSplitControl -Name RectangleEllipse -DefaultCommand Rectangle ` + -PrimaryText '□' -PrimaryAutomationName 'Rectangle tool, selected subtype Rectangle' ` + -OptionsAutomationName 'Rectangle or Ellipse options' -Options ([ordered]@{ Rectangle={}; Ellipse={} }) ` + -Context $Context -PrimaryWidth 38 -OptionsWidth 22 + $privacy = New-SnipSplitControl -Name BlurPixelate -DefaultCommand Blur ` + -PrimaryText '▒' -PrimaryAutomationName 'Blur tool, selected subtype Blur' ` + -OptionsAutomationName 'Blur or Pixelate options' -Options ([ordered]@{ Blur={}; Pixelate={} }) ` + -Context $Context -PrimaryWidth 38 -OptionsWidth 22 + $Context.SplitControls.Copy = $copy + $Context.SplitControls.ArrowLine = $arrow + $Context.SplitControls.RectangleEllipse = $rectangle + $Context.SplitControls.BlurPixelate = $privacy + $Context.ToolControls = [ordered]@{ + Select=$select; Crop=$crop; Pen=$pen; Highlight=$highlight; ArrowLine=$arrow.Root + RectangleEllipse=$rectangle.Root; Text=$text; Steps=$steps; BlurPixelate=$privacy.Root + Color=$color; More=$more; Undo=$undo; Redo=$redo + } + $select.Background = $Context.Resources['SnipAccentTintBrush'] + $select.BorderBrush = $Context.Resources['SnipAccentBrush'] + $moreMenu = [System.Windows.Controls.ContextMenu]::new() + $moreMenu.Background = $Context.Resources['SnipCanvasBrush'] + $moreMenu.Foreground = $Context.Resources['SnipPrimaryTextBrush'] + $moreMenu.BorderBrush = $Context.Resources['SnipHairlineBrush'] + $moreMenu.BorderThickness = [System.Windows.Thickness]::new(1) + $moreMenuItems = [ordered]@{} + $moreColorMenuItems = [ordered]@{} + foreach ($toolSpec in @( + @('Highlight','Highlight'), @('ArrowLine','Arrow/Line'), + @('RectangleEllipse','Rectangle/Ellipse'), @('Steps','Steps'), @('Color','Color'))) { + $item = [System.Windows.Controls.MenuItem]::new() + $item.Header = $toolSpec[1] + [System.Windows.Automation.AutomationProperties]::SetName($item, $toolSpec[1]) + if ($toolSpec[0] -eq 'Color') { + foreach ($colorName in @('Yellow','Green','Pink','Blue','Orange','Red')) { + $colorItem = [System.Windows.Controls.MenuItem]::new() + $colorItem.Header = $colorName + [System.Windows.Automation.AutomationProperties]::SetName( + $colorItem, $colorName) + $item.Items.Add($colorItem) | Out-Null + $moreColorMenuItems[$colorName] = $colorItem + } + } + $moreMenu.Items.Add($item) | Out-Null + $moreMenuItems[$toolSpec[0]] = $item + } + Set-SnipPreviewMenuStyle -Menu $moreMenu -Context $Context | Out-Null + $moreMenu.PlacementTarget = $more + $more.ContextMenu = $moreMenu + [System.Windows.Automation.AutomationProperties]::SetItemStatus($more, 'Collapsed') + $moreMenuState = [pscustomobject]@{ + IsExpanded=$false; MenuHandle=[IntPtr]::Zero; IsMenuWindowRegistered=$false + HandlersAttached=$false + } + $moreOpen = { + $moreMenu.PlacementTarget = $more + $moreMenu.IsOpen = $true + }.GetNewClosure() + $moreClose = { + if ($null -ne $moreMenu.Tag -and $null -ne $moreMenu.Tag.CloseNestedPopups) { + & $moreMenu.Tag.CloseNestedPopups + } + if ($moreMenu.IsOpen) { $moreMenu.IsOpen = $false } + if ($moreMenuState.IsMenuWindowRegistered -and + $moreMenuState.MenuHandle -ne [IntPtr]::Zero) { + & $Context.UnregisterWindow $moreMenuState.MenuHandle + } + $moreMenuState.IsExpanded = $false + $moreMenuState.MenuHandle = [IntPtr]::Zero + $moreMenuState.IsMenuWindowRegistered = $false + [System.Windows.Automation.AutomationProperties]::SetItemStatus($more, 'Collapsed') + try { $more.Focus() | Out-Null } catch {} + }.GetNewClosure() + $moreOpenedHandler = { + $moreMenuState.IsExpanded = $true + [System.Windows.Automation.AutomationProperties]::SetItemStatus($more, 'Expanded') + $source = [System.Windows.PresentationSource]::FromVisual($moreMenu) + if ($source -is [System.Windows.Interop.HwndSource] -and + $source.Handle -ne [IntPtr]::Zero -and + -not $moreMenuState.IsMenuWindowRegistered) { + & $Context.RegisterWindow $source.Handle + $moreMenuState.MenuHandle = $source.Handle + $moreMenuState.IsMenuWindowRegistered = $true + } + }.GetNewClosure() + $moreClosedHandler = { & $moreClose }.GetNewClosure() + $moreClickHandler = { & $moreOpen }.GetNewClosure() + $moreMenu.Add_Opened($moreOpenedHandler) + $moreMenu.Add_Closed($moreClosedHandler) + $more.Add_Click($moreClickHandler) + $moreKeyHandler = { + $modifiers = & $Context.GetKeyboardModifiers + if (($modifiers -band [System.Windows.Input.ModifierKeys]::Alt) -ne 0 -and + $_.Key -eq [System.Windows.Input.Key]::Down) { + & $moreOpen + $_.Handled = $true + } elseif ($_.Key -eq [System.Windows.Input.Key]::Escape -and + $moreMenuState.IsExpanded) { + & $moreClose + $_.Handled = $true + } + }.GetNewClosure() + $more.Add_PreviewKeyDown($moreKeyHandler) + $moreCleanupHandler = { + & $moreClose + $moreMenu.Remove_Opened($moreOpenedHandler) + $moreMenu.Remove_Closed($moreClosedHandler) + $more.Remove_Click($moreClickHandler) + $more.Remove_PreviewKeyDown($moreKeyHandler) + if ($null -ne $moreMenu.Tag -and $null -ne $moreMenu.Tag.Disconnect) { + & $moreMenu.Tag.Disconnect + } + $window.Remove_Closed($moreCleanupHandler) + $moreMenuState.HandlersAttached = $false + }.GetNewClosure() + $window.Add_Closed($moreCleanupHandler) + $moreMenuState.HandlersAttached = $true + $moreTransient = [pscustomobject]@{ + Name='More'; State=$moreMenuState; Menu=$moreMenu + OpenOptions=$moreOpen; CloseOptions=$moreClose + } + $Context.TransientMenus.Add($moreTransient) | Out-Null + $Context.Shell = [pscustomobject][ordered]@{ + Window=$window; StudioRoot=$window.FindName('StudioRoot'); Scroller=$window.FindName('Scroller') + ImageHost=$window.FindName('ImageHost'); PreviewImage=$window.FindName('PreviewImage') + AnnotationLayer=$window.FindName('AnnotationLayer') + InteractionLayer=$window.FindName('InteractionLayer') + SelectionLayer=$window.FindName('SelectionLayer') + HighlightLayer=$window.FindName('HighlightLayer'); BrandIsland=$window.FindName('BrandIsland') + ActionsIsland=$window.FindName('ActionsIsland'); PropertyIsland=$window.FindName('PropertyIsland') + ToolDock=$window.FindName('ToolDock'); ViewportIsland=$window.FindName('ViewportIsland') + StatusIsland=$window.FindName('StatusIsland'); ToolPanel=$toolPanel + PropertyPanel=$window.FindName('PropertyPanel'); MoreButton=$more + MoreMenu=$moreMenu; MoreMenuItems=$moreMenuItems; MoreMenuState=$moreMenuState + MoreColorMenuItems=$moreColorMenuItems; CloseMoreMenu=$moreClose + MoreIcon=$moreIcon; MoreName=$moreName; MoreIndicator=$moreIndicator + ColorMenu=$colorMenu; ColorMenuItems=$colorMenuItems; ColorMenuControl=$colorMenuControl + CoordinateText=$window.FindName('CoordinateText'); ZoomOutButton=$window.FindName('ZoomOutBtn') + ZoomInButton=$window.FindName('ZoomInBtn'); StatusText=$window.FindName('StatusText') + } + $closeMenus = { + foreach ($splitControl in @($Context.TransientMenus)) { + if ($null -ne $splitControl -and $null -ne $splitControl.CloseOptions) { + & $splitControl.CloseOptions + } + } + }.GetNewClosure() + $resolveCommand = { + param([string]$FocusedRole,[hashtable]$EditorState,[string]$Key,$Modifiers) + Resolve-PreviewKeyCommand -FocusedRole $FocusedRole -EditorState $EditorState ` + -Key $Key -Modifiers @($Modifiers) + }.GetNewClosure() + $clearStatus = { + $Context.StatusState.Kind = 'Idle' + $Context.StatusState.Text = '' + Set-SnipPreviewStatusPresentation -Context $Context + }.GetNewClosure() + $setStatus = { + param([string]$Text, [string]$Kind = 'Info') + if ([string]::IsNullOrWhiteSpace($Text)) { + & $clearStatus + return + } + $Context.StatusState.Kind = $Kind + $Context.StatusState.Text = $Text + Set-SnipPreviewStatusPresentation -Context $Context + }.GetNewClosure() + $Context.ClearStatus = $clearStatus + $Context.SetStatus = $setStatus + + $defaultSystemMenuAction = { + $point = $window.PointToScreen([System.Windows.Point]::new(0, 0)) + [System.Windows.SystemCommands]::ShowSystemMenu($window, $point) + }.GetNewClosure() + $closePreviewAction = { $window.Close() }.GetNewClosure() + $commandRouter = [pscustomobject][ordered]@{ + Resolve = $resolveCommand + CloseTransientMenus = $closeMenus + SupportsNativeSystemMenu = $true + LastCommand = $null + ResolveCount = 0 + SystemMenuAction = $defaultSystemMenuAction + CloseAction = $closePreviewAction + } + $Context.CommandRouter = $commandRouter + Set-SnipPreviewResponsiveMode -Context $Context -Width $window.Width -Height $window.Height | Out-Null + $Context.Shell +} + +function Show-PreviewWindow { + [CmdletBinding()] + param( + [System.Drawing.Bitmap]$Bitmap, + # Harness hook. When a scriptblock is provided, Show-PreviewWindow + # still calls ShowDialog() (so its local scope stays alive and the + # event handlers keep working), but on the Loaded event it invokes + # $TestAction with a hashtable of handles and then closes the window. + # The window is positioned off-screen for a headless feel. + [scriptblock]$TestAction, + [scriptblock]$OnOwnershipAccepted, + [scriptblock]$OnSurfaceReady, + [scriptblock]$OnNewSnip, + [scriptblock]$OnOutputStarting, + [scriptblock]$OnOutputCompleted + ) + + $previewLifecycle = [pscustomobject]@{ + Result = 'UserCancelled' + CleanupInstalled = $false + OwnershipAccepted = $false + BitmapDisposed = $false + } + $src = Convert-BitmapToBitmapSource $Bitmap + + # The legacy editor below remains the annotation engine; the Floating + # Studio owns only construction, layout, chrome, and command surfaces. + # Keeping this adapter at the old construction seam preserves every named + # closure and the public Show-PreviewWindow signature. + $resources = New-SnipThemeResources ` + -HighContrast ([bool][System.Windows.SystemParameters]::HighContrast) + $previewContext = New-SnipPreviewContext -Bitmap $Bitmap -BitmapSource $src ` + -Resources $resources + $studioShell = New-SnipPreviewWindow -Context $previewContext + $script:ActivePreviewContext = $previewContext + $win = $studioShell.Window + $previewImage = $win.FindName('PreviewImage') + $previewImage.Source = $previewContext.BitmapSource + $win.FindName('DimText').Text = "$($Bitmap.Width) × $($Bitmap.Height) px" + + # WindowChrome retains the native resize/snap contract while the one + # full-window StudioRoot paints the neutral-black client surface. + + # Surface ANY WPF dispatcher exception. Copy to clipboard AND write to + # %LOCALAPPDATA%\SnipIT\last-error.txt — a plain MessageBox doesn't always + # let you select text on Win11. + $win.Dispatcher.add_UnhandledException({ + param($sender, $e) + $ex = $e.Exception + $msg = "$($ex.GetType().FullName)`n$($ex.Message)`n`n$($ex.StackTrace)" + try { + $logFile = Join-Path $script:AppHomeDir 'last-error.txt' + Set-Content -LiteralPath $logFile -Value $msg -Encoding UTF8 + } catch {} + try { [System.Windows.Clipboard]::SetText($msg) } catch {} + try { + [System.Windows.Forms.MessageBox]::Show( + "$msg`n`n--- ALSO COPIED TO CLIPBOARD ---`nFile: $logFile", + 'SnipIT preview error (text on clipboard)', 'OK', 'Error') | Out-Null + } catch {} + $e.Handled = $true + }) + + $highlightLayer = $win.FindName('HighlightLayer') + $annotationLayer = $win.FindName('AnnotationLayer') + $interactionLayer = $win.FindName('InteractionLayer') + $selectionLayer = $win.FindName('SelectionLayer') + $highlightBtn = $win.FindName('HighlightBtn') + $rectBtn = $win.FindName('RectBtn') + $arrowBtn = $win.FindName('ArrowBtn') + $textBtn = $win.FindName('TextBtn') + $imageHost = $win.FindName('ImageHost') + $colorBar = $win.FindName('ColorBar') + $scroller = $win.FindName('Scroller') + $zoomText = $win.FindName('ZoomText') + # Create the shared transform before any gesture closure captures it. + # Selection/crop hit tolerances and handle sizes must observe the live + # object rather than a pre-initialization $null captured by GetNewClosure(). + $layoutScale = [System.Windows.Media.ScaleTransform]::new(1,1) + $imageHost.LayoutTransform = $layoutScale + + # Color palette: name → highlight (low alpha) and text (full alpha) variants + # Each entry: HiR/HiG/HiB used for highlights @ alpha 110, text @ alpha 255. + $palette = [ordered]@{ + Yellow = @{ R=255; G=222; B=0 } + Green = @{ R=70; G=210; B=110 } + Pink = @{ R=255; G=90; B=180 } + Blue = @{ R=80; G=170; B=255 } + Orange = @{ R=255; G=150; B=40 } + Red = @{ R=255; G=60; B=60 } + } + + $state = [pscustomobject]@{ + Annotations = $previewContext.Annotations + UndoStack = $previewContext.UndoStack + RedoStack = $previewContext.RedoStack + SelectionId = $previewContext.SelectedAnnotationId + CropRectangle = $previewContext.CropRectangle + Draft = $previewContext.Draft + ActiveColor = 'Yellow' + Drawing = $false + DrawingTool = $null + AnchorCanvas = $null + DraftRect = $null + EditingText = $false + Zoom = 1.0 + # Pan (Hand) mode: active when no annotation tool is checked. + Panning = $false + TemporaryPan = $false + PanStartSv = $null # mouse position at pan-begin, in Scroller-local coords + PanOrigX = 0.0 # Scroller.HorizontalOffset at pan-begin + PanOrigY = 0.0 # Scroller.VerticalOffset at pan-begin + ActiveStudioTool = $previewContext.ActiveTool + } + $previewContext.EditorState = $state + + function script:Get-DisplayedImageBounds { + # Canvas coordinates are in natural image-pixel space — the + # LayoutTransform only affects rendering, not local coords. Image + # and Canvas are both sized to Bitmap.Width x Bitmap.Height. + [pscustomobject]@{ + X = 0 + Y = 0 + W = $Bitmap.Width + H = $Bitmap.Height + Scale = 1.0 + } + } + + function script:To-WpfColor { + param([int]$A, [int]$R, [int]$G, [int]$B) + [System.Windows.Media.Color]::FromArgb($A, $R, $G, $B) + } + + function script:Get-PreviewAnnotationBounds { + param($Annotation) + if ($null -eq $Annotation -or $null -eq $Annotation.Geometry) { return $null } + $geometry = $Annotation.Geometry + switch ([string]$geometry.Type) { + { $_ -in @('Bounds','TextBounds','StepBounds') } { + return [pscustomobject]@{ + X=[double]$geometry.X; Y=[double]$geometry.Y + Width=[double]$geometry.Width; Height=[double]$geometry.Height + } + } + 'Line' { + $left = [math]::Min([double]$geometry.Start.X,[double]$geometry.End.X) + $top = [math]::Min([double]$geometry.Start.Y,[double]$geometry.End.Y) + return [pscustomobject]@{ + X=$left; Y=$top + Width=[math]::Max(1.0,[math]::Abs([double]$geometry.End.X-[double]$geometry.Start.X)) + Height=[math]::Max(1.0,[math]::Abs([double]$geometry.End.Y-[double]$geometry.Start.Y)) + } + } + 'Points' { + $points = @($geometry.Points) + if ($points.Count -eq 0) { return $null } + $xs = @($points | ForEach-Object { [double]$_.X }) + $ys = @($points | ForEach-Object { [double]$_.Y }) + $left = [double](($xs | Measure-Object -Minimum).Minimum) + $top = [double](($ys | Measure-Object -Minimum).Minimum) + return [pscustomobject]@{ + X=$left; Y=$top + Width=[math]::Max(1.0,[double](($xs | Measure-Object -Maximum).Maximum)-$left) + Height=[math]::Max(1.0,[double](($ys | Measure-Object -Maximum).Maximum)-$top) + } + } + } + $null + } + + function script:Get-PreviewAnnotationById { + param([AllowNull()][string]$Id) + if ([string]::IsNullOrWhiteSpace($Id)) { return $null } + $matches=@($previewContext.Annotations | Where-Object { + [string]$_.Id -eq $Id + } | Select-Object -First 1) + if($matches.Count -gt 0){return $matches[0]} + $null + } + + function script:Get-PreviewAnnotationIndexById { + param([AllowNull()][string]$Id) + if ([string]::IsNullOrWhiteSpace($Id)) { return -1 } + for ($index = 0; $index -lt $previewContext.Annotations.Count; $index++) { + if ([string]$previewContext.Annotations[$index].Id -eq $Id) { return $index } + } + -1 + } + + function script:Test-PreviewAnnotationEqual { + param($Left,$Right) + if ($null -eq $Left -or $null -eq $Right) { return $null -eq $Left -and $null -eq $Right } + (ConvertTo-Json $Left -Depth 32 -Compress) -ceq + (ConvertTo-Json $Right -Depth 32 -Compress) + } + + function script:Test-PreviewCropEqual { + param($Left,$Right) + if ($null -eq $Left -or $null -eq $Right) { return $null -eq $Left -and $null -eq $Right } + [int]$Left.X -eq [int]$Right.X -and [int]$Left.Y -eq [int]$Right.Y -and + [int]$Left.Width -eq [int]$Right.Width -and + [int]$Left.Height -eq [int]$Right.Height + } + + function script:Get-PreviewWpfBrush { + param([string]$Color,[int]$Alpha=255) + $rgb = $palette[$Color] + if ($null -eq $rgb) { return $resources['SnipPrimaryTextBrush'] } + [System.Windows.Media.SolidColorBrush]::new( + (To-WpfColor $Alpha $rgb.R $rgb.G $rgb.B)) + } + + function script:Add-PreviewHandleSet { + param( + [Parameter(Mandatory)] [System.Windows.Controls.Canvas]$Layer, + [Parameter(Mandatory)] $Bounds, + [Parameter(Mandatory)] [string]$Role, + [string[]]$Handles = @('TopLeft','Top','TopRight','Right','BottomRight','Bottom','BottomLeft','Left') + ) + $zoom = if ($null -ne $layoutScale -and $layoutScale.ScaleX -gt 0) { + [double]$layoutScale.ScaleX + } else { 1.0 } + $diameter = 10.0 / $zoom + $half = $diameter / 2.0 + $centers = @{ + TopLeft=@(([double]$Bounds.X),([double]$Bounds.Y)) + Top=@(([double]$Bounds.X + [double]$Bounds.Width/2.0),([double]$Bounds.Y)) + TopRight=@(([double]$Bounds.X + [double]$Bounds.Width),([double]$Bounds.Y)) + Right=@(([double]$Bounds.X + [double]$Bounds.Width),([double]$Bounds.Y + [double]$Bounds.Height/2.0)) + BottomRight=@(([double]$Bounds.X + [double]$Bounds.Width),([double]$Bounds.Y + [double]$Bounds.Height)) + Bottom=@(([double]$Bounds.X + [double]$Bounds.Width/2.0),([double]$Bounds.Y + [double]$Bounds.Height)) + BottomLeft=@(([double]$Bounds.X),([double]$Bounds.Y + [double]$Bounds.Height)) + Left=@(([double]$Bounds.X),([double]$Bounds.Y + [double]$Bounds.Height/2.0)) + } + foreach ($handleName in $Handles) { + $handle = [System.Windows.Shapes.Ellipse]::new() + $handle.Width = $diameter; $handle.Height = $diameter + $handle.Fill = $resources['SnipMintBrush'] + $handle.Stroke = $resources['SnipCanvasBrush'] + $handle.StrokeThickness = 1.0 / $zoom + $handle.IsHitTestVisible = $false + $handle.Tag = [pscustomobject]@{ Role=$Role; Handle=$handleName } + [System.Windows.Controls.Canvas]::SetLeft($handle,[double]$centers[$handleName][0]-$half) + [System.Windows.Controls.Canvas]::SetTop($handle,[double]$centers[$handleName][1]-$half) + $Layer.Children.Add($handle) | Out-Null + } + } + + function script:Render-PreviewInteraction { + $interactionLayer.Children.Clear() + $selectionLayer.Children.Clear() + $zoom = if ($null -ne $layoutScale -and $layoutScale.ScaleX -gt 0) { + [double]$layoutScale.ScaleX + } else { 1.0 } + + $crop = if ($null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Crop') { + $previewContext.Draft.Candidate + } else { $previewContext.CropRectangle } + if ($null -ne $crop) { + $cropOutline = [System.Windows.Shapes.Rectangle]::new() + $cropOutline.Width=[double]$crop.Width; $cropOutline.Height=[double]$crop.Height + $cropOutline.Stroke=$resources['SnipMintBrush'] + $cropOutline.StrokeThickness=2.0/$zoom + $cropOutline.Fill=[System.Windows.Media.SolidColorBrush]::new( + [System.Windows.Media.Color]::FromArgb(20,168,239,215)) + $cropOutline.IsHitTestVisible=$false + $cropOutline.Tag=[pscustomobject]@{ Role='CropOverlay' } + [System.Windows.Controls.Canvas]::SetLeft($cropOutline,[double]$crop.X) + [System.Windows.Controls.Canvas]::SetTop($cropOutline,[double]$crop.Y) + $interactionLayer.Children.Add($cropOutline) | Out-Null + Add-PreviewHandleSet -Layer $interactionLayer -Bounds $crop ` + -Role CropHandle + } + + $selected = Get-PreviewAnnotationById $previewContext.SelectedAnnotationId + if ($null -eq $selected) { + if ($null -ne $previewContext.SelectedAnnotationId) { + $previewContext.SelectedAnnotationId = $null + $state.SelectionId = $null + } + return + } + if ($null -ne $previewContext.Interaction -and + $previewContext.Interaction.Kind -eq 'Select' -and + $null -ne $previewContext.Interaction.Candidate) { + $selected = $previewContext.Interaction.Candidate + } + $geometry = $selected.Geometry + if ($geometry.Type -eq 'Line') { + $outline = [System.Windows.Shapes.Line]::new() + $outline.X1=[double]$geometry.Start.X; $outline.Y1=[double]$geometry.Start.Y + $outline.X2=[double]$geometry.End.X; $outline.Y2=[double]$geometry.End.Y + $outline.Stroke=$resources['SnipMintBrush']; $outline.StrokeThickness=2.0/$zoom + $outline.IsHitTestVisible=$false + $outline.Tag=[pscustomobject]@{ Role='SelectionOutline' } + $selectionLayer.Children.Add($outline) | Out-Null + foreach ($endpoint in @( + [pscustomobject]@{ Name='Start'; X=$geometry.Start.X; Y=$geometry.Start.Y }, + [pscustomobject]@{ Name='End'; X=$geometry.End.X; Y=$geometry.End.Y })) { + $diameter=10.0/$zoom; $handle=[System.Windows.Shapes.Ellipse]::new() + $handle.Width=$diameter; $handle.Height=$diameter + $handle.Fill=$resources['SnipMintBrush']; $handle.IsHitTestVisible=$false + $handle.Tag=[pscustomobject]@{ Role='SelectionHandle'; Handle=$endpoint.Name } + [System.Windows.Controls.Canvas]::SetLeft($handle,[double]$endpoint.X-$diameter/2) + [System.Windows.Controls.Canvas]::SetTop($handle,[double]$endpoint.Y-$diameter/2) + $selectionLayer.Children.Add($handle) | Out-Null + } + return + } + $bounds = Get-PreviewAnnotationBounds $selected + if ($null -eq $bounds) { return } + $selectionOutline = [System.Windows.Shapes.Rectangle]::new() + $selectionOutline.Width=$bounds.Width; $selectionOutline.Height=$bounds.Height + $selectionOutline.Stroke=$resources['SnipMintBrush'] + $selectionOutline.StrokeThickness=2.0/$zoom + $dashPattern=[System.Windows.Media.DoubleCollection]::new() + $dashPattern.Add(3.0/$zoom) + $dashPattern.Add(2.0/$zoom) + $selectionOutline.StrokeDashArray=$dashPattern + $selectionOutline.IsHitTestVisible=$false + $selectionOutline.Tag=[pscustomobject]@{ Role='SelectionOutline' } + [System.Windows.Controls.Canvas]::SetLeft($selectionOutline,$bounds.X) + [System.Windows.Controls.Canvas]::SetTop($selectionOutline,$bounds.Y) + $selectionLayer.Children.Add($selectionOutline) | Out-Null + Add-PreviewHandleSet -Layer $selectionLayer -Bounds $bounds ` + -Role SelectionHandle + } + + # Both WPF composition and compatibility bitmap output consume this same + # stable projection. Sorting the wrapper records leaves the authoritative + # annotation list and its canonical record identities untouched. + $getOrderedAnnotations = { + Initialize-SnipPreviewAnnotations ` + -Annotations $previewContext.Annotations | Out-Null + $entries = for ($index = 0; + $index -lt $previewContext.Annotations.Count; $index++) { + [pscustomobject]@{ + Annotation = $previewContext.Annotations[$index] + Index = $index + } + } + @($entries | Sort-Object ` + @{Expression={ [double]$_.Annotation.Z };Ascending=$true}, ` + @{Expression={ $_.Index };Ascending=$true} | + ForEach-Object Annotation) + }.GetNewClosure() + + function script:Render-Annotations { + $annotationLayer.Children.Clear() + foreach ($a in @(& $getOrderedAnnotations)) { + $geometry = $a.Geometry + $alpha = [int][math]::Round(255.0 * [math]::Max(0.0,[math]::Min(1.0,[double]$a.Opacity))) + $brush = Get-PreviewWpfBrush -Color ([string]$a.Color) -Alpha $alpha + switch ([string]$a.Kind) { + { $_ -in @('Highlight','Rectangle','Ellipse') } { + $shape = if ($a.Kind -eq 'Ellipse') { + [System.Windows.Shapes.Ellipse]::new() + } else { [System.Windows.Shapes.Rectangle]::new() } + $shape.Width=[double]$geometry.Width; $shape.Height=[double]$geometry.Height + if ($a.Kind -eq 'Highlight') { $shape.Fill=$brush } else { $shape.Stroke=$brush } + $shape.Stroke=$brush + $shape.StrokeThickness=[double]$a.StrokeWidth + $shape.IsHitTestVisible=$false + $shape.Tag=[pscustomobject]@{ Role='Annotation'; Id=$a.Id } + [System.Windows.Controls.Canvas]::SetLeft($shape,[double]$geometry.X) + [System.Windows.Controls.Canvas]::SetTop($shape,[double]$geometry.Y) + $annotationLayer.Children.Add($shape) | Out-Null + } + { $_ -in @('Arrow','Line') } { + $line=[System.Windows.Shapes.Line]::new() + $line.X1=[double]$geometry.Start.X; $line.Y1=[double]$geometry.Start.Y + $line.X2=[double]$geometry.End.X; $line.Y2=[double]$geometry.End.Y + $line.Stroke=$brush; $line.StrokeThickness=[double]$a.StrokeWidth + $line.StrokeStartLineCap='Round' + $line.StrokeEndLineCap=if($a.Kind -eq 'Arrow'){'Triangle'}else{'Round'} + $line.IsHitTestVisible=$false + $line.Tag=[pscustomobject]@{ Role='Annotation'; Id=$a.Id } + $annotationLayer.Children.Add($line) | Out-Null + } + 'Text' { + $textBlock=[System.Windows.Controls.TextBlock]::new() + $textBlock.Text=[string]$a.Properties.Text + $textBlock.FontFamily=[System.Windows.Media.FontFamily]::new('Segoe UI') + $textBlock.FontWeight=[System.Windows.FontWeights]::SemiBold + $textBlock.FontSize=[double]$a.Properties.FontSize + $textBlock.Foreground=$brush; $textBlock.IsHitTestVisible=$false + $textBlock.Tag=[pscustomobject]@{ Role='Annotation'; Id=$a.Id } + [System.Windows.Controls.Canvas]::SetLeft($textBlock,[double]$geometry.X) + [System.Windows.Controls.Canvas]::SetTop($textBlock,[double]$geometry.Y) + $annotationLayer.Children.Add($textBlock) | Out-Null + } + default { + if ($geometry.Type -eq 'Points') { + $polyline=[System.Windows.Shapes.Polyline]::new() + foreach ($point in @($geometry.Points)) { + $polyline.Points.Add([System.Windows.Point]::new($point.X,$point.Y)) + } + $polyline.Stroke=$brush; $polyline.StrokeThickness=[double]$a.StrokeWidth + $polyline.IsHitTestVisible=$false + $polyline.Tag=[pscustomobject]@{ Role='Annotation'; Id=$a.Id } + $annotationLayer.Children.Add($polyline) | Out-Null + } + } + } + } + Render-PreviewInteraction + } + + function script:Trim-SnipStack { + # Cap a Stack to its $Max most recent entries (oldest drop off the bottom). + param($Stack, [int]$Max = $script:UndoStackMaxDepth) + if ($Stack.Count -le $Max) { return } + $keep = Get-TrimmedRecent -Items $Stack.ToArray() -MaxDepth $Max + $Stack.Clear() + for ($i = $keep.Count - 1; $i -ge 0; $i--) { [void]$Stack.Push($keep[$i]) } + } + + function script:Snapshot-State { + $previewContext.UndoStack.Push((New-SnipEditorSnapshot ` + -Annotations $previewContext.Annotations ` + -CropRectangle $previewContext.CropRectangle)) + $previewContext.RedoStack.Clear() + Trim-SnipStack $previewContext.UndoStack + } + + function script:Restore-State { + param($snapshot) + $restored = New-SnipEditorSnapshot -Annotations $snapshot.Annotations ` + -CropRectangle $snapshot.CropRectangle + $previewContext.Annotations.Clear() + foreach ($a in $restored.Annotations) { + [void]$previewContext.Annotations.Add($a) + } + $previewContext.CropRectangle = $restored.CropRectangle + $state.CropRectangle = $previewContext.CropRectangle + $previewContext.Draft = $null; $state.Draft = $null + $previewContext.Interaction = $null + if ($null -eq (Get-PreviewAnnotationById $previewContext.SelectedAnnotationId)) { + $previewContext.SelectedAnnotationId = $null + $state.SelectionId = $null + } + Render-Annotations + } + + function script:Do-Undo { + if ($previewContext.UndoStack.Count -eq 0) { return } + $previewContext.RedoStack.Push((New-SnipEditorSnapshot ` + -Annotations $previewContext.Annotations ` + -CropRectangle $previewContext.CropRectangle)) + Trim-SnipStack $previewContext.RedoStack + Restore-State $previewContext.UndoStack.Pop() + } + + function script:Do-Redo { + if ($previewContext.RedoStack.Count -eq 0) { return } + $previewContext.UndoStack.Push((New-SnipEditorSnapshot ` + -Annotations $previewContext.Annotations ` + -CropRectangle $previewContext.CropRectangle)) + Trim-SnipStack $previewContext.UndoStack + Restore-State $previewContext.RedoStack.Pop() + } + + # Named color picker. Tests and the real swatch click handler both call + # this. Also live-updates the foreground of any text box that is + # currently being edited, so the user sees the color change immediately. + $pickColor = { + param([string]$Name) + if (-not $palette.Contains($Name)) { return } + $state.ActiveColor = $Name + if ($state.EditingText) { + foreach ($child in $highlightLayer.Children) { + if ($child -is [System.Windows.Controls.TextBox]) { + $rgbL = $palette[$Name] + $child.Foreground = New-Object System.Windows.Media.SolidColorBrush( + (To-WpfColor 255 $rgbL.R $rgbL.G $rgbL.B)) + $child.BorderBrush = New-Object System.Windows.Media.SolidColorBrush( + (To-WpfColor 200 $rgbL.R $rgbL.G $rgbL.B)) + break + } + } + } + # Update the active swatch in-place instead of rebuilding the bar. + # Rebuilding would re-attach click handlers, and because $pickColor + # self-reference inside its own body resolves to $null (the closure + # captured it before assignment completed), the rebuilt handlers + # would carry a dead reference. Keep the original handlers alive. + $accent = [System.Windows.Media.Color]::FromArgb(255, 0x5B, 0x8D, 0xEF) + foreach ($ring in $colorBar.Children) { + if ($ring.Tag -eq $Name) { + $ring.BorderBrush = New-Object System.Windows.Media.SolidColorBrush($accent) + } else { + $ring.BorderBrush = [System.Windows.Media.Brushes]::Transparent + } + } + }.GetNewClosure() + + # Build color swatches. + # $pickColor is passed explicitly: `function script:` creates a new scope + # that does NOT inherit Show-PreviewWindow's locals for closure purposes, + # so a { & $pickColor ... }.GetNewClosure() inside this body would capture + # $null. Accept it as a parameter so the closure has a real reference. + function script:Build-ColorBar { + param([scriptblock]$pickColor) + $colorBar.Children.Clear() + # Circular swatches with a 2px accent outline ring on the active one. + # Fixed size (no jump) — the ring lives on an outer Border + padding. + $accentColor = [System.Windows.Media.Color]::FromArgb(255, 0x5B, 0x8D, 0xEF) + foreach ($name in $palette.Keys) { + $rgb = $palette[$name] + $isActive = ($state.ActiveColor -eq $name) + + $ring = New-Object System.Windows.Controls.Border + $ring.Width = 22 + $ring.Height = 22 + $ring.CornerRadius = New-Object System.Windows.CornerRadius 11 + $ring.Margin = New-Object System.Windows.Thickness 3, 0, 3, 0 + $ring.Padding = New-Object System.Windows.Thickness 2 + if ($isActive) { + $ring.BorderBrush = New-Object System.Windows.Media.SolidColorBrush($accentColor) + $ring.BorderThickness = New-Object System.Windows.Thickness 2 + } else { + $ring.BorderBrush = [System.Windows.Media.Brushes]::Transparent + $ring.BorderThickness = New-Object System.Windows.Thickness 2 + } + $ring.Cursor = [System.Windows.Input.Cursors]::Hand + # Non-focusable so clicking a swatch while a text box is open + # doesn't steal keyboard focus (which would fire LostFocus → + # commit the text in the OLD color before we can update it). + $ring.Focusable = $false + $ring.ToolTip = $name + $ring.Tag = $name + + $dot = New-Object System.Windows.Controls.Border + $dot.Width = 14 + $dot.Height = 14 + $dot.CornerRadius = New-Object System.Windows.CornerRadius 7 + $dot.Background = New-Object System.Windows.Media.SolidColorBrush( + (To-WpfColor 255 $rgb.R $rgb.G $rgb.B)) + $ring.Child = $dot + + $ring.Add_MouseLeftButtonDown({ & $pickColor $this.Tag }.GetNewClosure()) + [void]$colorBar.Children.Add($ring) + } + } + Build-ColorBar $pickColor + foreach ($colorName in @('Yellow','Green','Pink','Blue','Orange','Red')) { + $selectedColor = $colorName + $studioShell.ColorMenuItems[$colorName].Add_Click({ + & $pickColor $selectedColor + }.GetNewClosure()) + $studioShell.MoreColorMenuItems[$colorName].Add_Click({ + & $pickColor $selectedColor + & $studioShell.CloseMoreMenu + }.GetNewClosure()) + } + + # Tool toggle interlock — at most one tool active. No tool = pan (Hand) mode. + $tools = @($highlightBtn, $rectBtn, $arrowBtn, $textBtn) + foreach ($t in $tools) { + $t.Add_Checked({ + $me = $this + foreach ($other in $tools) { if ($other -ne $me) { $other.IsChecked = $false } } + }.GetNewClosure()) + } + # No tool checked by default → pan mode is active. + # Note: $scroller is resolved later (XAML lookup). We bind the cursor + # refresh to tool-button state changes so it stays in sync as the + # user toggles tools on/off. + $updateCursor = { + $anyTool = $highlightBtn.IsChecked -or $rectBtn.IsChecked -or + $arrowBtn.IsChecked -or $textBtn.IsChecked + $highlightLayer.Cursor = if ($anyTool) { + [System.Windows.Input.Cursors]::Cross + } else { + [System.Windows.Input.Cursors]::Hand + } + }.GetNewClosure() + foreach ($t in $tools) { + $t.Add_Checked($updateCursor) + $t.Add_Unchecked($updateCursor) + } + & $updateCursor # initial: Hand (no tool) + + $setStudioTool = { + param([string]$Tool) + if ($previewContext.ActiveTool -ne $Tool -and $previewContext.CancelDraft) { + & $previewContext.CancelDraft + } + foreach ($buttonName in @('Select','Crop','Pen','Steps')) { + $button = $previewContext.ToolControls[$buttonName] + $button.Background = [System.Windows.Media.Brushes]::Transparent + $button.BorderBrush = $resources['SnipHairlineBrush'] + } + foreach ($splitName in @('ArrowLine','RectangleEllipse','BlurPixelate')) { + $splitButton = $previewContext.SplitControls[$splitName].PrimaryButton + $splitButton.Background = [System.Windows.Media.Brushes]::Transparent + $splitButton.BorderBrush = $resources['SnipHairlineBrush'] + } + switch ($Tool) { + 'Highlight' { $highlightBtn.IsChecked = $true } + 'RectangleEllipse' { $rectBtn.IsChecked = $true } + 'ArrowLine' { $arrowBtn.IsChecked = $true } + 'Text' { $textBtn.IsChecked = $true } + default { + foreach ($legacyTool in $tools) { $legacyTool.IsChecked = $false } + } + } + $activeButton = switch ($Tool) { + 'Select' { $previewContext.ToolControls.Select } + 'Crop' { $previewContext.ToolControls.Crop } + 'Pen' { $previewContext.ToolControls.Pen } + 'Steps' { $previewContext.ToolControls.Steps } + 'ArrowLine' { $previewContext.SplitControls.ArrowLine.PrimaryButton } + 'RectangleEllipse' { $previewContext.SplitControls.RectangleEllipse.PrimaryButton } + 'BlurPixelate' { $previewContext.SplitControls.BlurPixelate.PrimaryButton } + default { $null } + } + if ($null -ne $activeButton) { + $activeButton.Background = $resources['SnipAccentTintBrush'] + $activeButton.BorderBrush = $resources['SnipAccentBrush'] + } + $previewContext.ActiveTool = $Tool + $state.ActiveStudioTool = $Tool + if ($Tool -in @('Highlight','ArrowLine','RectangleEllipse','Steps')) { + [void]$previewContext.RecentTools.Remove($Tool) + $previewContext.RecentTools.Insert(0, $Tool) + while ($previewContext.RecentTools.Count -gt 2) { + $previewContext.RecentTools.RemoveAt($previewContext.RecentTools.Count - 1) + } + } + if ($Tool -in @('Select','Crop','Pen','Highlight','ArrowLine','RectangleEllipse','Text','Steps','BlurPixelate')) { + Set-SnipPropertyIsland -Context $previewContext -Tool $Tool | Out-Null + } + Set-SnipPreviewResponsiveMode -Context $previewContext ` + -Width $win.ActualWidth -Height $win.ActualHeight | Out-Null + }.GetNewClosure() + $highlightBtn.Add_Checked({ & $setStudioTool 'Highlight' }.GetNewClosure()) + $textBtn.Add_Checked({ & $setStudioTool 'Text' }.GetNewClosure()) + $arrowBtn.Add_Checked({ & $setStudioTool 'ArrowLine' }.GetNewClosure()) + $rectBtn.Add_Checked({ & $setStudioTool 'RectangleEllipse' }.GetNewClosure()) + foreach ($legacySpec in @( + [pscustomobject]@{ Button=$highlightBtn; Tool='Highlight' }, + [pscustomobject]@{ Button=$textBtn; Tool='Text' }, + [pscustomobject]@{ Button=$arrowBtn; Tool='ArrowLine' }, + [pscustomobject]@{ Button=$rectBtn; Tool='RectangleEllipse' })) { + $legacyToolName=$legacySpec.Tool + $legacySpec.Button.Add_Unchecked({ + $anyLegacy=$highlightBtn.IsChecked -or $rectBtn.IsChecked -or + $arrowBtn.IsChecked -or $textBtn.IsChecked + if(-not $anyLegacy -and $previewContext.ActiveTool -eq $legacyToolName){ + $previewContext.ActiveTool=$null + $state.ActiveStudioTool=$null + } + }.GetNewClosure()) + } + + $previewContext.SplitControls.ArrowLine.PrimaryButton.Add_Click({ + & $setStudioTool 'ArrowLine' + }.GetNewClosure()) + $previewContext.SplitControls.RectangleEllipse.PrimaryButton.Add_Click({ + & $setStudioTool 'RectangleEllipse' + }.GetNewClosure()) + $previewContext.SplitControls.BlurPixelate.PrimaryButton.Add_Click({ + & $setStudioTool 'BlurPixelate' + }.GetNewClosure()) + foreach ($subtype in @('Arrow','Line')) { + $selectedSubtype = $subtype + $previewContext.SplitControls.ArrowLine.MenuItems[$subtype].Add_Click({ + $previewContext.SplitControls.ArrowLine.DefaultCommand = $selectedSubtype + $previewContext.SplitControls.ArrowLine.Label.Text = if ($selectedSubtype -eq 'Arrow') { '↗' } else { '╱' } + [System.Windows.Automation.AutomationProperties]::SetName( + $previewContext.SplitControls.ArrowLine.PrimaryButton, + "Arrow or Line tool, selected subtype $selectedSubtype") + & $setStudioTool 'ArrowLine' + }.GetNewClosure()) + } + foreach ($subtype in @('Rectangle','Ellipse')) { + $selectedSubtype = $subtype + $previewContext.SplitControls.RectangleEllipse.MenuItems[$subtype].Add_Click({ + $previewContext.SplitControls.RectangleEllipse.DefaultCommand = $selectedSubtype + $previewContext.SplitControls.RectangleEllipse.Label.Text = if ($selectedSubtype -eq 'Rectangle') { '□' } else { '○' } + [System.Windows.Automation.AutomationProperties]::SetName( + $previewContext.SplitControls.RectangleEllipse.PrimaryButton, + "Rectangle or Ellipse tool, selected subtype $selectedSubtype") + & $setStudioTool 'RectangleEllipse' + }.GetNewClosure()) + } + foreach ($subtype in @('Blur','Pixelate')) { + $selectedSubtype = $subtype + $previewContext.SplitControls.BlurPixelate.MenuItems[$subtype].Add_Click({ + $previewContext.SplitControls.BlurPixelate.DefaultCommand = $selectedSubtype + $previewContext.SplitControls.BlurPixelate.Label.Text = if ($selectedSubtype -eq 'Blur') { '▒' } else { '▦' } + [System.Windows.Automation.AutomationProperties]::SetName( + $previewContext.SplitControls.BlurPixelate.PrimaryButton, + "Blur or Pixelate tool, selected subtype $selectedSubtype") + & $setStudioTool 'BlurPixelate' + }.GetNewClosure()) + } + $previewContext.ToolControls.Select.Add_Click({ & $setStudioTool 'Select' }.GetNewClosure()) + $previewContext.ToolControls.Crop.Add_Click({ & $setStudioTool 'Crop' }.GetNewClosure()) + $previewContext.ToolControls.Pen.Add_Click({ & $setStudioTool 'Pen' }.GetNewClosure()) + $previewContext.ToolControls.Steps.Add_Click({ & $setStudioTool 'Steps' }.GetNewClosure()) + foreach ($toolName in @('Highlight','ArrowLine','RectangleEllipse','Steps')) { + $selectedTool = $toolName + $studioShell.MoreMenuItems[$toolName].Add_Click({ + & $setStudioTool $selectedTool + }.GetNewClosure()) + } + # ---- Named core helpers (closures so tests can drive them directly) ---- + + $beginPan = { + param([System.Windows.Point]$SvPoint) + $state.Panning = $true + $state.PanStartSv = $SvPoint + $state.PanOrigX = $scroller.HorizontalOffset + $state.PanOrigY = $scroller.VerticalOffset + $highlightLayer.Cursor = [System.Windows.Input.Cursors]::SizeAll + try { $highlightLayer.CaptureMouse() | Out-Null } catch {} + }.GetNewClosure() + + $updatePan = { + param([System.Windows.Point]$SvPoint) + if (-not $state.Panning) { return } + $dx = $SvPoint.X - $state.PanStartSv.X + $dy = $SvPoint.Y - $state.PanStartSv.Y + $scroller.ScrollToHorizontalOffset($state.PanOrigX - $dx) + $scroller.ScrollToVerticalOffset( $state.PanOrigY - $dy) + }.GetNewClosure() + + $endPan = { + $state.TemporaryPan = $false + if (-not $state.Panning) { + $highlightLayer.Cursor = [System.Windows.Input.Cursors]::Hand + return + } + $state.Panning = $false + try { $highlightLayer.ReleaseMouseCapture() } catch {} + $highlightLayer.Cursor = [System.Windows.Input.Cursors]::Hand + }.GetNewClosure() + + # Context owns annotation draft semantics and the active interaction. + # The legacy state fields are kept as reference mirrors for older preview + # helpers and the interactive harness; no caller writes them independently. + $setAnnotationDraft = { + param([AllowNull()]$Draft) + $clearedAnnotation = $null -eq $Draft -and + $null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Annotation' + $previewContext.Draft = $Draft + $state.Draft = $Draft + if ($null -eq $Draft) { + if ($null -ne $previewContext.Interaction -and + $previewContext.Interaction.Kind -eq 'Annotation') { + $previewContext.Interaction = $null + } + $state.Drawing = $false + $state.DrawingTool = $null + $state.AnchorCanvas = $null + $state.DraftRect = $null + if ($clearedAnnotation) { + $previewContext.AnnotationDraftClearCount++ + } + return + } + $previewContext.Interaction = [pscustomobject][ordered]@{ + Kind = 'Annotation' + Mode = 'Create' + Draft = $Draft + } + $state.Drawing = $true + $state.DrawingTool = $Draft.Tool + $state.AnchorCanvas = $Draft.Anchor + $state.DraftRect = $Draft.Visual + }.GetNewClosure() + + $beginDraw = { + param([string]$Tool, [System.Windows.Point]$P) + $rgb = $palette[$state.ActiveColor] + $visual = $null + $kind = if ($Tool -eq 'arrow') { + 'Arrow' + } elseif ($Tool -eq 'highlight') { + 'Highlight' + } else { 'Rectangle' } + $geometry = if ($Tool -eq 'arrow') { + [pscustomobject][ordered]@{ + Type='Line' + Start=[pscustomobject]@{ X=$P.X; Y=$P.Y } + End=[pscustomobject]@{ X=$P.X; Y=$P.Y } + } + } else { + [pscustomobject][ordered]@{ + Type='Bounds'; X=$P.X; Y=$P.Y; Width=0.0; Height=0.0 + } + } + if ($Tool -eq 'arrow') { + $line = New-Object System.Windows.Shapes.Line + $line.X1 = $P.X; $line.Y1 = $P.Y; $line.X2 = $P.X; $line.Y2 = $P.Y + $line.Stroke = New-Object System.Windows.Media.SolidColorBrush( + (To-WpfColor 255 $rgb.R $rgb.G $rgb.B)) + $line.StrokeThickness = 4 + $line.StrokeStartLineCap = 'Round' + $line.StrokeEndLineCap = 'Triangle' + $line.IsHitTestVisible = $false + [void]$interactionLayer.Children.Add($line) + $visual = $line + } else { + $shape = New-Object System.Windows.Shapes.Rectangle + if ($Tool -eq 'highlight') { + $shape.Fill = New-Object System.Windows.Media.SolidColorBrush( + (To-WpfColor 110 $rgb.R $rgb.G $rgb.B)) + } + $shape.Stroke = New-Object System.Windows.Media.SolidColorBrush( + (To-WpfColor 220 $rgb.R $rgb.G $rgb.B)) + $shape.StrokeThickness = if ($Tool -eq 'rect') { 3 } else { 1.5 } + $shape.IsHitTestVisible = $false + [System.Windows.Controls.Canvas]::SetLeft($shape, $P.X) + [System.Windows.Controls.Canvas]::SetTop($shape, $P.Y) + $shape.Width = 0; $shape.Height = 0 + [void]$interactionLayer.Children.Add($shape) + $visual = $shape + } + $opacity = if ($Tool -eq 'highlight') { 110.0 / 255.0 } else { 1.0 } + $strokeWidth = if ($Tool -eq 'arrow') { + 4.0 + } elseif ($Tool -eq 'highlight') { 1.5 } else { 3.0 } + & $setAnnotationDraft ([pscustomobject][ordered]@{ + Kind='Annotation'; Tool=$Tool; RecordKind=$kind; Anchor=$P + Candidate=[pscustomobject][ordered]@{ + Kind=$kind; Geometry=$geometry; Color=$state.ActiveColor + StrokeWidth=$strokeWidth; Opacity=$opacity + Properties=[ordered]@{} + } + Visual=$visual + }) + try { $highlightLayer.CaptureMouse() | Out-Null } catch {} + }.GetNewClosure() + + $updateDraw = { + param([System.Windows.Point]$P) + $draft = $previewContext.Draft + if ($null -eq $draft -or $draft.Kind -ne 'Annotation' -or + $null -eq $draft.Visual) { return } + if ($draft.Tool -eq 'arrow') { + $draft.Visual.X2 = $P.X + $draft.Visual.Y2 = $P.Y + $draft.Candidate.Geometry.End = [pscustomobject]@{ X=$P.X; Y=$P.Y } + } else { + $r = Get-DragRectangle -AnchorX $draft.Anchor.X -AnchorY $draft.Anchor.Y ` + -CurrentX $P.X -CurrentY $P.Y + [System.Windows.Controls.Canvas]::SetLeft($draft.Visual, $r.X) + [System.Windows.Controls.Canvas]::SetTop($draft.Visual, $r.Y) + $draft.Visual.Width = $r.Width + $draft.Visual.Height = $r.Height + $draft.Candidate.Geometry = [pscustomobject][ordered]@{ + Type='Bounds'; X=$r.X; Y=$r.Y; Width=$r.Width; Height=$r.Height + } + } + }.GetNewClosure() + + $getNextAnnotationZ = { + if ($previewContext.Annotations.Count -eq 0) { return 0.0 } + $maximum = @($previewContext.Annotations | ForEach-Object { + if ($null -ne $_.PSObject.Properties['Z']) { [double]$_.Z } else { 0.0 } + } | Measure-Object -Maximum).Maximum + [double]$maximum + 1.0 + }.GetNewClosure() + + $openText = { + param([System.Windows.Point]$P) + $b = Get-DisplayedImageBounds + if (-not $b) { return $null } + + # Locals the inner $commit closure needs to capture. PS's chained + # GetNewClosure() does not propagate outer-closure captures into a + # nested .GetNewClosure(), so we must materialize them as real + # locals here before creating $commit. + $stateL = $state + $winL = $win + $hlLayerL = $highlightLayer + $textBtnL = $textBtn + $paletteL = $palette + $bL = $b + $getNextAnnotationZL = $getNextAnnotationZ + + $tb = New-Object System.Windows.Controls.TextBox + $tb.Background = New-Object System.Windows.Media.SolidColorBrush( + ([System.Windows.Media.Color]::FromArgb(180, 30, 30, 30))) + $rgb = $palette[$state.ActiveColor] + $tb.Foreground = New-Object System.Windows.Media.SolidColorBrush( + (To-WpfColor 255 $rgb.R $rgb.G $rgb.B)) + $tb.BorderBrush = New-Object System.Windows.Media.SolidColorBrush( + (To-WpfColor 200 $rgb.R $rgb.G $rgb.B)) + $tb.BorderThickness = New-Object System.Windows.Thickness 1 + $tb.FontFamily = New-Object System.Windows.Media.FontFamily 'Segoe UI' + $tb.FontWeight = [System.Windows.FontWeights]::SemiBold + $tb.FontSize = 18 + $tb.Padding = New-Object System.Windows.Thickness 4, 1, 4, 1 + $tb.MinWidth = 80 + [System.Windows.Controls.Canvas]::SetLeft($tb, $P.X) + [System.Windows.Controls.Canvas]::SetTop($tb, $P.Y) + [void]$highlightLayer.Children.Add($tb) + $state.EditingText = $true + + # Reentrance guard: ClearFocus / Children.Remove can synchronously + # fire LostFocus on the TextBox and recurse back into $commit. Using + # a hashtable field so the mutation propagates across invocations. + $commitGuard = @{ Done = $false } + $commit = { + if ($commitGuard.Done) { return } + $commitGuard.Done = $true + $stateL.EditingText = $false + $text = $tb.Text + try { [System.Windows.Input.Keyboard]::ClearFocus() } catch {} + try { [System.Windows.Input.Mouse]::Capture($null) } catch {} + try { $winL.Focus() | Out-Null } catch {} + [void]$hlLayerL.Children.Remove($tb) + if ([string]::IsNullOrWhiteSpace($text)) { return } + $imgX = [int][math]::Round(($P.X - $bL.X) / $bL.Scale) + $imgY = [int][math]::Round(($P.Y - $bL.Y) / $bL.Scale) + $fontSize = [int][math]::Round(18 / $bL.Scale) + Snapshot-State + $textWidth = [math]::Max(1,[int][math]::Ceiling( + $text.Length * $fontSize * 0.6)) + $textHeight = [math]::Max(1,[int][math]::Ceiling($fontSize * 1.3)) + [void]$stateL.Annotations.Add((New-SnipAnnotation -Kind Text ` + -Geometry ([pscustomobject]@{ + Type='TextBounds'; X=$imgX; Y=$imgY + Width=$textWidth; Height=$textHeight + }) -Color $stateL.ActiveColor -StrokeWidth 1 -Opacity 1 ` + -Properties ([ordered]@{ Text=$text; FontSize=$fontSize }) ` + -Z (& $getNextAnnotationZL))) + Render-Annotations + }.GetNewClosure() + + $tb.Add_KeyDown({ + if ($_.Key -eq 'Enter') { + & $commit + $textBtnL.IsChecked = $false + $_.Handled = $true + } + elseif ($_.Key -eq 'Escape') { + $commitGuard.Done = $true + $stateL.EditingText = $false + [void]$hlLayerL.Children.Remove($tb) + try { [System.Windows.Input.Keyboard]::ClearFocus() } catch {} + try { $hlLayerL.Focus() | Out-Null } catch {} + $_.Handled = $true + } + }.GetNewClosure()) + $tb.Add_LostFocus({ & $commit }.GetNewClosure()) + try { $tb.Focus() | Out-Null } catch {} + # Tests and external callers can drive commit via $tb.Tag + $tb.Tag = $commit + return $tb + }.GetNewClosure() + + $finishDraw = { + $draft = $previewContext.Draft + if ($null -eq $draft -or $draft.Kind -ne 'Annotation') { return } + $visual = $draft.Visual + if ($null -ne $visual) { + [void]$interactionLayer.Children.Remove($visual) + } + & $setAnnotationDraft $null + try { $highlightLayer.ReleaseMouseCapture() } catch {} + $b = Get-DisplayedImageBounds + if (-not $b) { + Render-PreviewInteraction + return + } + $candidate = $draft.Candidate + if ($draft.Tool -eq 'arrow') { + $start = $candidate.Geometry.Start + $end = $candidate.Geometry.End + $dx = $end.X - $start.X; $dy = $end.Y - $start.Y + if ([math]::Sqrt($dx * $dx + $dy * $dy) -lt 6) { + Render-PreviewInteraction + return + } + $x1 = [int][math]::Round(($start.X - $b.X) / $b.Scale) + $y1 = [int][math]::Round(($start.Y - $b.Y) / $b.Scale) + $x2 = [int][math]::Round(($end.X - $b.X) / $b.Scale) + $y2 = [int][math]::Round(($end.Y - $b.Y) / $b.Scale) + Snapshot-State + [void]$previewContext.Annotations.Add((New-SnipAnnotation -Kind Arrow ` + -Geometry ([pscustomobject]@{ + Type='Line' + Start=[pscustomobject]@{ X=$x1; Y=$y1 } + End=[pscustomobject]@{ X=$x2; Y=$y2 } + }) -Color $candidate.Color -StrokeWidth $candidate.StrokeWidth ` + -Opacity $candidate.Opacity ` + -Properties ([ordered]@{}) -Z (& $getNextAnnotationZ))) + Render-Annotations + return + } + if ($candidate.Geometry.Width -lt 3 -or $candidate.Geometry.Height -lt 3) { + Render-PreviewInteraction + return + } + $canvasX = [double]$candidate.Geometry.X + $canvasY = [double]$candidate.Geometry.Y + $rawX = [int][math]::Round(($canvasX - $b.X) / $b.Scale) + $rawY = [int][math]::Round(($canvasY - $b.Y) / $b.Scale) + $rawW = [int][math]::Round($candidate.Geometry.Width / $b.Scale) + $rawH = [int][math]::Round($candidate.Geometry.Height / $b.Scale) + $clamped = Get-ClampedAnnotationRect -X $rawX -Y $rawY -Width $rawW -Height $rawH ` + -BitmapWidth $Bitmap.Width -BitmapHeight $Bitmap.Height + Snapshot-State + [void]$previewContext.Annotations.Add((New-SnipAnnotation ` + -Kind $candidate.Kind ` + -Geometry ([pscustomobject]@{ + Type='Bounds'; X=$clamped.X; Y=$clamped.Y + Width=$clamped.Width; Height=$clamped.Height + }) -Color $candidate.Color -StrokeWidth $candidate.StrokeWidth ` + -Opacity $candidate.Opacity -Properties $candidate.Properties ` + -Z (& $getNextAnnotationZ))) + Render-Annotations + }.GetNewClosure() + + $setSelectedAnnotation = { + param([AllowNull()][string]$Id) + if (-not [string]::IsNullOrWhiteSpace($Id) -and + $null -eq (Get-PreviewAnnotationById $Id)) { + $Id = $null + } + $previewContext.SelectedAnnotationId = $Id + $state.SelectionId = $Id + if ($previewContext.ActiveTool -eq 'Select') { + Set-SnipPropertyIsland -Context $previewContext -Tool Select | Out-Null + } + Render-PreviewInteraction + $Id + }.GetNewClosure() + + $getResizeHandleAt = { + param([System.Windows.Point]$Point) + $annotation = Get-PreviewAnnotationById $previewContext.SelectedAnnotationId + if ($null -eq $annotation) { return $null } + $zoom = if ($layoutScale.ScaleX -gt 0) { [double]$layoutScale.ScaleX } else { 1.0 } + $tolerance = 7.0 / $zoom + if ($annotation.Geometry.Type -eq 'Line') { + foreach ($endpoint in @( + [pscustomobject]@{ Name='Start'; Point=$annotation.Geometry.Start }, + [pscustomobject]@{ Name='End'; Point=$annotation.Geometry.End })) { + $dx=$Point.X-[double]$endpoint.Point.X + $dy=$Point.Y-[double]$endpoint.Point.Y + if ([math]::Sqrt($dx*$dx+$dy*$dy) -le $tolerance) { + return $endpoint.Name + } + } + return $null + } + $bounds = Get-PreviewAnnotationBounds $annotation + if ($null -eq $bounds) { return $null } + $centers = [ordered]@{ + TopLeft=@(([double]$bounds.X),([double]$bounds.Y)) + Top=@(([double]$bounds.X+([double]$bounds.Width/2.0)),([double]$bounds.Y)) + TopRight=@(([double]$bounds.X+[double]$bounds.Width),([double]$bounds.Y)) + Right=@(([double]$bounds.X+[double]$bounds.Width), + ([double]$bounds.Y+([double]$bounds.Height/2.0))) + BottomRight=@(([double]$bounds.X+[double]$bounds.Width), + ([double]$bounds.Y+[double]$bounds.Height)) + Bottom=@(([double]$bounds.X+([double]$bounds.Width/2.0)), + ([double]$bounds.Y+[double]$bounds.Height)) + BottomLeft=@(([double]$bounds.X),([double]$bounds.Y+[double]$bounds.Height)) + Left=@(([double]$bounds.X), + ([double]$bounds.Y+([double]$bounds.Height/2.0))) + } + foreach ($entry in $centers.GetEnumerator()) { + $dx=$Point.X-[double]$entry.Value[0] + $dy=$Point.Y-[double]$entry.Value[1] + if ([math]::Sqrt($dx*$dx+$dy*$dy) -le $tolerance) { + return [string]$entry.Key + } + } + $null + }.GetNewClosure() + + $beginSelectGesture = { + param([System.Windows.Point]$Point) + $highlightLayer.Focus() | Out-Null + $handle = & $getResizeHandleAt $Point + $selectedId = $previewContext.SelectedAnnotationId + if ($null -eq $handle) { + $previewContext.LastHitRoute = 'Find-SnipAnnotation' + $selectedId = Select-SnipAnnotation -Annotations $previewContext.Annotations ` + -ImageX $Point.X -ImageY $Point.Y ` + -Tolerance (6.0/[math]::Max(0.05,[double]$layoutScale.ScaleX)) + if ([string]::IsNullOrWhiteSpace([string]$selectedId)) { + & $setSelectedAnnotation $null | Out-Null + $previewContext.Interaction = $null + return + } + & $setSelectedAnnotation $selectedId | Out-Null + } + $original = Get-PreviewAnnotationById $selectedId + if ($null -eq $original) { return } + $previewContext.Interaction = [pscustomobject][ordered]@{ + Kind='Select'; Mode=if($null -ne $handle){'Resize'}else{'Move'} + Handle=$handle; Start=$Point + Original=(Copy-SnipAnnotation -Annotation $original) + Candidate=(Copy-SnipAnnotation -Annotation $original) + Changed=$false + } + try { $highlightLayer.CaptureMouse() | Out-Null } catch {} + Render-PreviewInteraction + }.GetNewClosure() + + $updateSelectGesture = { + param([System.Windows.Point]$Point) + $interaction = $previewContext.Interaction + if ($null -eq $interaction -or $interaction.Kind -ne 'Select') { return } + $deltaX=[int][math]::Round($Point.X-[double]$interaction.Start.X) + $deltaY=[int][math]::Round($Point.Y-[double]$interaction.Start.Y) + try { + $candidate = if ($interaction.Mode -eq 'Resize') { + Resize-SnipAnnotation -Annotation $interaction.Original ` + -Handle $interaction.Handle -DeltaX $deltaX -DeltaY $deltaY ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height + } else { + Move-SnipAnnotation -Annotation $interaction.Original ` + -DeltaX $deltaX -DeltaY $deltaY ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height + } + } catch [System.ArgumentException] { + # A transient pointer sample may exactly collapse a Line or Points + # extent. Keep the last valid immutable candidate; release or a + # later valid sample remains safe and unexpected faults still escape. + Render-PreviewInteraction + return + } + $interaction.Candidate = $candidate + $interaction.Changed = -not (Test-PreviewAnnotationEqual ` + $interaction.Original $candidate) + Render-PreviewInteraction + }.GetNewClosure() + + $completeSelectGesture = { + $interaction = $previewContext.Interaction + if ($null -eq $interaction -or $interaction.Kind -ne 'Select') { return } + $previewContext.Interaction = $null + try { $highlightLayer.ReleaseMouseCapture() } catch {} + if ($interaction.Changed) { + $index = Get-PreviewAnnotationIndexById $interaction.Original.Id + if ($index -ge 0) { + Snapshot-State + $previewContext.Annotations[$index] = + Copy-SnipAnnotation -Annotation $interaction.Candidate + } + } + Render-Annotations + }.GetNewClosure() + + $cancelSelectGesture = { + if ($null -ne $previewContext.Interaction -and + $previewContext.Interaction.Kind -eq 'Select') { + $previewContext.Interaction = $null + try { $highlightLayer.ReleaseMouseCapture() } catch {} + Render-PreviewInteraction + } + }.GetNewClosure() + + $getCropHandleAt = { + param([System.Windows.Point]$Point) + $crop = if ($null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Crop') { + $previewContext.Draft.Candidate + } else { $previewContext.CropRectangle } + if ($null -eq $crop) { return $null } + $zoom=[math]::Max(0.05,[double]$layoutScale.ScaleX) + $tolerance=7.0/$zoom + $centers=[ordered]@{ + TopLeft=@(([double]$crop.X),([double]$crop.Y)) + Top=@(([double]$crop.X+([double]$crop.Width/2.0)),([double]$crop.Y)) + TopRight=@(([double]$crop.X+[double]$crop.Width),([double]$crop.Y)) + Right=@(([double]$crop.X+[double]$crop.Width), + ([double]$crop.Y+([double]$crop.Height/2.0))) + BottomRight=@(([double]$crop.X+[double]$crop.Width), + ([double]$crop.Y+[double]$crop.Height)) + Bottom=@(([double]$crop.X+([double]$crop.Width/2.0)), + ([double]$crop.Y+[double]$crop.Height)) + BottomLeft=@(([double]$crop.X),([double]$crop.Y+[double]$crop.Height)) + Left=@(([double]$crop.X), + ([double]$crop.Y+([double]$crop.Height/2.0))) + } + foreach($entry in $centers.GetEnumerator()){ + $dx=$Point.X-[double]$entry.Value[0] + $dy=$Point.Y-[double]$entry.Value[1] + if([math]::Sqrt($dx*$dx+$dy*$dy) -le $tolerance){return [string]$entry.Key} + } + $null + }.GetNewClosure() + + $beginCropDraft = { + param([System.Windows.Point]$Point) + $highlightLayer.Focus() | Out-Null + $resizeHandle=& $getCropHandleAt $Point + if($null -ne $resizeHandle){ + $existing=if($null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Crop'){ + $previewContext.Draft.Candidate + }else{$previewContext.CropRectangle} + $baseCandidate=[pscustomobject]@{ + X=[int]$existing.X;Y=[int]$existing.Y + Width=[int]$existing.Width;Height=[int]$existing.Height + } + $previewContext.Draft=[pscustomobject][ordered]@{ + Kind='Crop';Anchor=$Point;BaseCandidate=$baseCandidate + OriginalCandidate=$baseCandidate;Candidate=(Get-SnipCropRectangle ` + -Candidate $baseCandidate -SourceWidth $Bitmap.Width ` + -SourceHeight $Bitmap.Height -Preset Free) + Preset=$previewContext.ToolProperties.Crop.Preset + ResizeHandle=$resizeHandle + } + $state.Draft=$previewContext.Draft + $previewContext.Interaction=[pscustomobject]@{Kind='Crop'} + try{$highlightLayer.CaptureMouse()|Out-Null}catch{} + Render-PreviewInteraction + return + } + $anchor = [System.Windows.Point]::new( + [math]::Max(0,[math]::Min($Bitmap.Width-1,[int][math]::Round($Point.X))), + [math]::Max(0,[math]::Min($Bitmap.Height-1,[int][math]::Round($Point.Y)))) + $baseCandidate = [pscustomobject]@{ + X=[int]$anchor.X; Y=[int]$anchor.Y; Width=1; Height=1 + } + $candidate = Get-SnipCropRectangle -Candidate $baseCandidate ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height ` + -Preset $previewContext.ToolProperties.Crop.Preset + $previewContext.Draft = [pscustomobject][ordered]@{ + Kind='Crop'; Anchor=$anchor; BaseCandidate=$baseCandidate + Candidate=$candidate; Preset=$previewContext.ToolProperties.Crop.Preset + } + $state.Draft = $previewContext.Draft + $previewContext.Interaction = [pscustomobject]@{ Kind='Crop' } + try { $highlightLayer.CaptureMouse() | Out-Null } catch {} + Render-PreviewInteraction + }.GetNewClosure() + + $updateCropDraft = { + param([System.Windows.Point]$Point) + $draft = $previewContext.Draft + if ($null -eq $draft -or $draft.Kind -ne 'Crop') { return } + if($null -ne $draft.PSObject.Properties['ResizeHandle'] -and + -not [string]::IsNullOrWhiteSpace([string]$draft.ResizeHandle)){ + $deltaX=[int][math]::Round($Point.X-[double]$draft.Anchor.X) + $deltaY=[int][math]::Round($Point.Y-[double]$draft.Anchor.Y) + $left=[int]$draft.OriginalCandidate.X + $top=[int]$draft.OriginalCandidate.Y + $right=$left+[int]$draft.OriginalCandidate.Width + $bottom=$top+[int]$draft.OriginalCandidate.Height + switch([string]$draft.ResizeHandle){ + 'TopLeft'{$left+=$deltaX;$top+=$deltaY} + 'Top'{$top+=$deltaY} + 'TopRight'{$right+=$deltaX;$top+=$deltaY} + 'Right'{$right+=$deltaX} + 'BottomRight'{$right+=$deltaX;$bottom+=$deltaY} + 'Bottom'{$bottom+=$deltaY} + 'BottomLeft'{$left+=$deltaX;$bottom+=$deltaY} + 'Left'{$left+=$deltaX} + } + $raw=[pscustomobject]@{ + X=$left;Y=$top;Width=($right-$left);Height=($bottom-$top) + } + $draft.BaseCandidate=$raw + $draft.Candidate=Get-SnipCropRectangle -Candidate $raw ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height ` + -Preset $draft.Preset + Render-PreviewInteraction + return + } + $rectangle = Get-DragRectangle -AnchorX $draft.Anchor.X -AnchorY $draft.Anchor.Y ` + -CurrentX $Point.X -CurrentY $Point.Y + if ($rectangle.Width -le 0 -or $rectangle.Height -le 0) { return } + $draft.BaseCandidate = [pscustomobject]@{ + X=[int][math]::Round($rectangle.X); Y=[int][math]::Round($rectangle.Y) + Width=[int][math]::Round($rectangle.Width) + Height=[int][math]::Round($rectangle.Height) + } + $draft.Candidate = Get-SnipCropRectangle -Candidate $draft.BaseCandidate ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height ` + -Preset $draft.Preset + Render-PreviewInteraction + }.GetNewClosure() + + $selectCropPreset = { + param([ValidateSet('Free','Original','1:1','4:3','16:9')][string]$Preset) + $previewContext.ToolProperties.Crop.Preset = $Preset + $baseCandidate = if ($null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Crop') { + $previewContext.Draft.BaseCandidate + } elseif ($null -ne $previewContext.CropRectangle) { + $previewContext.CropRectangle + } else { $null } + $candidate = Get-SnipCropRectangle -Candidate $baseCandidate ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height -Preset $Preset + $previewContext.Draft = [pscustomobject][ordered]@{ + Kind='Crop'; Anchor=$null; BaseCandidate=$baseCandidate + Candidate=$candidate; Preset=$Preset + } + $state.Draft = $previewContext.Draft + foreach ($item in @($previewContext.PropertyControls.Aspect.MenuItems.Values)) { + $item.IsChecked = [string]$item.Header -eq $Preset + } + Render-PreviewInteraction + }.GetNewClosure() + + $cancelCropDraft = { + if ($null -ne $previewContext.Draft -and $previewContext.Draft.Kind -eq 'Crop') { + $previewContext.Draft = $null; $state.Draft = $null + $previewContext.Interaction = $null + try { $highlightLayer.ReleaseMouseCapture() } catch {} + Render-PreviewInteraction + } + }.GetNewClosure() + + $applyCropDraft = { + $draft = $previewContext.Draft + if ($null -eq $draft -or $draft.Kind -ne 'Crop' -or $null -eq $draft.Candidate) { + return + } + $applied = Set-SnipCrop -Action Apply -Candidate $draft.Candidate ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height ` + -Preset $draft.Preset + if (-not (Test-PreviewCropEqual $previewContext.CropRectangle $applied)) { + Snapshot-State + $previewContext.CropRectangle = $applied + $state.CropRectangle = $applied + } + $previewContext.Draft = $null; $state.Draft = $null + $previewContext.Interaction = $null + try { $highlightLayer.ReleaseMouseCapture() } catch {} + Render-PreviewInteraction + }.GetNewClosure() + + $resetCrop = { + & $cancelCropDraft + if ($null -eq $previewContext.CropRectangle) { return } + Snapshot-State + $previewContext.CropRectangle = Set-SnipCrop -Action Reset ` + -Candidate $previewContext.CropRectangle ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height + $state.CropRectangle = $previewContext.CropRectangle + Render-PreviewInteraction + }.GetNewClosure() + + $deleteSelection = { + $index = Get-PreviewAnnotationIndexById $previewContext.SelectedAnnotationId + if ($index -lt 0) { + & $setSelectedAnnotation $null | Out-Null + return + } + Snapshot-State + $previewContext.Annotations.RemoveAt($index) + & $setSelectedAnnotation $null | Out-Null + Render-Annotations + }.GetNewClosure() + + $duplicateSelection = { + $source = Get-PreviewAnnotationById $previewContext.SelectedAnnotationId + if ($null -eq $source) { return $null } + $offsetCopy = Move-SnipAnnotation -Annotation $source ` + -DeltaX 12 -DeltaY 12 -SourceWidth $Bitmap.Width ` + -SourceHeight $Bitmap.Height + $duplicate = New-SnipAnnotation -Kind $source.Kind ` + -Geometry $offsetCopy.Geometry -Color $source.Color ` + -StrokeWidth $source.StrokeWidth -Opacity $source.Opacity ` + -Properties $source.Properties -Z (& $getNextAnnotationZ) + Snapshot-State + $previewContext.Annotations.Add($duplicate) | Out-Null + & $setSelectedAnnotation $duplicate.Id | Out-Null + Render-Annotations + $duplicate + }.GetNewClosure() + + $applySelectionProperty = { + param([ValidateSet('Position','Size')][string]$Name,[string]$Text) + if($Text -notmatch '^\s*(-?\d+)\s*[,x×]\s*(-?\d+)\s*$'){return $false} + $first=[int]$Matches[1];$second=[int]$Matches[2] + $current=Get-PreviewAnnotationById $previewContext.SelectedAnnotationId + if($null -eq $current){return $false} + $bounds=Get-PreviewAnnotationBounds $current + if($null -eq $bounds){return $false} + $updated=if($Name -eq 'Position'){ + Move-SnipAnnotation -Annotation $current ` + -DeltaX ($first-[int]$bounds.X) -DeltaY ($second-[int]$bounds.Y) ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height + }elseif($current.Geometry.Type -ne 'Line'){ + Resize-SnipAnnotation -Annotation $current -Handle BottomRight ` + -DeltaX ($first-[int]$bounds.Width) ` + -DeltaY ($second-[int]$bounds.Height) ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height + }else{return $false} + if(Test-PreviewAnnotationEqual $current $updated){return $false} + Snapshot-State + $index=Get-PreviewAnnotationIndexById $current.Id + if($index -ge 0){$previewContext.Annotations[$index]=$updated} + Render-Annotations + $true + }.GetNewClosure() + + $cancelDraft = { + if ($null -ne $previewContext.Interaction -and + $previewContext.Interaction.Kind -eq 'Select') { + & $cancelSelectGesture + return + } + if ($null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Annotation') { + $visual = $previewContext.Draft.Visual + if ($null -ne $visual) { + [void]$interactionLayer.Children.Remove($visual) + } + & $setAnnotationDraft $null + try { $highlightLayer.ReleaseMouseCapture() } catch {} + Render-PreviewInteraction + return + } + if ($null -ne $previewContext.Draft -and $previewContext.Draft.Kind -eq 'Crop') { + & $cancelCropDraft + return + } + }.GetNewClosure() + $previewContext.SelectCropPreset = $selectCropPreset + $previewContext.ApplyCrop = $applyCropDraft + $previewContext.ResetCrop = $resetCrop + $previewContext.CancelDraft = $cancelDraft + $previewContext.DeleteSelection = $deleteSelection + $previewContext.DuplicateSelection = $duplicateSelection + $previewContext.ApplySelectionProperty = $applySelectionProperty + + # ---- Mouse interactions on the highlight layer ---- + # Named dispatcher for MouseLeftButtonDown so tests can drive it with + # synthetic points. Event handler below is a thin wrapper. + $handleMouseDown = { + param( + [System.Windows.Point]$HlPoint, + [System.Windows.Point]$SvPoint + ) + if ($state.EditingText) { return } + if ($state.Panning) { return } + $b = Get-DisplayedImageBounds; if (-not $b) { return } + $p = $HlPoint + if ($p.X -lt $b.X -or $p.Y -lt $b.Y -or + $p.X -gt $b.X + $b.W -or $p.Y -gt $b.Y + $b.H) { return } + + switch ([string]$previewContext.ActiveTool) { + 'Select' { & $beginSelectGesture $p; return } + 'Crop' { & $beginCropDraft $p; return } + } + + $tool = $null + if ($highlightBtn.IsChecked) { $tool = 'highlight' } + elseif ($rectBtn.IsChecked) { $tool = 'rect' } + elseif ($arrowBtn.IsChecked) { $tool = 'arrow' } + + if ($tool) { + & $beginDraw $tool $p + } + elseif ($textBtn.IsChecked) { + & $openText $p + } elseif ([string]::IsNullOrWhiteSpace([string]$previewContext.ActiveTool)) { + & $beginPan $SvPoint + } + }.GetNewClosure() + + $handleMouseMove = { + param( + [System.Windows.Point]$HlPoint, + [System.Windows.Point]$SvPoint + ) + if ($state.Panning) { & $updatePan $SvPoint; return } + if ($null -ne $previewContext.Interaction) { + switch ($previewContext.Interaction.Kind) { + 'Select' { & $updateSelectGesture $HlPoint; return } + 'Crop' { & $updateCropDraft $HlPoint; return } + } + } + if ($null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Annotation') { + & $updateDraw $HlPoint + } + }.GetNewClosure() + + $handleMouseUp = { + param([System.Windows.Point]$HlPoint) + if ($state.Panning) { + if (-not $state.TemporaryPan) { & $endPan } + return + } + if ($null -ne $previewContext.Interaction) { + switch ($previewContext.Interaction.Kind) { + 'Select' { & $completeSelectGesture; return } + 'Crop' { + & $updateCropDraft $HlPoint + $previewContext.Interaction = $null + try { $highlightLayer.ReleaseMouseCapture() } catch {} + Render-PreviewInteraction + return + } + } + } + if ($null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Annotation') { + & $finishDraw + } + }.GetNewClosure() + + $highlightLayer.Add_MouseLeftButtonDown({ + & $handleMouseDown ($_.GetPosition($highlightLayer)) ($_.GetPosition($scroller)) + if ($state.Panning -or $state.Drawing -or $state.EditingText -or + $null -ne $previewContext.Interaction -or $null -ne $previewContext.Draft) { + $_.Handled = $true + } + }.GetNewClosure()) + + $highlightLayer.Add_MouseMove({ + $isSynthetic = $_.GetType().Name -eq 'SnipTestMouseEventArgs' + if ($state.TemporaryPan -or $isSynthetic -or + $_.LeftButton -eq [System.Windows.Input.MouseButtonState]::Pressed) { + & $handleMouseMove ($_.GetPosition($highlightLayer)) ($_.GetPosition($scroller)) + } + }.GetNewClosure()) + + $highlightLayer.Add_MouseLeftButtonUp({ + & $handleMouseUp ($_.GetPosition($highlightLayer)) + }.GetNewClosure()) + $highlightLayer.Add_LostMouseCapture({ + if ($null -ne $previewContext.Interaction -or + ($null -ne $previewContext.Draft -and + $previewContext.Draft.Kind -eq 'Annotation')) { + & $cancelDraft + } elseif ($state.Panning) { + & $endPan + } + }.GetNewClosure()) + + # Re-render on resize (ImageHost growing/shrinking with zoom) + $imageHost.Add_SizeChanged({ Render-Annotations }) + + # Hit-test helper: returns the topmost annotation index under a canvas point, or -1 + function script:Find-AnnotationAt { + param([double]$CanvasX, [double]$CanvasY) + $hit = Find-SnipAnnotation -Annotations $state.Annotations ` + -ImageX $CanvasX -ImageY $CanvasY -Tolerance 6 + $previewContext.LastHitRoute = 'Find-SnipAnnotation' + if ($null -eq $hit) { return -1 } + Get-PreviewAnnotationIndexById $hit.Id + } + + # Right-click an existing annotation → color/delete context menu + $highlightLayer.Add_MouseRightButtonDown({ + if ($state.EditingText) { return } + $p = $_.GetPosition($highlightLayer) + $idx = Find-AnnotationAt -CanvasX $p.X -CanvasY $p.Y + if ($idx -lt 0) { return } + $_.Handled = $true + + # Local aliases so menu-item closures can find them + $stateL = $state + $paletteL = $palette + $targetId = [string]$state.Annotations[$idx].Id + + if ($null -ne $previewContext.AnnotationMenuControl) { + & $previewContext.AnnotationMenuControl.Disconnect + $previewContext.TransientMenus.Remove( + $previewContext.AnnotationMenuControl) + $previewContext.AnnotationMenuControl = $null + } + + $menu = New-Object System.Windows.Controls.ContextMenu + $menu.StaysOpen = $true + $menuBindings = [System.Collections.ArrayList]::new() + foreach ($name in $paletteL.Keys) { + $rgb = $paletteL[$name] + $mi = New-Object System.Windows.Controls.MenuItem + $mi.Header = $name + $swatch = New-Object System.Windows.Shapes.Rectangle + $swatch.Width = 14; $swatch.Height = 14 + $swatch.Fill = New-Object System.Windows.Media.SolidColorBrush( + ([System.Windows.Media.Color]::FromArgb(255, $rgb.R, $rgb.G, $rgb.B))) + $mi.Icon = $swatch + [System.Windows.Automation.AutomationProperties]::SetName($mi, $name) + $nameL = $name + $colorClickHandler = { + $currentIndex = Get-PreviewAnnotationIndexById $targetId + if ($currentIndex -ge 0 -and + [string]$stateL.Annotations[$currentIndex].Color -ne $nameL) { + Snapshot-State + $updated = Copy-SnipAnnotation ` + -Annotation $stateL.Annotations[$currentIndex] + $updated.Color = $nameL + $stateL.Annotations[$currentIndex] = $updated + Render-Annotations + } + if ($null -ne $previewContext.AnnotationMenuControl) { + & $previewContext.AnnotationMenuControl.CloseOptions + } + }.GetNewClosure() + $mi.Add_Click($colorClickHandler) + $menuBindings.Add([pscustomobject]@{ + Item=$mi; Handler=$colorClickHandler + }) | Out-Null + [void]$menu.Items.Add($mi) + } + [void]$menu.Items.Add((New-Object System.Windows.Controls.Separator)) + $delMi = New-Object System.Windows.Controls.MenuItem + $delMi.Header = 'Delete' + [System.Windows.Automation.AutomationProperties]::SetName($delMi, 'Delete') + $deleteClickHandler = { + $currentIndex = Get-PreviewAnnotationIndexById $targetId + if ($currentIndex -ge 0) { + Snapshot-State + $stateL.Annotations.RemoveAt($currentIndex) + if ($previewContext.SelectedAnnotationId -eq $targetId) { + & $setSelectedAnnotation $null | Out-Null + } + Render-Annotations + } + if ($null -ne $previewContext.AnnotationMenuControl) { + & $previewContext.AnnotationMenuControl.CloseOptions + } + }.GetNewClosure() + $delMi.Add_Click($deleteClickHandler) + $menuBindings.Add([pscustomobject]@{ + Item=$delMi; Handler=$deleteClickHandler + }) | Out-Null + [void]$menu.Items.Add($delMi) + + $annotationCleanup = { + foreach ($binding in @($menuBindings)) { + $binding.Item.Remove_Click($binding.Handler) + } + }.GetNewClosure() + $previewContext.AnnotationMenuControl = + Connect-SnipPreviewTransientContextMenu -Menu $menu ` + -PlacementTarget $highlightLayer -Context $previewContext ` + -Name Annotation -Cleanup $annotationCleanup + & $previewContext.AnnotationMenuControl.OpenOptions + }) + + # Toolbar buttons + $win.FindName('ClearBtn').Add_Click({ + if ($state.Annotations.Count -eq 0) { return } + Snapshot-State + $state.Annotations.Clear() + & $setSelectedAnnotation $null | Out-Null + Render-Annotations + }) + $win.FindName('UndoBtn').Add_Click({ Do-Undo }) + $win.FindName('RedoBtn').Add_Click({ Do-Redo }) + + $toggleMaximize = { + $win.WindowState = if ($win.WindowState -eq [System.Windows.WindowState]::Maximized) { + [System.Windows.WindowState]::Normal + } else { [System.Windows.WindowState]::Maximized } + }.GetNewClosure() + $beginWindowDrag = { + if ($win.WindowState -eq [System.Windows.WindowState]::Maximized) { + $win.WindowState = [System.Windows.WindowState]::Normal + } + try { $win.DragMove() } catch {} + }.GetNewClosure() + $showSystemMenu = { + & $previewContext.CommandRouter.SystemMenuAction + }.GetNewClosure() + $resizeHitTest = { + param([double]$X,[double]$Y,[double]$Width,[double]$Height) + $left = $X -lt 6; $right = $X -ge ($Width - 6) + $top = $Y -lt 6; $bottom = $Y -ge ($Height - 6) + if ($top -and $left) { return 'TopLeft' } + if ($top -and $right) { return 'TopRight' } + if ($bottom -and $left) { return 'BottomLeft' } + if ($bottom -and $right) { return 'BottomRight' } + if ($top) { return 'Top' }; if ($bottom) { return 'Bottom' } + if ($left) { return 'Left' }; if ($right) { return 'Right' } + 'Client' + }.GetNewClosure() + $setResponsiveMode = { + param([double]$Width,[double]$Height) + Set-SnipPreviewResponsiveMode -Context $previewContext -Width $Width -Height $Height | Out-Null + }.GetNewClosure() + $setPropertyIsland = { + param([string]$Tool) + Set-SnipPropertyIsland -Context $previewContext -Tool $Tool | Out-Null + }.GetNewClosure() + $getIslandBounds = { + param([double]$Width,[double]$Height) + $win.Width = $Width; $win.Height = $Height + & $setResponsiveMode $Width $Height + $win.UpdateLayout() + $root = $studioShell.StudioRoot + $result = @{} + foreach ($entry in ([ordered]@{ + Brand=$studioShell.BrandIsland; Actions=$studioShell.ActionsIsland + Property=$studioShell.PropertyIsland; ToolDock=$studioShell.ToolDock + Viewport=$studioShell.ViewportIsland; Status=$studioShell.StatusIsland + }).GetEnumerator()) { + $origin = $entry.Value.TranslatePoint([System.Windows.Point]::new(0,0), $root) + $result[$entry.Key] = [System.Windows.Rect]::new( + $origin.X, $origin.Y, $entry.Value.ActualWidth, $entry.Value.ActualHeight) + } + $result + }.GetNewClosure() + $responsiveSizeChanged = [System.Windows.SizeChangedEventHandler]{ + param($sender,$eventArgs) + & $setResponsiveMode $eventArgs.NewSize.Width $eventArgs.NewSize.Height + }.GetNewClosure() + $win.Add_SizeChanged($responsiveSizeChanged) + + # The brand island is the only drag surface. Buttons remain independent + # native focus targets and a double-click toggles work-area maximize. + $win.FindName('DragHeader').Add_MouseLeftButtonDown({ + $src = $_.OriginalSource + $p = $src + while ($p -and -not ($p -is [System.Windows.Controls.Primitives.ButtonBase])) { + $p = [System.Windows.Media.VisualTreeHelper]::GetParent($p) + } + if ($p -is [System.Windows.Controls.Primitives.ButtonBase]) { return } + if ($_.ClickCount -eq 2) { + & $toggleMaximize + } else { + & $beginWindowDrag + } + }.GetNewClosure()) + + # Always-on-top pin + $pinBtn = $win.FindName('PinBtn') + $pinBtn.Add_Checked({ $win.Topmost = $true }) + $pinBtn.Add_Unchecked({ $win.Topmost = $false }) + + # Zoom controls. Uses LayoutTransform on ImageHost. The ScaleTransform + # itself is the single source of truth for the current zoom — reading + # $layoutScale.ScaleX through a captured object reference is immune to + # the PS-scope / closure quirks that broke prior attempts using + # $script: or $Global: variables inside WPF event handlers. + # ($scroller and $zoomText already resolved near the top of this function) + + foreach ($canvas in @($annotationLayer,$interactionLayer,$selectionLayer,$highlightLayer)) { + $canvas.Width = $Bitmap.Width + $canvas.Height = $Bitmap.Height + } + + $setZoom = { + param([double]$s) + # NB: literal doubles required. [math]::Min(10, 1.25) resolves to + # the Min(int,int) overload in PowerShell and truncates to 1. + $s = [math]::Max(0.05, [math]::Min(10.0, $s)) + $layoutScale.ScaleX = $s + $layoutScale.ScaleY = $s + $imageHost.InvalidateMeasure() + $imageHost.UpdateLayout() + try { $scroller.InvalidateScrollInfo() } catch {} + if ($zoomText) { $zoomText.Text = '{0:P0}' -f $s } + $brandZoomText = $win.FindName('BrandZoomText') + if ($brandZoomText) { $brandZoomText.Text = '{0:P0}' -f $s } + Render-PreviewInteraction + }.GetNewClosure() + + $zoomBy = { + param([double]$factor) + & $setZoom ($layoutScale.ScaleX * $factor) + }.GetNewClosure() + + $fitToViewport = { + if (-not $scroller -or $scroller.ViewportWidth -le 0) { return } + $fw = $scroller.ViewportWidth / $Bitmap.Width + $fh = $scroller.ViewportHeight / $Bitmap.Height + $fit = [math]::Min($fw, $fh) + if ($fit -gt 1) { $fit = 1 } + & $setZoom $fit + }.GetNewClosure() + + $win.Add_Loaded({ & $fitToViewport }.GetNewClosure()) + + $win.FindName('ZoomInBtn').Add_Click({ & $zoomBy 1.25 }.GetNewClosure()) + $win.FindName('ZoomOutBtn').Add_Click({ & $zoomBy (1 / 1.25) }.GetNewClosure()) + $win.FindName('FitBtn').Add_Click({ & $fitToViewport }.GetNewClosure()) + + $win.Add_PreviewMouseWheel({ + if (([System.Windows.Input.Keyboard]::Modifiers -band [System.Windows.Input.ModifierKeys]::Control) -ne 0) { + $factor = if ($_.Delta -gt 0) { 1.25 } else { 1 / 1.25 } + $oldScale = $layoutScale.ScaleX + $cursor = $_.GetPosition($scroller) + $oldSx = $scroller.HorizontalOffset + $oldSy = $scroller.VerticalOffset + & $zoomBy $factor + $newScale = $layoutScale.ScaleX + $offset = Get-ZoomCenteredOffset ` + -CursorX $cursor.X -CursorY $cursor.Y ` + -OldScrollX $oldSx -OldScrollY $oldSy ` + -OldScale $oldScale -NewScale $newScale ` + -ContentWidth ($Bitmap.Width * $newScale) ` + -ContentHeight ($Bitmap.Height * $newScale) ` + -ViewportWidth $scroller.ViewportWidth ` + -ViewportHeight $scroller.ViewportHeight + $scroller.ScrollToHorizontalOffset($offset.X) + $scroller.ScrollToVerticalOffset( $offset.Y) + $_.Handled = $true + } + }.GetNewClosure()) + + # Keyboard shortcuts + $fireClick = { + param($btnName) + $b = $win.FindName($btnName) + if ($b) { + $e = New-Object System.Windows.RoutedEventArgs( + [System.Windows.Controls.Primitives.ButtonBase]::ClickEvent) + $b.RaiseEvent($e) + } + }.GetNewClosure() + + $handlePreviewKeyDown = { + param([System.Windows.Input.KeyEventArgs]$EventArgs) + $modifierFlags = & $previewContext.GetKeyboardModifiers + $eventKey = if ($EventArgs.Key -eq [System.Windows.Input.Key]::System) { + $EventArgs.SystemKey + } else { $EventArgs.Key } + $eventKeyName = [string]$eventKey + if ($eventKeyName -eq 'Return') { $eventKeyName = 'Enter' } + $focused = [System.Windows.Input.Keyboard]::FocusedElement + $openMenus = @($previewContext.TransientMenus | Where-Object { + $null -ne $_.State -and $_.State.IsExpanded + }) + $focusedRole = if ($openMenus.Count -gt 0 -or + $focused -is [System.Windows.Controls.MenuItem] -or + $focused -is [System.Windows.Controls.ContextMenu]) { + 'Popup' + } elseif ($null -ne $focused -and $null -ne $focused.PSObject.Properties['Tag'] -and + $null -ne $focused.Tag -and + $null -ne $focused.Tag.PSObject.Properties['Role'] -and + $focused.Tag.Role -eq 'PropertyEditor') { + 'PropertyEditor' + } elseif ($state.EditingText -or + $focused -is [System.Windows.Controls.TextBox] -or + $focused -is [System.Windows.Controls.RichTextBox]) { + 'TextEditor' + } elseif ($focused -is [System.Windows.Controls.Primitives.ButtonBase]) { + 'Button' + } elseif ($highlightLayer.IsKeyboardFocusWithin -or + [object]::ReferenceEquals($focused,$highlightLayer)) { + 'Canvas' + } else { 'Window' } + $modifierNames = @() + if (($modifierFlags -band [System.Windows.Input.ModifierKeys]::Control) -ne 0) { + $modifierNames += 'Ctrl' + } + if (($modifierFlags -band [System.Windows.Input.ModifierKeys]::Shift) -ne 0) { + $modifierNames += 'Shift' + } + if (($modifierFlags -band [System.Windows.Input.ModifierKeys]::Alt) -ne 0) { + $modifierNames += 'Alt' + } + $draftForKeys = if ($null -ne $previewContext.Interaction) { + $previewContext.Interaction + } else { $previewContext.Draft } + $editorState = @{ + PopupOpen=($openMenus.Count -gt 0) + EditingText=[bool]$state.EditingText + EditingProperty=[bool]$previewContext.EditingProperty + Draft=$draftForKeys + SelectionId=$previewContext.SelectedAnnotationId + ActiveTool=$previewContext.ActiveTool + } + $previewContext.CommandRouter.ResolveCount++ + $command = & $previewContext.CommandRouter.Resolve $focusedRole ` + $editorState $eventKeyName $modifierNames + $previewContext.CommandRouter.LastCommand = $command + if ([string]::IsNullOrWhiteSpace([string]$command)) { return } + + switch -Regex ($command) { + '^ClosePopup$' { + $owningMenu = $null + if ($null -ne $previewContext.PopupRouteMenu) { + $owningMenu = @($openMenus | Where-Object { + [object]::ReferenceEquals( + $_.Menu,$previewContext.PopupRouteMenu) + } | Select-Object -Last 1) + $owningMenu = if ($owningMenu.Count -gt 0) { + $owningMenu[0] + } else { $null } + } + if ($null -eq $owningMenu -and $openMenus.Count -gt 0) { + $owningMenu = $openMenus[-1] + } + if ($null -ne $owningMenu) { & $owningMenu.CloseOptions } + $EventArgs.Handled=$true; return + } + '^(TextCopy|TextInput|CommitText|CancelTextEdit|PropertyInput|CancelPropertyEdit|ActivateFocusedButton|PopupNavigation)$' { + return + } + '^CancelDraft$' { + & $cancelDraft; $EventArgs.Handled=$true; return + } + '^ClearSelection$' { + & $setSelectedAnnotation $null | Out-Null + $EventArgs.Handled=$true; return + } + '^DeleteSelection$' { + & $deleteSelection; $EventArgs.Handled=$true; return + } + '^MoveSelection(Left|Right|Up|Down)(1|10)$' { + $direction=$Matches[1]; $distance=[int]$Matches[2] + $deltaX=0; $deltaY=0 + switch ($direction) { + 'Left' { $deltaX=-$distance } + 'Right' { $deltaX=$distance } + 'Up' { $deltaY=-$distance } + 'Down' { $deltaY=$distance } + } + $current = Get-PreviewAnnotationById $previewContext.SelectedAnnotationId + if ($null -ne $current) { + $moved = Move-SnipAnnotation -Annotation $current ` + -DeltaX $deltaX -DeltaY $deltaY ` + -SourceWidth $Bitmap.Width -SourceHeight $Bitmap.Height + if (-not (Test-PreviewAnnotationEqual $current $moved)) { + Snapshot-State + $index=Get-PreviewAnnotationIndexById $current.Id + if ($index -ge 0) { $previewContext.Annotations[$index]=$moved } + Render-Annotations + } + } + $EventArgs.Handled=$true; return + } + '^ActivateSelect$' { + & $setStudioTool Select + $EventArgs.Handled=$true; return + } + '^Undo$' { Do-Undo; $EventArgs.Handled=$true; return } + '^Redo$' { Do-Redo; $EventArgs.Handled=$true; return } + '^CopyKeepOpen$' { + $previewContext.SplitControls.Copy.MenuItems.CopyKeepOpen.RaiseEvent( + [System.Windows.RoutedEventArgs]::new( + [System.Windows.Controls.MenuItem]::ClickEvent)) + $EventArgs.Handled=$true; return + } + '^CopyAndClose$' { & $fireClick CopyBtn; $EventArgs.Handled=$true; return } + '^Save$' { & $fireClick SaveBtn; $EventArgs.Handled=$true; return } + '^NewSmartCapture$' { & $fireClick NewBtn; $EventArgs.Handled=$true; return } + '^ZoomIn$' { & $zoomBy 1.25; $EventArgs.Handled=$true; return } + '^ZoomOut$' { & $zoomBy (1/1.25); $EventArgs.Handled=$true; return } + '^ZoomFit$' { & $fitToViewport; $EventArgs.Handled=$true; return } + '^TemporaryPan$' { + if (-not $state.Panning) { + $state.TemporaryPan = $true + & $beginPan ([System.Windows.Input.Mouse]::GetPosition($scroller)) + } + $EventArgs.Handled=$true; return + } + '^ShowSystemMenu$' { + & $previewContext.CommandRouter.SystemMenuAction + $EventArgs.Handled=$true; return + } + '^ClosePreview$' { + $EventArgs.Handled=$true + & $previewContext.CommandRouter.CloseAction + return + } + } + }.GetNewClosure() + $previewContext.RoutePreviewKeyDown = $handlePreviewKeyDown + $previewKeyDownHandler = { + & $handlePreviewKeyDown $_ + }.GetNewClosure() + $win.Add_PreviewKeyDown($previewKeyDownHandler) + $previewKeyUpHandler = [System.Windows.Input.KeyEventHandler]({ + param($sender,$eventArgs) + $eventKey = if ($eventArgs.Key -eq [System.Windows.Input.Key]::System) { + $eventArgs.SystemKey + } else { $eventArgs.Key } + if ($eventKey -eq [System.Windows.Input.Key]::Space -and + $state.TemporaryPan) { + & $endPan + $eventArgs.Handled = $true + } + }.GetNewClosure()) + $win.Add_PreviewKeyUp($previewKeyUpHandler) + + function script:Get-FlattenedBitmap { + $orderedAnnotations = @(& $getOrderedAnnotations) + if ($orderedAnnotations.Count -eq 0) { return $Bitmap } + $flat = New-Object System.Drawing.Bitmap $Bitmap.Width, $Bitmap.Height, + ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + $g = [System.Drawing.Graphics]::FromImage($flat) + $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit + $g.DrawImage($Bitmap, 0, 0, $Bitmap.Width, $Bitmap.Height) + foreach ($a in $orderedAnnotations) { + $rgb = $palette[$a.Color] + if (-not $rgb) { continue } + $alpha = [int][math]::Round(255.0 * + [math]::Max(0.0,[math]::Min(1.0,[double]$a.Opacity))) + if ($a.Kind -eq 'Highlight') { + $brush = New-Object System.Drawing.SolidBrush( + [System.Drawing.Color]::FromArgb($alpha, $rgb.R, $rgb.G, $rgb.B)) + $g.FillRectangle($brush, [int]$a.Geometry.X, [int]$a.Geometry.Y, + [int]$a.Geometry.Width, [int]$a.Geometry.Height) + $brush.Dispose() + } elseif ($a.Kind -in @('Rectangle','Ellipse')) { + $pen = New-Object System.Drawing.Pen( + [System.Drawing.Color]::FromArgb($alpha, $rgb.R, $rgb.G, $rgb.B), + [single]$a.StrokeWidth) + if ($a.Kind -eq 'Ellipse') { + $g.DrawEllipse($pen, [int]$a.Geometry.X, [int]$a.Geometry.Y, + [int]$a.Geometry.Width, [int]$a.Geometry.Height) + } else { + $g.DrawRectangle($pen, [int]$a.Geometry.X, [int]$a.Geometry.Y, + [int]$a.Geometry.Width, [int]$a.Geometry.Height) + } + $pen.Dispose() + } elseif ($a.Kind -in @('Arrow','Line')) { + $pen = New-Object System.Drawing.Pen( + [System.Drawing.Color]::FromArgb($alpha, $rgb.R, $rgb.G, $rgb.B), + [single]$a.StrokeWidth) + $pen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round + $pen.EndCap = if ($a.Kind -eq 'Arrow') { + [System.Drawing.Drawing2D.LineCap]::ArrowAnchor + } else { [System.Drawing.Drawing2D.LineCap]::Round } + $g.DrawLine($pen, [int]$a.Geometry.Start.X, [int]$a.Geometry.Start.Y, + [int]$a.Geometry.End.X, [int]$a.Geometry.End.Y) + $pen.Dispose() + } elseif ($a.Kind -eq 'Text') { + $brush = New-Object System.Drawing.SolidBrush( + [System.Drawing.Color]::FromArgb($alpha, $rgb.R, $rgb.G, $rgb.B)) + $font = New-Object System.Drawing.Font 'Segoe UI', $a.Properties.FontSize, + ([System.Drawing.FontStyle]::Bold), ([System.Drawing.GraphicsUnit]::Pixel) + $g.DrawString([string]$a.Properties.Text, $font, $brush, + [single]$a.Geometry.X, [single]$a.Geometry.Y) + $font.Dispose(); $brush.Dispose() + } elseif ($a.Geometry.Type -eq 'Points') { + $points = @($a.Geometry.Points) + if ($points.Count -gt 1) { + $pen = New-Object System.Drawing.Pen( + [System.Drawing.Color]::FromArgb($alpha,$rgb.R,$rgb.G,$rgb.B), + [single]$a.StrokeWidth) + for ($index=1; $index -lt $points.Count; $index++) { + $g.DrawLine($pen,[int]$points[$index-1].X,[int]$points[$index-1].Y, + [int]$points[$index].X,[int]$points[$index].Y) + } + $pen.Dispose() + } + } + } + $g.Dispose() + return $flat + } + + $copyBtn = $win.FindName('CopyBtn') + $invokeCopy = { + param([bool]$CloseAfterCopy) + $operation = if ($CloseAfterCopy) { 'CopyAndClose' } else { 'CopyKeepOpen' } + if ($OnOutputStarting) { & $OnOutputStarting $operation } + $flat = $null + $success = $false + $copyError = $null + try { + $flat = Get-FlattenedBitmap + $clipSrc = Convert-BitmapToBitmapSource $flat + $retrySchedule = @(50, 100, 200) + for ($attempt = 0; $attempt -le $retrySchedule.Count; $attempt++) { + try { + & $previewContext.ClipboardSetter $clipSrc + $success = $true + break + } catch { + $copyError = $_ + if ($attempt -lt $retrySchedule.Count) { + & $previewContext.RetryDelay $retrySchedule[$attempt] + } + } + } + if ($success) { + & $previewContext.SetStatus 'Copied to clipboard' 'Success' + } else { + & $previewContext.SetStatus 'Clipboard is unavailable' 'Error' + if ($null -ne $copyError) { + Write-SnipDiag -Message 'Clipboard copy failed' -ErrorRecord $copyError + } + } + } catch { + & $previewContext.SetStatus 'Clipboard is unavailable' 'Error' + Write-SnipDiag -Message 'Clipboard copy failed' -ErrorRecord $_ + } finally { + if ($null -ne $flat -and $flat -ne $Bitmap) { + try { $flat.Dispose() } catch {} + } + if ($OnOutputCompleted) { & $OnOutputCompleted $operation $success } + } + if ($success -and $CloseAfterCopy) { + $previewLifecycle.Result = 'Completed' + $win.Close() + } + $success + }.GetNewClosure() + $copyBtn.Add_Click({ & $invokeCopy $true | Out-Null }.GetNewClosure()) + $previewContext.SplitControls.Copy.MenuItems.CopyKeepOpen.Add_Click({ + & $invokeCopy $false | Out-Null + }.GetNewClosure()) + $win.FindName('SaveBtn').Add_Click({ + if ($OnOutputStarting) { & $OnOutputStarting 'Save' } + $flat = $null + $success = $false + try { + $flat = Get-FlattenedBitmap + $savedPath = & $previewContext.SaveBitmap $flat + $success = -not [string]::IsNullOrWhiteSpace([string]$savedPath) + } catch { + Write-SnipDiag -Message 'Save failed' -ErrorRecord $_ + } finally { + if ($null -ne $flat -and $flat -ne $Bitmap) { + try { $flat.Dispose() } catch {} + } + if ($OnOutputCompleted) { & $OnOutputCompleted 'Save' $success } + } + }.GetNewClosure()) + $win.FindName('NewBtn').Add_Click({ + $previewLifecycle.Result = 'Preempted' + if ($OnNewSnip) { & $OnNewSnip } + $win.Close() + }.GetNewClosure()) + $win.FindName('CloseBtn').Add_Click({ $win.Close() }) + + $script:CurrentPreviewWindow = $win + $previewHelper = New-Object System.Windows.Interop.WindowInteropHelper $win + # SourceInitialized fires once the OS hwnd exists but before the window + # paints. Register here so a window-capture triggered with the preview + # in focus correctly falls back to the virtual desktop. + $win.Add_SourceInitialized({ + Register-SelfWindowHandle -Hwnd $previewHelper.Handle + }.GetNewClosure()) + $previewSurface = [pscustomobject]@{ + Kind = 'Preview' + Window = $win + Close = { + param([string]$result) + $previewLifecycle.Result = $result + $win.Close() + }.GetNewClosure() + } + $win.Add_Closed({ + try { $win.Remove_SizeChanged($responsiveSizeChanged) } catch {} + try { & $cancelDraft } catch {} + try { & $previewContext.CommandRouter.CloseTransientMenus } catch {} + if ([object]::ReferenceEquals($script:ActivePreviewContext, $previewContext)) { + $script:ActivePreviewContext = $null + } + try { Unregister-SelfWindowHandle -Hwnd $previewHelper.Handle } catch {} + $script:CurrentPreviewWindow = $null + # Preview owns the original only after the transfer callback succeeds. + # The one-shot guard protects exceptional close paths from re-disposal. + if ($previewLifecycle.OwnershipAccepted -and -not $previewLifecycle.BitmapDisposed) { + $previewLifecycle.BitmapDisposed = $true + try { if ($Bitmap) { $Bitmap.Dispose() } } + catch { Write-SnipDiag -Message 'Bitmap dispose failed' -ErrorRecord $_ } + } + }.GetNewClosure()) + $previewLifecycle.CleanupInstalled = $true + try { + if ($OnSurfaceReady) { & $OnSurfaceReady $previewSurface } + if ($OnOwnershipAccepted) { & $OnOwnershipAccepted $previewLifecycle } + $previewLifecycle.OwnershipAccepted = $true + } catch { + try { $win.Close() } catch {} + throw + } + + if ($TestAction) { + $kit = @{ + Win = $win + Context = $previewContext + Resources = $resources + ResponsiveMode = $previewContext.ModeState + CommandRouter = $previewContext.CommandRouter + StudioRoot = $studioShell.StudioRoot + State = $state + LayoutScale = $layoutScale + Scroller = $scroller + ImageHost = $imageHost + PreviewImage = $previewImage + AnnotationLayer = $annotationLayer + InteractionLayer = $interactionLayer + SelectionLayer = $selectionLayer + HighlightLayer = $highlightLayer + BrandIsland = $studioShell.BrandIsland + ActionsIsland = $studioShell.ActionsIsland + PropertyIsland = $studioShell.PropertyIsland + ToolDock = $studioShell.ToolDock + ViewportIsland = $studioShell.ViewportIsland + StatusIsland = $studioShell.StatusIsland + ActionOrder = [string[]]@('Pin','Save','CopyAndClose','Close') + SplitControls = $previewContext.SplitControls + ToolOrder = $previewContext.ToolOrder + MoreState = $previewContext.MoreState + PropertyState = $previewContext.PropertyState + SetResponsiveMode = $setResponsiveMode + SetPropertyIsland = $setPropertyIsland + SetStudioTool = $setStudioTool + SetSelectedAnnotation = $setSelectedAnnotation + SetStatus = $previewContext.SetStatus + ClearStatus = $previewContext.ClearStatus + GetIslandBounds = $getIslandBounds + ResizeHitTest = $resizeHitTest + ToggleMaximize = $toggleMaximize + BeginWindowDrag = $beginWindowDrag + ShowSystemMenu = $showSystemMenu + ZoomText = $zoomText + HighlightBtn = $highlightBtn + RectBtn = $rectBtn + ArrowBtn = $arrowBtn + TextBtn = $textBtn + Bitmap = $Bitmap + Palette = $palette + SetZoom = $setZoom + ZoomBy = $zoomBy + FitToViewport = $fitToViewport + BeginPan = $beginPan + UpdatePan = $updatePan + EndPan = $endPan + BeginDraw = $beginDraw + UpdateDraw = $updateDraw + FinishDraw = $finishDraw + OpenText = $openText + HandleMouseDown = $handleMouseDown + HandleMouseMove = $handleMouseMove + HandleMouseUp = $handleMouseUp + HandlePreviewKeyDown = $handlePreviewKeyDown + BeginSelectGesture = $beginSelectGesture + UpdateSelectGesture = $updateSelectGesture + CompleteSelectGesture = $completeSelectGesture + CancelSelectGesture = $cancelSelectGesture + BeginCropDraft = $beginCropDraft + GetCropHandleAt = $getCropHandleAt + UpdateCropDraft = $updateCropDraft + ApplyCropDraft = $applyCropDraft + CancelCropDraft = $cancelCropDraft + ResetCrop = $resetCrop + PickColor = $pickColor + Render = ${function:script:Render-Annotations} + Snapshot = ${function:script:Snapshot-State} + Undo = ${function:script:Do-Undo} + Redo = ${function:script:Do-Redo} + FindAt = ${function:script:Find-AnnotationAt} + Flatten = ${function:script:Get-FlattenedBitmap} + Surface = $previewSurface + OwnershipState = $previewLifecycle + } + $script:pwTestError = $null + # Hide off-screen so the window is effectively headless. + $win.WindowStartupLocation = 'Manual' + $win.Left = -5000; $win.Top = -5000 + $win.Add_Loaded({ + try { & $TestAction $kit } + catch { $script:pwTestError = $_ } + finally { try { $win.Close() } catch {} } + }.GetNewClosure()) + $win.ShowDialog() | Out-Null + if ($script:pwTestError) { throw $script:pwTestError } + return $previewLifecycle.Result + } + + try { + $win.ShowDialog() | Out-Null + } catch { + $previewLifecycle.Result = 'Failed' + try { $win.Close() } catch {} + } + $script:CurrentPreviewWindow = $null + return $previewLifecycle.Result +} diff --git a/src/50-Tray.ps1 b/src/50-Tray.ps1 new file mode 100644 index 0000000..fac38a4 --- /dev/null +++ b/src/50-Tray.ps1 @@ -0,0 +1,1293 @@ +#requires -Version 7.5 +# Generated deterministically from the reviewed SnipIT.ps1 AST by scripts/Export-SnipITModules.ps1. + +$script:WidgetWindow = $null + +function Show-SettingsWindow { + [CmdletBinding()] + param( + $Context = $script:UtilityContext, + [scriptblock]$TestAction + ) + + if ($null -eq $Context) { + throw [ArgumentNullException]::new('Context') + } + foreach ($requiredProperty in 'Settings','SettingsPath','RegisteredHotkey','Hwnd') { + if ($null -eq $Context.PSObject.Properties[$requiredProperty]) { + throw [ArgumentException]::new("Settings context is missing '$requiredProperty'.", 'Context') + } + } + + $highContrastProperty = $Context.PSObject.Properties['HighContrast'] + $highContrast = if ($null -ne $highContrastProperty) { + [bool]$highContrastProperty.Value + } else { + [bool][System.Windows.SystemParameters]::HighContrast + } + $themeParameters = @{ HighContrast = $highContrast } + $animationsProperty = $Context.PSObject.Properties['AnimationsEnabled'] + if ($null -ne $animationsProperty) { + $themeParameters.AnimationsEnabled = [bool]$animationsProperty.Value + } + $resources = New-SnipThemeResources @themeParameters + + [xml]$xaml = [xml](Get-SnipXamlText -Name 'SettingsWindow') + $reader = [System.Xml.XmlNodeReader]::new($xaml) + try { + $window = [System.Windows.Markup.XamlReader]::Load($reader) + } finally { + $reader.Dispose() + } + Add-SnipThemeResources -Root $window -Resources $resources | Out-Null + + $dragHeader = $window.FindName('DragHeader') + $headerCloseButton = $window.FindName('HeaderCloseBtn') + $shortcutRecorder = $window.FindName('ShortcutRecorder') + $shortcutText = $window.FindName('ShortcutText') + $saveFolderBox = $window.FindName('SaveFolderBox') + $launchCheck = $window.FindName('LaunchCheck') + $widgetCheck = $window.FindName('WidgetCheck') + $errorText = $window.FindName('ErrorText') + $cancelButton = $window.FindName('CancelBtn') + $saveButton = $window.FindName('SaveBtn') + + $saveFolderBox.Text = [string]$Context.Settings.SaveFolder + $launchCheck.IsChecked = [bool]$Context.Settings.LaunchAtSignIn + $widgetCheck.IsChecked = [bool]$Context.Settings.WidgetVisible + $formatActiveShortcut = { + param($binding) + if ($null -eq $binding) { return 'Unavailable' } + Format-SnipHotkey -Modifiers ([int]$binding.Modifiers) -VirtualKey ([int]$binding.VirtualKey) + } + $shortcutText.Text = & $formatActiveShortcut $Context.RegisteredHotkey + + $registerProperty = $Context.PSObject.Properties['RegisterHotkey'] + $registerHotkey = if ($null -ne $registerProperty -and $registerProperty.Value -is [scriptblock]) { + $registerProperty.Value + } else { + { param($hwnd,$id,$modifiers,$virtualKey) [Native]::RegisterHotKey($hwnd,$id,$modifiers,$virtualKey) } + } + $unregisterProperty = $Context.PSObject.Properties['UnregisterHotkey'] + $unregisterHotkey = if ($null -ne $unregisterProperty -and $unregisterProperty.Value -is [scriptblock]) { + $unregisterProperty.Value + } else { + { param($hwnd,$id) [Native]::UnregisterHotKey($hwnd,$id) } + } + $setFeedback = { + param([string]$Message, [ValidateSet('Error','Success','Neutral')] [string]$Kind = 'Error') + $errorText.Text = $Message + $brushKey = switch ($Kind) { + 'Success' { 'SnipMintBrush' } + 'Neutral' { 'SnipSecondaryTextBrush' } + default { 'SnipCoralBrush' } + } + $errorText.Foreground = $window.Resources[$brushKey] + }.GetNewClosure() + $recordShortcut = { + param([int]$Modifiers, [int]$VirtualKey) + + $result = Set-SnipHotkeyBinding -Context $Context ` + -Candidate ([pscustomobject]@{ Modifiers = $Modifiers; VirtualKey = $VirtualKey }) ` + -Register $registerHotkey -Unregister $unregisterHotkey + $shortcutText.Text = & $formatActiveShortcut $result.ActiveBinding + if ($result.Success) { + & $setFeedback '' Neutral + } elseif ($result.RollbackError -and $null -ne $result.ActiveBinding) { + & $setFeedback ("{0} {1} Retry with another shortcut or close SnipIT to retry cleanup." -f + $result.CandidateError, $result.RollbackError) Error + } elseif ($result.RollbackError) { + & $setFeedback ("{0} {1} No global shortcut is active; capture remains available from the tray." -f + $result.CandidateError, $result.RollbackError) Error + } elseif ($null -ne $result.ActiveBinding) { + & $setFeedback ("{0}; previous shortcut remains active." -f $result.CandidateError) Error + } else { + & $setFeedback ("{0} No global shortcut is active; capture remains available from the tray." -f + $result.CandidateError) Error + } + + $hotkeyChangedProperty = $Context.PSObject.Properties['OnHotkeyChanged'] + if ($null -ne $hotkeyChangedProperty -and $hotkeyChangedProperty.Value -is [scriptblock]) { + & $hotkeyChangedProperty.Value $result | Out-Null + } + $result + }.GetNewClosure() + $recorderState = [pscustomobject]@{ LastHandled = $false } + $recordKey = { + param( + [System.Windows.Input.Key]$Key, + [System.Windows.Input.Key]$SystemKey, + [System.Windows.Input.ModifierKeys]$Modifiers + ) + + $recorderState.LastHandled = $false + $resolvedKey = if ($Key -eq [System.Windows.Input.Key]::System) { $SystemKey } else { $Key } + if ($resolvedKey -eq [System.Windows.Input.Key]::Tab) { + return $null + } + $recorderState.LastHandled = $true + if ($resolvedKey -in @( + [System.Windows.Input.Key]::LeftCtrl, [System.Windows.Input.Key]::RightCtrl, + [System.Windows.Input.Key]::LeftAlt, [System.Windows.Input.Key]::RightAlt, + [System.Windows.Input.Key]::LeftShift, [System.Windows.Input.Key]::RightShift, + [System.Windows.Input.Key]::LWin, [System.Windows.Input.Key]::RWin)) { + return $null + } + + $modifierBits = 0x4000 + if (($Modifiers -band [System.Windows.Input.ModifierKeys]::Control) -ne 0) { $modifierBits = $modifierBits -bor 0x2 } + if (($Modifiers -band [System.Windows.Input.ModifierKeys]::Alt) -ne 0) { $modifierBits = $modifierBits -bor 0x1 } + if (($Modifiers -band [System.Windows.Input.ModifierKeys]::Shift) -ne 0) { $modifierBits = $modifierBits -bor 0x4 } + if (($Modifiers -band [System.Windows.Input.ModifierKeys]::Windows) -ne 0) { $modifierBits = $modifierBits -bor 0x8 } + $virtualKey = [System.Windows.Input.KeyInterop]::VirtualKeyFromKey($resolvedKey) + & $recordShortcut $modifierBits $virtualKey + }.GetNewClosure() + $saveChanges = { + $previousSettings = [pscustomobject][ordered]@{ + Version = $Context.Settings.Version + Hotkey = $Context.Settings.Hotkey + SaveFolder = $Context.Settings.SaveFolder + SaveFormat = $Context.Settings.SaveFormat + LaunchAtSignIn = $Context.Settings.LaunchAtSignIn + WidgetVisible = $Context.Settings.WidgetVisible + } + $candidatePersisted = $false + $startupAttempted = $false + $widgetAttempted = $false + try { + $folder = $saveFolderBox.Text.Trim() + if ([string]::IsNullOrWhiteSpace($folder)) { + throw [ArgumentException]::new('Choose a default save folder.') + } + $updatedSettings = [pscustomobject][ordered]@{ + Version = $Context.Settings.Version + Hotkey = $Context.Settings.Hotkey + SaveFolder = $folder + SaveFormat = $Context.Settings.SaveFormat + LaunchAtSignIn = [bool]$launchCheck.IsChecked + WidgetVisible = [bool]$widgetCheck.IsChecked + } + Save-SnipSettings -Settings $updatedSettings -Path ([string]$Context.SettingsPath) + $candidatePersisted = $true + + $Context.Settings.SaveFolder = $updatedSettings.SaveFolder + $Context.Settings.LaunchAtSignIn = $updatedSettings.LaunchAtSignIn + $Context.Settings.WidgetVisible = $updatedSettings.WidgetVisible + $syncProperty = $Context.PSObject.Properties['SyncStartup'] + if ($null -ne $syncProperty -and $syncProperty.Value -is [scriptblock]) { + $startupAttempted = $true + & $syncProperty.Value $Context.Settings | Out-Null + } + $widgetProperty = $Context.PSObject.Properties['SetWidgetVisible'] + if ($null -ne $widgetProperty -and $widgetProperty.Value -is [scriptblock]) { + $widgetAttempted = $true + & $widgetProperty.Value ([bool]$Context.Settings.WidgetVisible) | Out-Null + } + & $setFeedback 'Settings saved.' Success + return $true + } catch { + $failureMessage = $_.Exception.Message + $rollbackErrors = [System.Collections.Generic.List[string]]::new() + try { + $Context.Settings.SaveFolder = $previousSettings.SaveFolder + $Context.Settings.LaunchAtSignIn = $previousSettings.LaunchAtSignIn + $Context.Settings.WidgetVisible = $previousSettings.WidgetVisible + } catch { + $rollbackErrors.Add("in-memory settings: $($_.Exception.Message)") + } + if ($startupAttempted) { + try { & $syncProperty.Value $previousSettings | Out-Null } + catch { $rollbackErrors.Add("launch-at-sign-in: $($_.Exception.Message)") } + } + if ($widgetAttempted) { + try { & $widgetProperty.Value ([bool]$previousSettings.WidgetVisible) | Out-Null } + catch { $rollbackErrors.Add("widget visibility: $($_.Exception.Message)") } + } + if ($candidatePersisted) { + try { + Save-SnipSettings -Settings $previousSettings -Path ([string]$Context.SettingsPath) + } catch { + $rollbackErrors.Add("settings file: $($_.Exception.Message)") + } + } + if ($rollbackErrors.Count -eq 0) { + & $setFeedback ("Settings could not be saved: {0} Previous settings were restored." -f + $failureMessage) Error + } else { + & $setFeedback ("Settings could not be saved: {0} Previous settings could not be fully restored: {1}" -f + $failureMessage, ($rollbackErrors -join '; ')) Error + } + return $false + } + }.GetNewClosure() + + $surfaceState = [pscustomobject]@{ Result = 'UserCancelled'; Published = $false } + $surface = [pscustomobject]@{ Window = $window; RequestedResult = 'UserCancelled'; Close = $null } + $closeSurface = { + param([string]$Result = 'Preempted') + $surfaceState.Result = $Result + $surface.RequestedResult = $Result + if ($window.IsVisible) { $window.Close() } + }.GetNewClosure() + $surface.Close = $closeSurface + + $lifecycleParameters = @{ Window = $window } + $registerWindowProperty = $Context.PSObject.Properties['RegisterWindow'] + $unregisterWindowProperty = $Context.PSObject.Properties['UnregisterWindow'] + if ($null -ne $registerWindowProperty -and $registerWindowProperty.Value -is [scriptblock]) { + $lifecycleParameters.Register = $registerWindowProperty.Value + } + if ($null -ne $unregisterWindowProperty -and $unregisterWindowProperty.Value -is [scriptblock]) { + $lifecycleParameters.Unregister = $unregisterWindowProperty.Value + } + $lifecycle = Connect-SnipWindowLifecycle @lifecycleParameters + $surfaceReadyProperty = $Context.PSObject.Properties['OnSurfaceReady'] + $surfaceReadyHandler = [EventHandler]{ + param($sender, $eventArgs) + if (-not $surfaceState.Published -and $null -ne $surfaceReadyProperty -and + $surfaceReadyProperty.Value -is [scriptblock]) { + $surfaceState.Published = $true + & $surfaceReadyProperty.Value $surface | Out-Null + } + }.GetNewClosure() + + $recorderKeyHandler = [System.Windows.Input.KeyEventHandler]{ + param($sender, $eventArgs) + & $recordKey $eventArgs.Key $eventArgs.SystemKey ([System.Windows.Input.Keyboard]::Modifiers) | Out-Null + $eventArgs.Handled = [bool]$recorderState.LastHandled + }.GetNewClosure() + $windowKeyHandler = [System.Windows.Input.KeyEventHandler]{ + param($sender, $eventArgs) + if ($eventArgs.Key -eq [System.Windows.Input.Key]::Escape) { + $surfaceState.Result = 'UserCancelled' + $window.Close() + $eventArgs.Handled = $true + } + }.GetNewClosure() + $dragHandler = [System.Windows.Input.MouseButtonEventHandler]{ + param($sender, $eventArgs) + if (-not $headerCloseButton.IsMouseOver -and + $eventArgs.ChangedButton -eq [System.Windows.Input.MouseButton]::Left) { + try { $window.DragMove() } catch {} + } + }.GetNewClosure() + $closeClickHandler = [System.Windows.RoutedEventHandler]{ + param($sender, $eventArgs) + $surfaceState.Result = 'UserCancelled' + $window.Close() + }.GetNewClosure() + $saveClickHandler = [System.Windows.RoutedEventHandler]{ param($sender,$eventArgs) & $saveChanges | Out-Null }.GetNewClosure() + $window.Add_SourceInitialized($surfaceReadyHandler) + $shortcutRecorder.Add_PreviewKeyDown($recorderKeyHandler) + $window.Add_KeyDown($windowKeyHandler) + $dragHeader.Add_MouseLeftButtonDown($dragHandler) + $headerCloseButton.Add_Click($closeClickHandler) + $cancelButton.Add_Click($closeClickHandler) + $saveButton.Add_Click($saveClickHandler) + + $kit = [pscustomobject]@{ + Window = $window + Resources = $resources + Lifecycle = $lifecycle + ShortcutRecorder = $shortcutRecorder + ShortcutText = $shortcutText + SaveFolderBox = $saveFolderBox + LaunchCheck = $launchCheck + WidgetCheck = $widgetCheck + ErrorText = $errorText + RecordShortcut = $recordShortcut + RecordKey = $recordKey + RecorderState = $recorderState + SaveChanges = $saveChanges + Close = $closeSurface + } + + try { + if ($TestAction) { + $window.WindowStartupLocation = 'Manual' + $window.Left = -10000 + $window.Top = -10000 + $window.ShowActivated = $false + $window.Show() + $window.UpdateLayout() + & $TestAction $kit | Out-Null + if ($window.IsVisible) { $window.Close() } + } else { + $window.ShowDialog() | Out-Null + } + } catch { + $surfaceState.Result = 'Failed' + throw + } finally { + try { + if ($window.IsVisible) { $window.Close() } + $window.Remove_SourceInitialized($surfaceReadyHandler) + $shortcutRecorder.Remove_PreviewKeyDown($recorderKeyHandler) + $window.Remove_KeyDown($windowKeyHandler) + $dragHeader.Remove_MouseLeftButtonDown($dragHandler) + $headerCloseButton.Remove_Click($closeClickHandler) + $cancelButton.Remove_Click($closeClickHandler) + $saveButton.Remove_Click($saveClickHandler) + } finally { + $surfaceClosedProperty = $Context.PSObject.Properties['OnSurfaceClosed'] + if ($null -ne $surfaceClosedProperty -and + $surfaceClosedProperty.Value -is [scriptblock]) { + & $surfaceClosedProperty.Value $surfaceState.Result $surface | Out-Null + } + } + } + $surfaceState.Result +} + +function Show-AboutWindow { + [CmdletBinding()] + param( + $Context = $script:UtilityContext, + [scriptblock]$TestAction + ) + + if ($null -eq $Context) { $Context = [pscustomobject]@{} } + $appVersionProperty = $Context.PSObject.Properties['AppVersion'] + $repositoryProperty = $Context.PSObject.Properties['Repository'] + $licenseProperty = $Context.PSObject.Properties['License'] + $hotkeyProperty = $Context.PSObject.Properties['RegisteredHotkey'] + $activeBinding = if ($null -ne $hotkeyProperty) { $hotkeyProperty.Value } else { $null } + $metadata = [pscustomobject][ordered]@{ + Version = if ($null -ne $appVersionProperty -and + -not [string]::IsNullOrWhiteSpace([string]$appVersionProperty.Value)) { + [string]$appVersionProperty.Value + } else { $script:SnipITAppVersion } + PowerShell = $PSVersionTable.PSVersion.ToString() + DotNet = [Environment]::Version.ToString() + Repository = if ($null -ne $repositoryProperty -and + -not [string]::IsNullOrWhiteSpace([string]$repositoryProperty.Value)) { + [string]$repositoryProperty.Value + } else { 'https://github.com/RandomCodeSpace/snipIT' } + License = if ($null -ne $licenseProperty -and + -not [string]::IsNullOrWhiteSpace([string]$licenseProperty.Value)) { + [string]$licenseProperty.Value + } else { 'MIT License' } + ActiveShortcut = if ($null -eq $activeBinding) { + 'Unavailable' + } else { + Format-SnipHotkey -Modifiers ([int]$activeBinding.Modifiers) ` + -VirtualKey ([int]$activeBinding.VirtualKey) + } + } + + $highContrastProperty = $Context.PSObject.Properties['HighContrast'] + $highContrast = if ($null -ne $highContrastProperty) { + [bool]$highContrastProperty.Value + } else { + [bool][System.Windows.SystemParameters]::HighContrast + } + $themeParameters = @{ HighContrast = $highContrast } + $animationsProperty = $Context.PSObject.Properties['AnimationsEnabled'] + if ($null -ne $animationsProperty) { $themeParameters.AnimationsEnabled = [bool]$animationsProperty.Value } + $resources = New-SnipThemeResources @themeParameters + + [xml]$xaml = [xml](Get-SnipXamlText -Name 'AboutWindow') + $reader = [System.Xml.XmlNodeReader]::new($xaml) + try { $window = [System.Windows.Markup.XamlReader]::Load($reader) } + finally { $reader.Dispose() } + Add-SnipThemeResources -Root $window -Resources $resources | Out-Null + + $window.FindName('VersionText').Text = $metadata.Version + $window.FindName('PowerShellText').Text = $metadata.PowerShell + $window.FindName('DotNetText').Text = $metadata.DotNet + $window.FindName('ShortcutText').Text = $metadata.ActiveShortcut + $window.FindName('RepositoryText').Text = $metadata.Repository + $window.FindName('LicenseText').Text = $metadata.License + $dragHeader = $window.FindName('DragHeader') + $headerCloseButton = $window.FindName('HeaderCloseBtn') + $closeButton = $window.FindName('CloseBtn') + + $lifecycleParameters = @{ Window = $window } + $registerWindowProperty = $Context.PSObject.Properties['RegisterWindow'] + $unregisterWindowProperty = $Context.PSObject.Properties['UnregisterWindow'] + if ($null -ne $registerWindowProperty -and $registerWindowProperty.Value -is [scriptblock]) { + $lifecycleParameters.Register = $registerWindowProperty.Value + } + if ($null -ne $unregisterWindowProperty -and $unregisterWindowProperty.Value -is [scriptblock]) { + $lifecycleParameters.Unregister = $unregisterWindowProperty.Value + } + $lifecycle = Connect-SnipWindowLifecycle @lifecycleParameters + $surfaceState = [pscustomobject]@{ Result = 'UserCancelled'; Published = $false } + $surface = [pscustomobject]@{ Window = $window; RequestedResult = 'UserCancelled'; Close = $null } + $closeSurface = { + param([string]$Result = 'Preempted') + $surfaceState.Result = $Result + $surface.RequestedResult = $Result + if ($window.IsVisible) { $window.Close() } + }.GetNewClosure() + $surface.Close = $closeSurface + $surfaceReadyProperty = $Context.PSObject.Properties['OnSurfaceReady'] + $surfaceReadyHandler = [EventHandler]{ + param($sender,$eventArgs) + if (-not $surfaceState.Published -and $null -ne $surfaceReadyProperty -and + $surfaceReadyProperty.Value -is [scriptblock]) { + $surfaceState.Published = $true + & $surfaceReadyProperty.Value $surface | Out-Null + } + }.GetNewClosure() + $closeClickHandler = [System.Windows.RoutedEventHandler]{ + param($sender,$eventArgs) + $surfaceState.Result = 'UserCancelled' + $window.Close() + }.GetNewClosure() + $keyHandler = [System.Windows.Input.KeyEventHandler]{ + param($sender,$eventArgs) + if ($eventArgs.Key -eq [System.Windows.Input.Key]::Escape) { + $surfaceState.Result = 'UserCancelled' + $window.Close() + $eventArgs.Handled = $true + } + }.GetNewClosure() + $dragHandler = [System.Windows.Input.MouseButtonEventHandler]{ + param($sender,$eventArgs) + if (-not $headerCloseButton.IsMouseOver -and + $eventArgs.ChangedButton -eq [System.Windows.Input.MouseButton]::Left) { + try { $window.DragMove() } catch {} + } + }.GetNewClosure() + $window.Add_SourceInitialized($surfaceReadyHandler) + $window.Add_KeyDown($keyHandler) + $dragHeader.Add_MouseLeftButtonDown($dragHandler) + $headerCloseButton.Add_Click($closeClickHandler) + $closeButton.Add_Click($closeClickHandler) + + $kit = [pscustomobject]@{ + Window = $window + Resources = $resources + Lifecycle = $lifecycle + Metadata = $metadata + Close = $closeSurface + } + try { + if ($TestAction) { + $window.WindowStartupLocation = 'Manual' + $window.Left = -10000 + $window.Top = -10000 + $window.ShowActivated = $false + $window.Show() + $window.UpdateLayout() + & $TestAction $kit | Out-Null + if ($window.IsVisible) { $window.Close() } + } else { + $window.ShowDialog() | Out-Null + } + } catch { + $surfaceState.Result = 'Failed' + throw + } finally { + try { + if ($window.IsVisible) { $window.Close() } + $window.Remove_SourceInitialized($surfaceReadyHandler) + $window.Remove_KeyDown($keyHandler) + $dragHeader.Remove_MouseLeftButtonDown($dragHandler) + $headerCloseButton.Remove_Click($closeClickHandler) + $closeButton.Remove_Click($closeClickHandler) + } finally { + $surfaceClosedProperty = $Context.PSObject.Properties['OnSurfaceClosed'] + if ($null -ne $surfaceClosedProperty -and + $surfaceClosedProperty.Value -is [scriptblock]) { + & $surfaceClosedProperty.Value $surfaceState.Result $surface | Out-Null + } + } + } + $surfaceState.Result +} + +function Clear-SnipWidgetWindow { + param([System.Windows.Window]$Window) + if ($script:WidgetWindow -eq $Window) { $script:WidgetWindow = $null } +} + +function Show-FloatingWidget { + [CmdletBinding()] + param( + $Context = $script:UtilityContext, + [scriptblock]$TestAction + ) + + if ($null -eq $Context -or $null -eq $Context.PSObject.Properties['Settings']) { + throw [ArgumentException]::new('The widget requires a settings context.', 'Context') + } + if (-not [bool]$Context.Settings.WidgetVisible) { + if ($script:WidgetWindow -and $script:WidgetWindow.IsVisible) { + $script:WidgetWindow.Close() + } + return $null + } + if ($script:WidgetWindow -and -not $TestAction) { + if (-not $script:WidgetWindow.IsVisible) { $script:WidgetWindow.Show() } + return $script:WidgetWindow + } + + $submitProperty = $Context.PSObject.Properties['SubmitRequest'] + $settingsProperty = $Context.PSObject.Properties['OpenSettings'] + if ($null -eq $submitProperty -or $submitProperty.Value -isnot [scriptblock]) { + throw [ArgumentException]::new('The widget requires a SubmitRequest service.', 'Context') + } + if ($null -eq $settingsProperty -or $settingsProperty.Value -isnot [scriptblock]) { + throw [ArgumentException]::new('The widget requires an OpenSettings service.', 'Context') + } + $submitRequest = $submitProperty.Value + $openSettings = $settingsProperty.Value + + $highContrastProperty = $Context.PSObject.Properties['HighContrast'] + $highContrast = if ($null -ne $highContrastProperty) { + [bool]$highContrastProperty.Value + } else { [bool][System.Windows.SystemParameters]::HighContrast } + $themeParameters = @{ HighContrast = $highContrast } + $animationsProperty = $Context.PSObject.Properties['AnimationsEnabled'] + if ($null -ne $animationsProperty) { $themeParameters.AnimationsEnabled = [bool]$animationsProperty.Value } + $resources = New-SnipThemeResources @themeParameters + + [xml]$xaml = [xml](Get-SnipXamlText -Name 'FloatingWidget') + $reader = [System.Xml.XmlNodeReader]::new($xaml) + try { $window = [System.Windows.Markup.XamlReader]::Load($reader) } + finally { $reader.Dispose() } + Add-SnipThemeResources -Root $window -Resources $resources | Out-Null + + $workArea = [System.Windows.SystemParameters]::WorkArea + $window.Left = $workArea.Left + (($workArea.Width - $window.Width) / 2) + $shownTop = $workArea.Top + 10 + $hiddenTop = $workArea.Top - $window.Height + 9 + $window.Top = $hiddenTop + $widgetState = [pscustomobject]@{ + LastAnimationDuration = [timespan]::Zero + Closed = $false + ClosedHandler = $null + } + $moveWidget = { + param([double]$TargetTop) + if ([bool]$resources['SnipAnimationsEnabled']) { + $duration = [timespan]$resources['SnipMotionDuration'] + $animation = [System.Windows.Media.Animation.DoubleAnimation]::new() + $animation.From = $window.Top + $animation.To = $TargetTop + $animation.Duration = [System.Windows.Duration]::new($duration) + $animation.FillBehavior = [System.Windows.Media.Animation.FillBehavior]::Stop + $ease = [System.Windows.Media.Animation.CubicEase]::new() + $ease.EasingMode = [System.Windows.Media.Animation.EasingMode]::EaseOut + $animation.EasingFunction = $ease + $window.Top = $TargetTop + $window.BeginAnimation([System.Windows.Window]::TopProperty, $animation) + $widgetState.LastAnimationDuration = $duration + } else { + $window.BeginAnimation([System.Windows.Window]::TopProperty, $null) + $window.Top = $TargetTop + $widgetState.LastAnimationDuration = [timespan]::Zero + } + }.GetNewClosure() + $reveal = { & $moveWidget $shownTop }.GetNewClosure() + $conceal = { & $moveWidget $hiddenTop }.GetNewClosure() + + $buttons = [ordered]@{ + SmartBtn = $window.FindName('SmartBtn') + FullBtn = $window.FindName('FullBtn') + WindowBtn = $window.FindName('WindowBtn') + SettingsBtn = $window.FindName('SettingsBtn') + } + $activeShortcutProperty = $Context.PSObject.Properties['ActiveShortcut'] + $activeShortcut = if ($null -ne $activeShortcutProperty -and + -not [string]::IsNullOrWhiteSpace([string]$activeShortcutProperty.Value)) { + [string]$activeShortcutProperty.Value + } elseif ($null -ne $Context.PSObject.Properties['RegisteredHotkey'] -and + $null -ne $Context.RegisteredHotkey) { + Format-SnipHotkey -Modifiers ([int]$Context.RegisteredHotkey.Modifiers) ` + -VirtualKey ([int]$Context.RegisteredHotkey.VirtualKey) + } else { 'Unavailable' } + $buttons.SmartBtn.ToolTip = "Smart capture ($activeShortcut)" + $smartClick = [System.Windows.RoutedEventHandler]{ param($sender,$eventArgs) & $submitRequest 'Smart' ([timespan]::Zero) 'Widget' | Out-Null }.GetNewClosure() + $fullClick = [System.Windows.RoutedEventHandler]{ param($sender,$eventArgs) & $submitRequest 'Full' ([timespan]::Zero) 'Widget' | Out-Null }.GetNewClosure() + $windowClick = [System.Windows.RoutedEventHandler]{ param($sender,$eventArgs) & $submitRequest 'Window' ([timespan]::Zero) 'Widget' | Out-Null }.GetNewClosure() + $settingsClick = [System.Windows.RoutedEventHandler]{ param($sender,$eventArgs) & $openSettings | Out-Null }.GetNewClosure() + $buttons.SmartBtn.Add_Click($smartClick) + $buttons.FullBtn.Add_Click($fullClick) + $buttons.WindowBtn.Add_Click($windowClick) + $buttons.SettingsBtn.Add_Click($settingsClick) + + $mouseEnterHandler = [System.Windows.Input.MouseEventHandler]{ param($sender,$eventArgs) & $reveal }.GetNewClosure() + $mouseLeaveHandler = [System.Windows.Input.MouseEventHandler]{ + param($sender,$eventArgs) + if (-not $window.IsMouseOver) { & $conceal } + }.GetNewClosure() + $window.Add_MouseEnter($mouseEnterHandler) + $window.Add_MouseLeave($mouseLeaveHandler) + + $timer = [System.Windows.Threading.DispatcherTimer]::new() + $timer.Interval = [timespan]::FromMilliseconds(150) + $timerTickHandler = [EventHandler]{ + param($sender,$eventArgs) + $position = [System.Windows.Forms.Control]::MousePosition + $scaleX = [math]::Max(0.01, [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Width / + [System.Windows.SystemParameters]::PrimaryScreenWidth) + $scaleY = [math]::Max(0.01, [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Height / + [System.Windows.SystemParameters]::PrimaryScreenHeight) + $dipX = $workArea.Left + (($position.X - [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Left) / $scaleX) + $dipY = $workArea.Top + (($position.Y - [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Top) / $scaleY) + if ($dipY -le $workArea.Top + 4 -and $dipX -ge $window.Left -and + $dipX -le $window.Left + $window.Width) { + & $reveal + } elseif (-not $window.IsMouseOver -and $dipY -gt $shownTop + $window.Height + 8) { + & $conceal + } + }.GetNewClosure() + $timer.Add_Tick($timerTickHandler) + + $lifecycleParameters = @{ Window = $window } + $registerWindowProperty = $Context.PSObject.Properties['RegisterWindow'] + $unregisterWindowProperty = $Context.PSObject.Properties['UnregisterWindow'] + if ($null -ne $registerWindowProperty -and $registerWindowProperty.Value -is [scriptblock]) { + $lifecycleParameters.Register = $registerWindowProperty.Value + } + if ($null -ne $unregisterWindowProperty -and $unregisterWindowProperty.Value -is [scriptblock]) { + $lifecycleParameters.Unregister = $unregisterWindowProperty.Value + } + $lifecycle = Connect-SnipWindowLifecycle @lifecycleParameters + $closedHandler = [EventHandler]{ + param($sender,$eventArgs) + if ($widgetState.Closed) { return } + $widgetState.Closed = $true + $timer.Stop() + $timer.Remove_Tick($timerTickHandler) + $window.Remove_MouseEnter($mouseEnterHandler) + $window.Remove_MouseLeave($mouseLeaveHandler) + $buttons.SmartBtn.Remove_Click($smartClick) + $buttons.FullBtn.Remove_Click($fullClick) + $buttons.WindowBtn.Remove_Click($windowClick) + $buttons.SettingsBtn.Remove_Click($settingsClick) + Clear-SnipWidgetWindow -Window $window + $window.Remove_Closed($widgetState.ClosedHandler) + }.GetNewClosure() + $widgetState.ClosedHandler = $closedHandler + $window.Add_Closed($closedHandler) + + $click = { + param([Parameter(Mandatory)] [string]$Name) + if (-not $buttons.Contains($Name)) { throw [ArgumentException]::new("Unknown widget control '$Name'.") } + $eventArgs = [System.Windows.RoutedEventArgs]::new([System.Windows.Controls.Button]::ClickEvent) + $buttons[$Name].RaiseEvent($eventArgs) + }.GetNewClosure() + $kit = [pscustomobject]@{ + Window = $window + Resources = $resources + Lifecycle = $lifecycle + Order = [string[]]@('Smart','Full','Window','Settings') + Buttons = $buttons + Click = $click + Reveal = $reveal + Conceal = $conceal + State = $widgetState + } + + $script:WidgetWindow = $window + if ($TestAction) { + $window.Left = -10000 + $window.Top = -10000 + $window.Show() + $window.UpdateLayout() + try { & $TestAction $kit | Out-Null } + finally { + if ($window.IsVisible) { $window.Close() } + Clear-SnipWidgetWindow -Window $window + } + return $null + } + + $window.Show() + $timer.Start() + $window +} + +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)" + $activeBinding = $null + $candidateRemoved = $false + try { + if (-not [bool](& $Unregister $hwnd 1)) { + throw [InvalidOperationException]::new('Could not unregister the unsaved candidate hotkey.') + } + $candidateRemoved = $true + } catch { + $candidateText = Format-SnipHotkey -Modifiers $candidateBinding.Modifiers ` + -VirtualKey $candidateBinding.VirtualKey + $rollbackError = "The unsaved shortcut $candidateText may remain active because native cleanup failed: $($_.Exception.Message)" + $activeBinding = $candidateBinding + $Context.RegisteredHotkey = $candidateBinding + } + if ($candidateRemoved) { + try { + 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 + } + $activeBinding = $Context.RegisteredHotkey + } catch { + $rollbackError = "Could not restore the previous hotkey: $($_.Exception.Message)" + $Context.RegisteredHotkey = $null + $activeBinding = $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 = $activeBinding + CandidateError = $candidateError + RollbackError = $rollbackError + } + } + + $Context.Settings.Hotkey = $candidateBinding + $Context.RegisteredHotkey = $candidateBinding + [pscustomobject]@{ + Success = $true + ActiveBinding = $candidateBinding + CandidateError = $null + RollbackError = $null + } +} + +function New-SnipTrayMenu { + [CmdletBinding()] + param([Parameter(Mandatory)] $Context) + + foreach ($requiredService in 'SubmitRequest','OpenSettings','OpenAbout','Exit') { + $property = $Context.PSObject.Properties[$requiredService] + if ($null -eq $property -or $property.Value -isnot [scriptblock]) { + throw [ArgumentException]::new("Tray context is missing the $requiredService service.", 'Context') + } + } + $tokens = Get-SnipThemeTokens + $highContrastProperty = $Context.PSObject.Properties['HighContrast'] + $highContrast = if ($null -ne $highContrastProperty) { + [bool]$highContrastProperty.Value + } else { + [bool][System.Windows.Forms.SystemInformation]::HighContrast + } + if ($highContrast) { + $backgroundColor = [System.Drawing.SystemColors]::Menu + $textColor = [System.Drawing.SystemColors]::MenuText + $accentColor = [System.Drawing.SystemColors]::Highlight + $accentTextColor = [System.Drawing.SystemColors]::HighlightText + $borderColor = [System.Drawing.SystemColors]::ActiveBorder + } else { + $backgroundColor = [System.Drawing.ColorTranslator]::FromHtml($tokens.PageBlack) + $textColor = [System.Drawing.ColorTranslator]::FromHtml($tokens.PrimaryText) + $accentColor = [System.Drawing.ColorTranslator]::FromHtml($tokens.Accent) + $accentTextColor = [System.Drawing.ColorTranslator]::FromHtml($tokens.PageBlack) + $borderColor = [System.Drawing.Color]::FromArgb(0x59, 255, 255, 255) + } + $palette = [pscustomobject][ordered]@{ + PageBlack = [System.Drawing.ColorTranslator]::ToHtml($backgroundColor) + PrimaryText = [System.Drawing.ColorTranslator]::ToHtml($textColor) + Accent = [System.Drawing.ColorTranslator]::ToHtml($accentColor) + AccentText = [System.Drawing.ColorTranslator]::ToHtml($accentTextColor) + } + + $menu = [System.Windows.Forms.ContextMenuStrip]::new() + $menu.Name = 'SnipTrayMenu' + $menu.ShowImageMargin = $false + $menu.ShowCheckMargin = $true + $menu.BackColor = $backgroundColor + $menu.ForeColor = $textColor + $menu.Padding = [System.Windows.Forms.Padding]::new(5) + $menu.Font = [System.Drawing.Font]::new('Segoe UI Variable Text', 9.0, + [System.Drawing.FontStyle]::Regular, [System.Drawing.GraphicsUnit]::Point) + + $renderer = [System.Windows.Forms.ToolStripProfessionalRenderer]::new() + $renderer.RoundedEdges = $true + $renderBackground = [System.Windows.Forms.ToolStripRenderEventHandler]{ + param($sender,$eventArgs) + $brush = [System.Drawing.SolidBrush]::new($backgroundColor) + try { $eventArgs.Graphics.FillRectangle($brush, $eventArgs.AffectedBounds) } + finally { $brush.Dispose() } + }.GetNewClosure() + $renderBorder = [System.Windows.Forms.ToolStripRenderEventHandler]{ + param($sender,$eventArgs) + $rectangle = $eventArgs.ToolStrip.ClientRectangle + $rectangle.Width = [math]::Max(0, $rectangle.Width - 1) + $rectangle.Height = [math]::Max(0, $rectangle.Height - 1) + $pen = [System.Drawing.Pen]::new($borderColor, 1) + try { $eventArgs.Graphics.DrawRectangle($pen, $rectangle) } + finally { $pen.Dispose() } + }.GetNewClosure() + $renderItemBackground = [System.Windows.Forms.ToolStripItemRenderEventHandler]{ + param($sender,$eventArgs) + $color = if ($eventArgs.Item.Selected) { $accentColor } else { $backgroundColor } + $brush = [System.Drawing.SolidBrush]::new($color) + try { + $bounds = [System.Drawing.Rectangle]::new(2, 1, + [math]::Max(0, $eventArgs.Item.Width - 4), + [math]::Max(0, $eventArgs.Item.Height - 2)) + $eventArgs.Graphics.FillRectangle($brush, $bounds) + } finally { $brush.Dispose() } + }.GetNewClosure() + $renderText = [System.Windows.Forms.ToolStripItemTextRenderEventHandler]{ + param($sender,$eventArgs) + $eventArgs.TextColor = if ($eventArgs.Item.Selected) { $accentTextColor } else { $textColor } + }.GetNewClosure() + $renderSeparator = [System.Windows.Forms.ToolStripSeparatorRenderEventHandler]{ + param($sender,$eventArgs) + $pen = [System.Drawing.Pen]::new($borderColor, 1) + try { + $y = [int]($eventArgs.Item.Height / 2) + $eventArgs.Graphics.DrawLine($pen, 10, $y, [math]::Max(10, $eventArgs.Item.Width - 10), $y) + } finally { $pen.Dispose() } + }.GetNewClosure() + $checkPalette = [pscustomobject][ordered]@{ + Fill = $accentColor + Glyph = $accentTextColor + } + $renderItemCheck = [System.Windows.Forms.ToolStripItemImageRenderEventHandler]{ + param($sender,$eventArgs) + $rectangle = $eventArgs.ImageRectangle + if ($rectangle.Width -le 0 -or $rectangle.Height -le 0) { return } + + $contentRectangle = $eventArgs.Item.ContentRectangle + $chromeLeft = [math]::Max(2, $contentRectangle.Left) + $chromeRight = [math]::Min( + $eventArgs.Item.Width - 2, $rectangle.Right + 3) + $chromeRectangle = [System.Drawing.Rectangle]::new( + $chromeLeft, 0, + [math]::Max(0, $chromeRight - $chromeLeft), + $eventArgs.Item.Height) + $itemBackground = if ($eventArgs.Item.Selected) { + $accentColor + } else { + $backgroundColor + } + $backgroundBrush = [System.Drawing.SolidBrush]::new($itemBackground) + $brush = [System.Drawing.SolidBrush]::new($checkPalette.Fill) + $pen = [System.Drawing.Pen]::new($checkPalette.Glyph, 2) + $previousSmoothing = $eventArgs.Graphics.SmoothingMode + try { + $eventArgs.Graphics.FillRectangle($backgroundBrush, $chromeRectangle) + $eventArgs.Graphics.FillRectangle($brush, $rectangle) + $eventArgs.Graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $pen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round + $pen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round + $points = [System.Drawing.PointF[]]@( + [System.Drawing.PointF]::new( + $rectangle.Left + ($rectangle.Width * 0.22), + $rectangle.Top + ($rectangle.Height * 0.52)), + [System.Drawing.PointF]::new( + $rectangle.Left + ($rectangle.Width * 0.43), + $rectangle.Top + ($rectangle.Height * 0.72)), + [System.Drawing.PointF]::new( + $rectangle.Left + ($rectangle.Width * 0.78), + $rectangle.Top + ($rectangle.Height * 0.30)) + ) + $eventArgs.Graphics.DrawLines($pen, $points) + } finally { + $eventArgs.Graphics.SmoothingMode = $previousSmoothing + $pen.Dispose() + $brush.Dispose() + $backgroundBrush.Dispose() + } + }.GetNewClosure() + $renderer.add_RenderToolStripBackground($renderBackground) + $renderer.add_RenderToolStripBorder($renderBorder) + $renderer.add_RenderMenuItemBackground($renderItemBackground) + $renderer.add_RenderItemText($renderText) + $renderer.add_RenderSeparator($renderSeparator) + $renderer.add_RenderItemCheck($renderItemCheck) + $menu.Renderer = $renderer + + $submitRequest = $Context.SubmitRequest + $openSettings = $Context.OpenSettings + $openAbout = $Context.OpenAbout + $exitApplication = $Context.Exit + $handlerList = [System.Collections.Generic.List[object]]::new() + $items = [ordered]@{} + $activeShortcutProperty = $Context.PSObject.Properties['ActiveShortcut'] + $activeShortcut = if ($null -ne $activeShortcutProperty -and + -not [string]::IsNullOrWhiteSpace([string]$activeShortcutProperty.Value)) { + [string]$activeShortcutProperty.Value + } else { 'Unavailable' } + + $items.Smart = [System.Windows.Forms.ToolStripMenuItem]::new("Smart capture ($activeShortcut)") + $items.Smart.Name = 'Smart' + $smartClick = [EventHandler]{ param($sender,$eventArgs) & $submitRequest 'Smart' ([timespan]::Zero) | Out-Null }.GetNewClosure() + $items.Smart.Add_Click($smartClick); $handlerList.Add($smartClick) + [void]$menu.Items.Add($items.Smart) + + $items.Full = [System.Windows.Forms.ToolStripMenuItem]::new('Full desktop') + $items.Full.Name = 'Full' + $fullClick = [EventHandler]{ param($sender,$eventArgs) & $submitRequest 'Full' ([timespan]::Zero) | Out-Null }.GetNewClosure() + $items.Full.Add_Click($fullClick); $handlerList.Add($fullClick) + [void]$menu.Items.Add($items.Full) + + $items.Window = [System.Windows.Forms.ToolStripMenuItem]::new('Active window') + $items.Window.Name = 'Window' + $windowClick = [EventHandler]{ param($sender,$eventArgs) & $submitRequest 'Window' ([timespan]::Zero) | Out-Null }.GetNewClosure() + $items.Window.Add_Click($windowClick); $handlerList.Add($windowClick) + [void]$menu.Items.Add($items.Window) + + $items.Separator1 = [System.Windows.Forms.ToolStripSeparator]::new() + $items.Separator1.Name = 'Separator1' + [void]$menu.Items.Add($items.Separator1) + + $items.Delay = [System.Windows.Forms.ToolStripMenuItem]::new('Delay capture') + $items.Delay.Name = 'Delay' + foreach ($delaySpec in @( + [pscustomobject]@{ Text='Smart in 3 seconds'; Mode='Smart'; Seconds=3 }, + [pscustomobject]@{ Text='Smart in 5 seconds'; Mode='Smart'; Seconds=5 }, + [pscustomobject]@{ Text='Smart in 10 seconds'; Mode='Smart'; Seconds=10 }, + [pscustomobject]@{ Text='Full in 3 seconds'; Mode='Full'; Seconds=3 }, + [pscustomobject]@{ Text='Window in 3 seconds'; Mode='Window'; Seconds=3 })) { + $delayItem = [System.Windows.Forms.ToolStripMenuItem]::new($delaySpec.Text) + $delayItem.Tag = $delaySpec + $delayClick = [EventHandler]{ + param($sender,$eventArgs) + & $submitRequest $sender.Tag.Mode ([timespan]::FromSeconds([int]$sender.Tag.Seconds)) | Out-Null + }.GetNewClosure() + $delayItem.Add_Click($delayClick) + $handlerList.Add($delayClick) + [void]$items.Delay.DropDownItems.Add($delayItem) + } + [void]$menu.Items.Add($items.Delay) + + $items.Settings = [System.Windows.Forms.ToolStripMenuItem]::new('Settings') + $items.Settings.Name = 'Settings' + $settingsClick = [EventHandler]{ param($sender,$eventArgs) & $openSettings | Out-Null }.GetNewClosure() + $items.Settings.Add_Click($settingsClick); $handlerList.Add($settingsClick) + [void]$menu.Items.Add($items.Settings) + + $items.Widget = [System.Windows.Forms.ToolStripMenuItem]::new('Edge-reveal widget') + $items.Widget.Name = 'Widget' + $items.Widget.CheckOnClick = $true + $items.Widget.Checked = [bool]$Context.Settings.WidgetVisible + $toggleWidgetProperty = $Context.PSObject.Properties['SetWidgetVisible'] + $widgetClick = [EventHandler]{ + param($sender,$eventArgs) + $visible = [bool]$sender.Checked + if ($null -ne $toggleWidgetProperty -and $toggleWidgetProperty.Value -is [scriptblock]) { + & $toggleWidgetProperty.Value $visible | Out-Null + } else { + $Context.Settings.WidgetVisible = $visible + } + }.GetNewClosure() + $items.Widget.Add_Click($widgetClick); $handlerList.Add($widgetClick) + [void]$menu.Items.Add($items.Widget) + + $items.OpenFolder = [System.Windows.Forms.ToolStripMenuItem]::new('Open snips folder') + $items.OpenFolder.Name = 'OpenFolder' + $openFolderProperty = $Context.PSObject.Properties['OpenFolder'] + $openFolderClick = [EventHandler]{ + param($sender,$eventArgs) + if ($null -ne $openFolderProperty -and $openFolderProperty.Value -is [scriptblock]) { + & $openFolderProperty.Value | Out-Null + } + }.GetNewClosure() + $items.OpenFolder.Add_Click($openFolderClick); $handlerList.Add($openFolderClick) + [void]$menu.Items.Add($items.OpenFolder) + + $items.Separator2 = [System.Windows.Forms.ToolStripSeparator]::new() + $items.Separator2.Name = 'Separator2' + [void]$menu.Items.Add($items.Separator2) + + $items.About = [System.Windows.Forms.ToolStripMenuItem]::new('About SnipIT') + $items.About.Name = 'About' + $aboutClick = [EventHandler]{ param($sender,$eventArgs) & $openAbout | Out-Null }.GetNewClosure() + $items.About.Add_Click($aboutClick); $handlerList.Add($aboutClick) + [void]$menu.Items.Add($items.About) + + $items.Uninstall = [System.Windows.Forms.ToolStripMenuItem]::new('Uninstall') + $items.Uninstall.Name = 'Uninstall' + $uninstallProperty = $Context.PSObject.Properties['Uninstall'] + $uninstallClick = [EventHandler]{ + param($sender,$eventArgs) + if ($null -ne $uninstallProperty -and $uninstallProperty.Value -is [scriptblock]) { + & $uninstallProperty.Value | Out-Null + } + }.GetNewClosure() + $items.Uninstall.Add_Click($uninstallClick); $handlerList.Add($uninstallClick) + [void]$menu.Items.Add($items.Uninstall) + + $items.Exit = [System.Windows.Forms.ToolStripMenuItem]::new('Exit') + $items.Exit.Name = 'Exit' + $exitClick = [EventHandler]{ param($sender,$eventArgs) & $exitApplication | Out-Null }.GetNewClosure() + $items.Exit.Add_Click($exitClick); $handlerList.Add($exitClick) + [void]$menu.Items.Add($items.Exit) + + $registerWindowProperty = $Context.PSObject.Properties['RegisterWindow'] + $unregisterWindowProperty = $Context.PSObject.Properties['UnregisterWindow'] + $registerMenuWindow = if ($null -ne $registerWindowProperty -and + $registerWindowProperty.Value -is [scriptblock]) { $registerWindowProperty.Value } else { $null } + $unregisterMenuWindow = if ($null -ne $unregisterWindowProperty -and + $unregisterWindowProperty.Value -is [scriptblock]) { $unregisterWindowProperty.Value } else { $null } + $dropDownLifecycles = [System.Collections.Generic.List[object]]::new() + $connectDropDownLifecycle = { + param([Parameter(Mandatory)] [System.Windows.Forms.ToolStripDropDown]$DropDown) + + $windowState = [pscustomobject]@{ + DropDown = $DropDown + Handle = [IntPtr]::Zero + Registered = $false + OpenedHandler = $null + ClosedHandler = $null + DisposedHandler = $null + RegisterWindow = $registerMenuWindow + UnregisterWindow = $unregisterMenuWindow + } + $disconnect = { + if (-not $windowState.Registered) { return } + try { + if ($null -ne $windowState.UnregisterWindow -and + $windowState.Handle -ne [IntPtr]::Zero) { + & $windowState.UnregisterWindow $windowState.Handle + } + } finally { + $windowState.Handle = [IntPtr]::Zero + $windowState.Registered = $false + } + }.GetNewClosure() + $opened = [EventHandler]{ + param($sender,$eventArgs) + if ($windowState.Registered -or $null -eq $windowState.RegisterWindow) { return } + $handle = $DropDown.Handle + if ($handle -eq [IntPtr]::Zero) { return } + & $windowState.RegisterWindow $handle + $windowState.Handle = $handle + $windowState.Registered = $true + }.GetNewClosure() + $closed = [System.Windows.Forms.ToolStripDropDownClosedEventHandler]{ + param($sender,$eventArgs) + & $disconnect + }.GetNewClosure() + $disposed = [EventHandler]{ + param($sender,$eventArgs) + try { & $disconnect } + finally { + $DropDown.Remove_Opened($windowState.OpenedHandler) + $DropDown.Remove_Closed($windowState.ClosedHandler) + $DropDown.Remove_Disposed($windowState.DisposedHandler) + } + }.GetNewClosure() + $windowState.OpenedHandler = $opened + $windowState.ClosedHandler = $closed + $windowState.DisposedHandler = $disposed + $DropDown.Add_Opened($opened) + $DropDown.Add_Closed($closed) + $DropDown.Add_Disposed($disposed) + $dropDownLifecycles.Add($windowState) + $windowState + }.GetNewClosure() + + $pendingDropDowns = [System.Collections.Generic.Queue[System.Windows.Forms.ToolStripDropDown]]::new() + $pendingDropDowns.Enqueue($menu) + while ($pendingDropDowns.Count -gt 0) { + $dropDown = $pendingDropDowns.Dequeue() + $dropDown.Renderer = $renderer + $dropDown.BackColor = $backgroundColor + $dropDown.ForeColor = $textColor + $dropDown.Font = $menu.Font + $dropDown.Margin = $menu.Margin + $dropDown.Padding = $menu.Padding + if ($dropDown -is [System.Windows.Forms.ToolStripDropDownMenu]) { + $dropDown.ShowImageMargin = $menu.ShowImageMargin + $dropDown.ShowCheckMargin = $menu.ShowCheckMargin + } + & $connectDropDownLifecycle $dropDown | Out-Null + foreach ($item in $dropDown.Items) { + if ($item -is [System.Windows.Forms.ToolStripDropDownItem] -and + $item.HasDropDownItems) { + $pendingDropDowns.Enqueue($item.DropDown) + } + } + } + $rootWindowState = $dropDownLifecycles[0] + + $state = [pscustomobject]@{ + Palette = $palette + HighContrast = $highContrast + OwnerDrawn = $true + PrimaryOrder = [string[]]@('Smart','Full','Window','Settings','About','Exit') + Order = [string[]]@( + 'Smart','Full','Window','Separator1','Delay','Settings','Widget', + 'OpenFolder','Separator2','About','Uninstall','Exit') + Items = $items + Handlers = $handlerList + RenderHandlers = [object[]]@( + $renderBackground,$renderBorder,$renderItemBackground,$renderText,$renderSeparator, + $renderItemCheck) + Renderer = $renderer + MenuFont = $menu.Font + CheckPalette = $checkPalette + CheckRenderHandler = $renderItemCheck + CheckHandlerAttached = $true + WindowState = $rootWindowState + OpenedHandler = $rootWindowState.OpenedHandler + ClosedHandler = $rootWindowState.ClosedHandler + DropDownLifecycles = $dropDownLifecycles + DisposeHandler = $null + } + $menu.Tag = $state + $disposeHandler = [EventHandler]{ + param($sender,$eventArgs) + $renderer.remove_RenderToolStripBackground($renderBackground) + $renderer.remove_RenderToolStripBorder($renderBorder) + $renderer.remove_RenderMenuItemBackground($renderItemBackground) + $renderer.remove_RenderItemText($renderText) + $renderer.remove_RenderSeparator($renderSeparator) + $renderer.remove_RenderItemCheck($state.CheckRenderHandler) + $state.CheckHandlerAttached = $false + $menu.Remove_Disposed($state.DisposeHandler) + $state.MenuFont.Dispose() + }.GetNewClosure() + $state.DisposeHandler = $disposeHandler + $menu.Add_Disposed($disposeHandler) + $menu +} diff --git a/src/90-Main.ps1 b/src/90-Main.ps1 new file mode 100644 index 0000000..952cb5c --- /dev/null +++ b/src/90-Main.ps1 @@ -0,0 +1,401 @@ +#requires -Version 7.5 +# Generated deterministically from the reviewed SnipIT.ps1 AST by scripts/Export-SnipITModules.ps1. + +$hotkeyForm = $null + +$tray = $null + +$menu = $null + +$trayDoubleClickHandler = $null + +$hkWin = $null + +$registeredHotkey = $null + +try { +$bootstrapReady = & $script:SnipBootstrapInitializer -Phase Bootstrap + +if (-not $bootstrapReady) { return } + +& $script:SnipNativeInitializer + +$null = & $script:SnipBootstrapInitializer -Phase Settings + +$script:SnipBootstrapInitializer = $null + +$script:SnipNativeInitializer = $null + +if ($env:SNIPIT_TEST_MODE) { return } + +$hotkeyForm = New-Object System.Windows.Forms.Form + +$hotkeyForm.FormBorderStyle = 'FixedToolWindow' + +$hotkeyForm.ShowInTaskbar = $false + +$hotkeyForm.Opacity = 0 + +$hotkeyForm.Size = New-Object System.Drawing.Size 1, 1 + +$hotkeyForm.StartPosition = 'Manual' + +$hotkeyForm.Location = New-Object System.Drawing.Point -2000, -2000 + +$HOTKEY_SMART = 1 + +$WM_HOTKEY = 0x0312 + +if (-not ('HotkeyWindow' -as [type])) { + $nativeWindowSrc = @' +using System; +using System.Windows.Forms; +public class HotkeyWindow : NativeWindow { + public Action Callback; + private Control sync; + public HotkeyWindow(Form host) { + sync = host; + AssignHandle(host.Handle); + } + protected override void WndProc(ref Message m) { + if (m.Msg == 0x0312 && Callback != null && sync != null && sync.IsHandleCreated) { + int id = (int)m.WParam; + Action cb = Callback; + sync.BeginInvoke(new Action(delegate { cb(id); })); + } + base.WndProc(ref m); + } +} +'@ + # .NET 9 splits WinForms across multiple assemblies. Reference each by + # resolving a type that lives in it. + $refs = @( + [System.Windows.Forms.Form].Assembly.Location, # System.Windows.Forms + [System.Windows.Forms.Message].Assembly.Location, # System.Windows.Forms.Primitives + [System.ComponentModel.Component].Assembly.Location, # System.ComponentModel.Primitives + [System.Drawing.Bitmap].Assembly.Location, # System.Drawing.Common + [System.Drawing.Color].Assembly.Location # System.Drawing.Primitives + ) | Sort-Object -Unique + Add-Type -TypeDefinition $nativeWindowSrc -ReferencedAssemblies $refs +} + +$hotkeyForm.CreateControl() + +$null = $hotkeyForm.Handle + +Register-SelfWindowHandle -Hwnd $hotkeyForm.Handle + +if ($script:ConsoleHwnd) { Register-SelfWindowHandle -Hwnd $script:ConsoleHwnd } + +$postToHost = { + param($work) + if ($null -eq $hotkeyForm -or $hotkeyForm.IsDisposed -or + -not $hotkeyForm.IsHandleCreated) { return $false } + $postedAction = [Action]{ & $work }.GetNewClosure() + [void]$hotkeyForm.BeginInvoke($postedAction) + return $true +}.GetNewClosure() + +$script:CaptureCoordinator = New-SnipCaptureCoordinator ` + -Post $postToHost ` + -Services (New-SnipRuntimeCaptureServices) ` + -Settings $script:Settings + +$hkWin = New-Object HotkeyWindow $hotkeyForm + +$hkWin.Callback = [Action[int]]{ + param([int]$id) + try { + if ($id -eq $HOTKEY_SMART) { + Request-SnipCapture -Coordinator $script:CaptureCoordinator ` + -Mode Smart -Source Hotkey | Out-Null + } + } catch { + Write-SnipDiag -Message 'Hotkey capture failed' -ErrorRecord $_ + try { + $script:tray.BalloonTipTitle = 'SnipIT error' + $script:tray.BalloonTipText = $_.Exception.Message + $script:tray.ShowBalloonTip(3000) + } catch {} + } +} + +$registerNativeHotkey = { + param($hwnd, $id, $modifiers, $virtualKey) + [Native]::RegisterHotKey($hwnd, $id, $modifiers, $virtualKey) +} + +$initialHotkeyResult = Register-SnipHotkeyBinding -Hwnd $hotkeyForm.Handle ` + -Binding $script:Settings.Hotkey -Register $registerNativeHotkey + +$registeredHotkey = $initialHotkeyResult.ActiveBinding + +$script:CaptureCoordinator.RegisteredHotkey = $registeredHotkey + +$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' +} + +$script:tray = New-Object System.Windows.Forms.NotifyIcon + +$tray = $script:tray + +$tray.Visible = $true + +$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 } + +$utilityState = [pscustomobject]@{ + Coordinator = $script:CaptureCoordinator + Tray = $tray + Menu = $null + Context = $null +} + +$installPathsForUtilities = $script:InstallPaths + +$unregisterNativeHotkey = { + param($hwnd, $id) + [Native]::UnregisterHotKey($hwnd, $id) +} + +$submitUtilityRequest = { + param( + [string]$Mode, + [timespan]$Delay = [timespan]::Zero, + [string]$Source = 'Tray' + ) + Request-SnipCapture -Coordinator $utilityState.Coordinator ` + -Mode $Mode -Delay $Delay -Source $Source | Out-Null +}.GetNewClosure() + +$publishAuxiliarySurface = { + param($surface) + Set-SnipAuxiliarySurface -Coordinator $utilityState.Coordinator -Surface $surface +}.GetNewClosure() + +$completeAuxiliarySurface = { + param([string]$Result, $surface) + Complete-SnipAuxiliarySurface -Coordinator $utilityState.Coordinator ` + -Surface $surface -Result $Result | Out-Null +}.GetNewClosure() + +$openSettings = { + if ($utilityState.Coordinator.Phase -ne 'Idle') { + $utilityState.Tray.BalloonTipTitle = 'SnipIT is busy' + $utilityState.Tray.BalloonTipText = 'Finish or cancel the current capture before opening Settings.' + $utilityState.Tray.ShowBalloonTip(2500) + return + } + try { Show-SettingsWindow -Context $utilityState.Context | Out-Null } + catch { + Write-SnipDiag -Message 'Settings window failed' -ErrorRecord $_ + $utilityState.Tray.BalloonTipTitle = 'SnipIT Settings error' + $utilityState.Tray.BalloonTipText = $_.Exception.Message + $utilityState.Tray.ShowBalloonTip(3000) + } +}.GetNewClosure() + +$openAbout = { + if ($utilityState.Coordinator.Phase -ne 'Idle') { + $utilityState.Tray.BalloonTipTitle = 'SnipIT is busy' + $utilityState.Tray.BalloonTipText = 'Finish or cancel the current capture before opening About.' + $utilityState.Tray.ShowBalloonTip(2500) + return + } + try { Show-AboutWindow -Context $utilityState.Context | Out-Null } + catch { + Write-SnipDiag -Message 'About window failed' -ErrorRecord $_ + $utilityState.Tray.BalloonTipTitle = 'SnipIT About error' + $utilityState.Tray.BalloonTipText = $_.Exception.Message + $utilityState.Tray.ShowBalloonTip(3000) + } +}.GetNewClosure() + +$syncStartup = { + param($settings) + Sync-SnipStartupShortcut -Settings $settings -Paths $installPathsForUtilities +}.GetNewClosure() + +$setWidgetVisible = { + param([bool]$Visible) + $previous = [bool]$utilityState.Context.Settings.WidgetVisible + if ($previous -ne $Visible) { + $utilityState.Context.Settings.WidgetVisible = $Visible + try { + Save-SnipSettings -Settings $utilityState.Context.Settings ` + -Path $utilityState.Context.SettingsPath + } catch { + $utilityState.Context.Settings.WidgetVisible = $previous + $Visible = $previous + Write-SnipDiag -Message 'Widget visibility could not be saved' -ErrorRecord $_ + $utilityState.Tray.BalloonTipTitle = 'SnipIT Settings error' + $utilityState.Tray.BalloonTipText = 'Widget visibility could not be saved.' + $utilityState.Tray.ShowBalloonTip(3000) + } + } + if ($utilityState.Menu -and $utilityState.Menu.Tag -and + $utilityState.Menu.Tag.Items['Widget']) { + $utilityState.Menu.Tag.Items['Widget'].Checked = $Visible + } + Show-FloatingWidget -Context $utilityState.Context | Out-Null +}.GetNewClosure() + +$openFolder = { + $directory = [string]$utilityState.Context.Settings.SaveFolder + New-Item -ItemType Directory -Force -Path $directory | Out-Null + Start-Process explorer.exe -ArgumentList $directory +}.GetNewClosure() + +$exitApplication = { + Stop-SnipCaptureCoordinator -Coordinator $utilityState.Coordinator + $utilityState.Tray.Visible = $false + [System.Windows.Forms.Application]::Exit() +}.GetNewClosure() + +$uninstallApplication = { + $response = [System.Windows.Forms.MessageBox]::Show( + 'Remove SnipIT shortcuts and AppData folder?', + 'Uninstall SnipIT', 'YesNo', 'Warning') + if ($response -eq 'Yes') { + Stop-SnipCaptureCoordinator -Coordinator $utilityState.Coordinator + Uninstall-SnipIT + $utilityState.Tray.Visible = $false + [System.Windows.Forms.Application]::Exit() + } +}.GetNewClosure() + +$hotkeyChanged = { + param($result) + $active = $result.ActiveBinding + $utilityState.Coordinator.RegisteredHotkey = $active + $utilityState.Context.RegisteredHotkey = $active + $text = if ($null -eq $active) { + 'Unavailable' + } else { + Format-SnipHotkey -Modifiers ([int]$active.Modifiers) -VirtualKey ([int]$active.VirtualKey) + } + $utilityState.Context.ActiveShortcut = $text + $utilityState.Tray.Text = if ($null -eq $active) { 'SnipIT - use tray to capture' } else { "SnipIT - $text to snip" } + if ($utilityState.Menu -and $utilityState.Menu.Tag -and + $utilityState.Menu.Tag.Items['Smart']) { + $utilityState.Menu.Tag.Items['Smart'].Text = "Smart capture ($text)" + } +}.GetNewClosure() + +$script:UtilityContext = [pscustomobject][ordered]@{ + AppVersion = $script:SnipITAppVersion + Repository = 'https://github.com/RandomCodeSpace/snipIT' + License = 'MIT License' + Settings = $script:Settings + SettingsPath = $script:SettingsPath + RegisteredHotkey = $registeredHotkey + ActiveShortcut = $hotkeyText + Hwnd = $hotkeyForm.Handle + RegisterHotkey = $registerNativeHotkey + UnregisterHotkey = $unregisterNativeHotkey + SubmitRequest = $submitUtilityRequest + OpenSettings = $openSettings + OpenAbout = $openAbout + OpenFolder = $openFolder + Exit = $exitApplication + Uninstall = $uninstallApplication + SyncStartup = $syncStartup + SetWidgetVisible = $setWidgetVisible + OnHotkeyChanged = $hotkeyChanged + OnSurfaceReady = $publishAuxiliarySurface + OnSurfaceClosed = $completeAuxiliarySurface + RegisterWindow = { param($hwnd) Register-SelfWindowHandle -Hwnd $hwnd } + UnregisterWindow = { param($hwnd) Unregister-SelfWindowHandle -Hwnd $hwnd } +} + +$utilityState.Context = $script:UtilityContext + +$menu = New-SnipTrayMenu -Context $script:UtilityContext + +$utilityState.Menu = $menu + +$tray.ContextMenuStrip = $menu + +$trayDoubleClickHandler = [EventHandler]{ + param($sender,$eventArgs) + & $utilityState.Context.SubmitRequest 'Smart' ([timespan]::Zero) 'Tray' | Out-Null +}.GetNewClosure() + +$tray.Add_DoubleClick($trayDoubleClickHandler) + +if ($script:Settings.WidgetVisible) { + Show-FloatingWidget -Context $script:UtilityContext | Out-Null +} + +if (-not $initialHotkeyResult.Success) { + Write-SnipDiag -Message "Hotkey registration failed: $($initialHotkeyResult.CandidateError)" + $tray.BalloonTipTitle = 'SnipIT — hotkey conflict' + $tray.BalloonTipText = "Could not register $configuredHotkeyText. Choose another shortcut in Settings; capture remains available from the tray." + $tray.ShowBalloonTip(5000) + $conflictSettingsAction = [Action]{ + & $utilityState.Context.OpenSettings | Out-Null + }.GetNewClosure() + [void]$hotkeyForm.BeginInvoke($conflictSettingsAction) +} elseif ($script:SnipFreshInstall) { + $tray.BalloonTipTitle = 'SnipIT installed' + $tray.BalloonTipText = "Press $hotkeyText to capture. Right-click the tray icon for options." + $tray.ShowBalloonTip(4000) +} + +[System.Windows.Forms.Application]::Run() +} +catch { + # Surface the actual inner exception (PS wraps the .NET one in a + # MethodInvocationException whose Message is unhelpfully generic). + $msg = $_.Exception.Message + if ($_.Exception.InnerException) { + $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 { + if ($script:CaptureCoordinator) { + try { Stop-SnipCaptureCoordinator -Coordinator $script:CaptureCoordinator } + catch { Write-SnipDiag -Message 'Capture coordinator cleanup failed' -ErrorRecord $_ } + } + $activeHotkeyAtShutdown = if ($script:UtilityContext) { + $script:UtilityContext.RegisteredHotkey + } else { + $registeredHotkey + } + if ($activeHotkeyAtShutdown -and $hotkeyForm) { + try { [Native]::UnregisterHotKey($hotkeyForm.Handle, 1) | Out-Null } + catch { Write-SnipDiag -Message 'Hotkey cleanup failed' -ErrorRecord $_ } + } + if ($script:WidgetWindow -and $script:WidgetWindow.IsVisible) { + try { $script:WidgetWindow.Close() } catch {} + } + if ($tray -and $trayDoubleClickHandler) { + try { $tray.Remove_DoubleClick($trayDoubleClickHandler) } catch {} + } + if ($menu) { try { $menu.Dispose() } catch {} } + if ($tray) { try { $tray.Dispose() } catch {} } + if ($hotkeyForm) { try { $hotkeyForm.Dispose() } catch {} } + if ($script:SingleInstanceMutex) { + try { $script:SingleInstanceMutex.ReleaseMutex() } catch {} + try { $script:SingleInstanceMutex.Dispose() } catch {} + } + $script:SnipBootstrapInitializer = $null + $script:SnipNativeInitializer = $null +} diff --git a/tests/baselines/snipit-function-surface.json b/tests/baselines/snipit-function-surface.json new file mode 100644 index 0000000..051b94f --- /dev/null +++ b/tests/baselines/snipit-function-surface.json @@ -0,0 +1,875 @@ +{ + "ScriptParamBlock": "param([switch]$CoreOnly)", + "Functions": [ + { + "Name": "Add-SnipThemeResources", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [System.Windows.FrameworkElement]$Root,\n [Parameter(Mandatory)] [System.Windows.ResourceDictionary]$Resources\n )" + }, + { + "Name": "Clear-SnipWidgetWindow", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([System.Windows.Window]$Window)" + }, + { + "Name": "Close-SnipActiveSurface", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Coordinator,\n [ValidateSet('Completed', 'UserCancelled', 'Preempted', 'Failed', 'Shutdown')]\n [string]$Result = 'Preempted'\n )" + }, + { + "Name": "Complete-SnipAuxiliarySurface", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Coordinator,\n [Parameter(Mandatory)] $Surface,\n [Parameter(Mandatory)]\n [ValidateSet('Completed', 'UserCancelled', 'Preempted', 'Failed', 'Shutdown')]\n [string]$Result\n )" + }, + { + "Name": "Complete-SnipSurface", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Coordinator,\n [Parameter(Mandatory)]\n [ValidateSet('Completed', 'UserCancelled', 'Preempted', 'Failed', 'Shutdown')]\n [string]$Result,\n [ValidateSet('Capture', 'Selection', 'Preview', 'Copy', 'CopyKeepOpen', 'Save')]\n [string]$Operation = 'Preview'\n )" + }, + { + "Name": "Connect-SnipPreviewMenuButton", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [System.Windows.Controls.Button]$Button,\n [Parameter(Mandatory)] [System.Windows.Controls.ContextMenu]$Menu,\n [Parameter(Mandatory)] $Context,\n [Parameter(Mandatory)] [string]$Name\n )" + }, + { + "Name": "Connect-SnipPreviewTransientContextMenu", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [System.Windows.Controls.ContextMenu]$Menu,\n [Parameter(Mandatory)] [System.Windows.FrameworkElement]$PlacementTarget,\n [Parameter(Mandatory)] $Context,\n [Parameter(Mandatory)] [string]$Name,\n [scriptblock]$Cleanup = {}\n )" + }, + { + "Name": "Connect-SnipWindowLifecycle", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [System.Windows.Window]$Window,\n [scriptblock]$Register = { param($hwnd) Register-SelfWindowHandle -Hwnd $hwnd },\n [scriptblock]$Unregister = { param($hwnd) Unregister-SelfWindowHandle -Hwnd $hwnd }\n )" + }, + { + "Name": "Convert-BitmapToBitmapSource", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([System.Drawing.Bitmap]$Bitmap)" + }, + { + "Name": "ConvertFrom-SnipHexColor", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] [string]$Hex)" + }, + { + "Name": "ConvertTo-SnipAnnotationGeometry", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] $Geometry)" + }, + { + "Name": "ConvertTo-SnipCropLocalRect", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Rectangle,\n [Parameter(Mandatory)] $Crop\n )" + }, + { + "Name": "ConvertTo-SnipDipPoint", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [double]$PhysicalX,\n [Parameter(Mandatory)] [double]$PhysicalY,\n [Parameter(Mandatory)] [double]$MonitorPhysicalX,\n [Parameter(Mandatory)] [double]$MonitorPhysicalY,\n [Parameter(Mandatory)] [double]$ScaleX,\n [Parameter(Mandatory)] [double]$ScaleY\n )" + }, + { + "Name": "ConvertTo-SnipPhysicalPoint", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [double]$DipX,\n [Parameter(Mandatory)] [double]$DipY,\n [Parameter(Mandatory)] [double]$MonitorPhysicalX,\n [Parameter(Mandatory)] [double]$MonitorPhysicalY,\n [Parameter(Mandatory)] [double]$ScaleX,\n [Parameter(Mandatory)] [double]$ScaleY\n )" + }, + { + "Name": "ConvertTo-SnipSurfaceResult", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n $Value,\n [switch]$CaptureResult\n )" + }, + { + "Name": "Copy-AnnotationList", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([AllowNull()][AllowEmptyCollection()] $Annotations)" + }, + { + "Name": "Copy-SnipAnnotation", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] $Annotation)" + }, + { + "Name": "Copy-SnipSemanticKey", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([AllowNull()] $Key)" + }, + { + "Name": "Copy-SnipSemanticValue", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([AllowNull()] $Value)" + }, + { + "Name": "Find-SnipAnnotation", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [AllowNull()][AllowEmptyCollection()] $Annotations,\n [Parameter(Mandatory)] [double]$ImageX,\n [Parameter(Mandatory)] [double]$ImageY,\n [double]$Tolerance = 6.0\n )" + }, + { + "Name": "Format-SnipHotkey", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [int]$Modifiers,\n [Parameter(Mandatory)] [int]$VirtualKey\n )" + }, + { + "Name": "Get-ClampedAnnotationRect", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [int]$X,\n [Parameter(Mandatory)] [int]$Y,\n [Parameter(Mandatory)] [int]$Width,\n [Parameter(Mandatory)] [int]$Height,\n [Parameter(Mandatory)] [int]$BitmapWidth,\n [Parameter(Mandatory)] [int]$BitmapHeight\n )" + }, + { + "Name": "Get-CropBounds", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [int]$RectX,\n [Parameter(Mandatory)] [int]$RectY,\n [Parameter(Mandatory)] [int]$RectW,\n [Parameter(Mandatory)] [int]$RectH,\n [Parameter(Mandatory)] [int]$VsX,\n [Parameter(Mandatory)] [int]$VsY\n )" + }, + { + "Name": "Get-DefaultSnipFilename", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([datetime]$Timestamp = (Get-Date))" + }, + { + "Name": "Get-DragRectangle", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [double]$AnchorX,\n [Parameter(Mandatory)] [double]$AnchorY,\n [Parameter(Mandatory)] [double]$CurrentX,\n [Parameter(Mandatory)] [double]$CurrentY\n )" + }, + { + "Name": "Get-ImageFormatNameFromPath", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] [string]$Path)" + }, + { + "Name": "Get-InstallPaths", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [string]$LocalAppData = $env:LOCALAPPDATA,\n [string]$DesktopDir,\n [string]$StartupDir\n )" + }, + { + "Name": "Get-LoadedSetting", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] [string]$Name)" + }, + { + "Name": "Get-LoupePosition", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [int]$MouseX,\n [Parameter(Mandatory)] [int]$MouseY,\n [Parameter(Mandatory)] [int]$VsX,\n [Parameter(Mandatory)] [int]$VsY,\n [Parameter(Mandatory)] [int]$VsWidth,\n [Parameter(Mandatory)] [int]$VsHeight,\n [int]$LoupeWidth = 170,\n [int]$LoupeHeight = 190,\n [int]$Offset = 24,\n [int]$FlipMarginX = 14,\n [int]$FlipMarginY = 10\n )" + }, + { + "Name": "Get-LoupeSourceRect", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [int]$MouseX,\n [Parameter(Mandatory)] [int]$MouseY,\n [Parameter(Mandatory)] [int]$VsX,\n [Parameter(Mandatory)] [int]$VsY,\n [Parameter(Mandatory)] [int]$VsWidth,\n [Parameter(Mandatory)] [int]$VsHeight,\n [int]$Size = 18\n )" + }, + { + "Name": "Get-PreviewResponsiveMode", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [double]$Width,\n [Parameter(Mandatory)] [double]$Height\n )" + }, + { + "Name": "Get-RelativeLuminance", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] [double[]]$Rgb)" + }, + { + "Name": "Get-ShortcutArguments", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] [string]$ScriptPath)" + }, + { + "Name": "Get-SnipAnnotationHitView", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] $Annotation)" + }, + { + "Name": "Get-SnipContrastRatio", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [string]$Foreground,\n [Parameter(Mandatory)] [string]$Background\n )" + }, + { + "Name": "Get-SnipCoordinatorDecision", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [string]$Phase,\n [Parameter(Mandatory)] [string]$Event,\n [Parameter(Mandatory)] [bool]$HasPending\n )" + }, + { + "Name": "Get-SnipCoordinatorService", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Coordinator,\n [Parameter(Mandatory)] [string]$Name\n )" + }, + { + "Name": "Get-SnipCropRectangle", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [AllowNull()] $Candidate,\n [Parameter(Mandatory)] [int]$SourceWidth,\n [Parameter(Mandatory)] [int]$SourceHeight,\n [ValidateSet('Free','Original','1:1','4:3','16:9')] [string]$Preset = 'Free'\n )" + }, + { + "Name": "Get-SnipDefaultSettings", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([string]$PicturesDir = [Environment]::GetFolderPath('MyPictures'))" + }, + { + "Name": "Get-SnipDiag", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "" + }, + { + "Name": "Get-SnipITIconPath", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "" + }, + { + "Name": "Get-SnipMonitorDescriptors", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param()" + }, + { + "Name": "Get-SnipMonitorLayouts", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [object[]]$MonitorDescriptors\n )" + }, + { + "Name": "Get-SnipOverlayIntersections", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Rectangle,\n [Parameter(Mandatory)] [object[]]$MonitorLayouts\n )" + }, + { + "Name": "Get-SnipOverlayService", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Services,\n [Parameter(Mandatory)] [string]$Name\n )" + }, + { + "Name": "Get-SnipRecordValue", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $InputObject,\n [Parameter(Mandatory)] [string]$Name,\n [AllowNull()] $Default = $null,\n [switch]$Required\n )" + }, + { + "Name": "Get-SnipSettingsPath", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([string]$LocalAppData = $env:LOCALAPPDATA)" + }, + { + "Name": "Get-SnipThemeTokens", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "" + }, + { + "Name": "Get-SnipWindowAtPhysicalPoint", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Point,\n [object[]]$OwnHandles = @()\n )" + }, + { + "Name": "Get-SnipWindowBounds", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] [IntPtr]$Hwnd)" + }, + { + "Name": "Get-SnipXamlText", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)]\n [string]$Name\n )" + }, + { + "Name": "Get-TrimmedRecent", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [AllowNull()][AllowEmptyCollection()] $Items,\n [int]$MaxDepth = 100\n )" + }, + { + "Name": "Get-VirtualScreenBounds", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "" + }, + { + "Name": "Get-ZoomCenteredOffset", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [double]$CursorX,\n [Parameter(Mandatory)] [double]$CursorY,\n [Parameter(Mandatory)] [double]$OldScrollX,\n [Parameter(Mandatory)] [double]$OldScrollY,\n [Parameter(Mandatory)] [double]$OldScale,\n [Parameter(Mandatory)] [double]$NewScale,\n [Parameter(Mandatory)] [double]$ContentWidth,\n [Parameter(Mandatory)] [double]$ContentHeight,\n [Parameter(Mandatory)] [double]$ViewportWidth,\n [Parameter(Mandatory)] [double]$ViewportHeight\n )" + }, + { + "Name": "Hide-OwnSnipITWindowsForCapture", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param()" + }, + { + "Name": "Initialize-SnipPreviewAnnotations", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [System.Collections.IList]$Annotations\n )" + }, + { + "Name": "Install-SnipIT", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([object]$Paths = $script:InstallPaths)" + }, + { + "Name": "Invoke-CaptureLoop", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [scriptblock]$CaptureFactory,\n [Parameter(Mandatory)] [scriptblock]$PreviewHandler,\n [int]$MaxIterations = 32\n )" + }, + { + "Name": "Invoke-FullScreenCapture", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "" + }, + { + "Name": "Invoke-SmartCapture", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "" + }, + { + "Name": "Invoke-SnipCancelDelay", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] $Coordinator)" + }, + { + "Name": "Invoke-SnipCapturePump", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] $Coordinator)" + }, + { + "Name": "Invoke-SnipOverlayRenderTick", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Context,\n $PhysicalPoint\n )" + }, + { + "Name": "Invoke-SnipPreviewTransfer", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Bitmap,\n [Parameter(Mandatory)] [scriptblock]$Preview\n )" + }, + { + "Name": "Invoke-SnipResourceDispose", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] $Resource)" + }, + { + "Name": "Invoke-WindowCapture", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "" + }, + { + "Name": "Move-SnipAnnotation", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Annotation,\n [Parameter(Mandatory)] [int]$DeltaX,\n [Parameter(Mandatory)] [int]$DeltaY,\n [Parameter(Mandatory)] [int]$SourceWidth,\n [Parameter(Mandatory)] [int]$SourceHeight\n )" + }, + { + "Name": "New-ScreenBitmap", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [int]$X,\n [Parameter(Mandatory)] [int]$Y,\n [Parameter(Mandatory)] [int]$Width,\n [Parameter(Mandatory)] [int]$Height,\n [scriptblock]$BitmapFactory = {\n param($width,$height,$pixelFormat)\n [System.Drawing.Bitmap]::new($width, $height, $pixelFormat)\n },\n [scriptblock]$GraphicsFactory = {\n param($bitmap)\n [System.Drawing.Graphics]::FromImage($bitmap)\n },\n [scriptblock]$CopyPixels = {\n param($graphics,$x,$y,$width,$height)\n $graphics.CopyFromScreen(\n $x, $y, 0, 0,\n [System.Drawing.Size]::new($width, $height),\n [System.Drawing.CopyPixelOperation]::SourceCopy)\n }\n )" + }, + { + "Name": "New-SnipAnnotation", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Kind,\n [Parameter(Mandatory)] $Geometry,\n [Parameter(Mandatory)] [AllowEmptyString()] [string]$Color,\n [Parameter(Mandatory)] [double]$StrokeWidth,\n [Parameter(Mandatory)] [double]$Opacity,\n [Parameter(Mandatory)] [AllowNull()] $Properties,\n [Parameter(Mandatory)] [double]$Z,\n [AllowNull()] [AllowEmptyString()] [string]$Id\n )" + }, + { + "Name": "New-SnipCaptureCoordinator", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [scriptblock]$Post = { param($work) & $work },\n $Services = $null,\n $Settings = $null,\n $RegisteredHotkey = $null\n )" + }, + { + "Name": "New-SnipCaptureRequest", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)]\n [ValidateSet('Smart', 'Full', 'Window')]\n [string]$Mode,\n\n [timespan]$Delay = [timespan]::Zero,\n\n [Parameter(Mandatory)]\n [ValidateNotNullOrEmpty()]\n [string]$Source,\n\n [guid]$Id = [guid]::NewGuid(),\n [datetimeoffset]$SubmittedAt = [datetimeoffset]::UtcNow\n )" + }, + { + "Name": "New-SnipEditorSnapshot", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [AllowNull()][AllowEmptyCollection()] $Annotations,\n [AllowNull()] $CropRectangle\n )" + }, + { + "Name": "New-SnipITIcon", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] [string]$Path)" + }, + { + "Name": "New-SnipOverlayContext", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Snapshot,\n $SnapshotSource,\n [Parameter(Mandatory)] $VirtualBounds,\n [Parameter(Mandatory)] [object[]]$MonitorLayouts,\n [Parameter(Mandatory)] $Services,\n [Parameter(Mandatory)] [System.Windows.ResourceDictionary]$Resources\n )" + }, + { + "Name": "New-SnipOverlayWindow", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Context,\n [Parameter(Mandatory)] $MonitorLayout\n )" + }, + { + "Name": "New-SnipPreviewContext", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [System.Drawing.Bitmap]$Bitmap,\n [System.Windows.Media.Imaging.BitmapSource]$BitmapSource,\n $CaptureBounds,\n [object[]]$MonitorDescriptors,\n [System.Windows.ResourceDictionary]$Resources,\n [scriptblock]$RegisterWindow = { param($hwnd) Register-SelfWindowHandle -Hwnd $hwnd },\n [scriptblock]$UnregisterWindow = { param($hwnd) Unregister-SelfWindowHandle -Hwnd $hwnd },\n [scriptblock]$SetWindowPosition = {\n param($hwnd, $bounds)\n [Native]::SetWindowPos(\n $hwnd, [IntPtr]::Zero,\n [int]$bounds.X, [int]$bounds.Y,\n [int]$bounds.Width, [int]$bounds.Height,\n [Native]::SWP_NOZORDER -bor [Native]::SWP_NOACTIVATE)\n }\n )" + }, + { + "Name": "New-SnipPreviewWindow", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] $Context)" + }, + { + "Name": "New-SnipRuntimeCaptureServices", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [scriptblock]$SmartOverlay = {\n param($onSurfaceReady,$coordinator,$request)\n Show-SmartOverlay -OnSurfaceReady $onSurfaceReady\n },\n [scriptblock]$GetVirtualBounds = { Get-VirtualScreenBounds },\n [scriptblock]$ResolveWindow = {\n $foreground = [Native]::GetForegroundWindow()\n $target = Resolve-WindowCaptureTarget -ForegroundHwnd $foreground `\n -SelfWindowHandles $script:SelfWindowHandles\n if ($null -eq $target) { return $null }\n\n $bounds = New-Object Native+RECT\n $gotBounds = ([Native]::DwmGetWindowAttribute(\n $target, [Native]::DWMWA_EXTENDED_FRAME_BOUNDS, [ref]$bounds, 16) -eq 0)\n if (-not $gotBounds) {\n $gotBounds = [Native]::GetWindowRect($target, [ref]$bounds)\n }\n $width = $bounds.Right - $bounds.Left\n $height = $bounds.Bottom - $bounds.Top\n if (-not $gotBounds -or $width -le 0 -or $height -le 0) {\n return $null\n }\n [pscustomobject][ordered]@{\n Hwnd = $target\n X = $bounds.Left\n Y = $bounds.Top\n Width = $width\n Height = $height\n }\n },\n [scriptblock]$HideWindows = { Hide-OwnSnipITWindowsForCapture },\n [scriptblock]$RestoreWindows = {\n param($hidden)\n Show-OwnSnipITWindowsForCapture -Hidden $hidden\n },\n [scriptblock]$CaptureRectangle = {\n param($bounds)\n New-ScreenBitmap -X $bounds.X -Y $bounds.Y `\n -Width $bounds.Width -Height $bounds.Height\n }\n )" + }, + { + "Name": "New-SnipSplitControl", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [string]$Name,\n [Parameter(Mandatory)] [string]$DefaultCommand,\n [Parameter(Mandatory)] [string]$PrimaryText,\n [Parameter(Mandatory)] [string]$PrimaryAutomationName,\n [Parameter(Mandatory)] [string]$OptionsAutomationName,\n [Parameter(Mandatory)] [System.Collections.IDictionary]$Options,\n [Parameter(Mandatory)] $Context,\n [string]$Glyph = '',\n [double]$PrimaryWidth = 38,\n [double]$OptionsWidth = 22,\n [scriptblock]$PrimaryAction = {}\n )" + }, + { + "Name": "New-SnipThemeResources", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [bool]$HighContrast,\n [Nullable[bool]]$AnimationsEnabled = $null\n )" + }, + { + "Name": "New-SnipTrayMenu", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] $Context)" + }, + { + "Name": "Read-SnipSettings", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [string]$Path,\n [string]$PicturesDir = [Environment]::GetFolderPath('MyPictures')\n )" + }, + { + "Name": "Register-SelfWindowHandle", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([IntPtr]$Hwnd)" + }, + { + "Name": "Register-SnipHotkeyBinding", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [IntPtr]$Hwnd,\n [Parameter(Mandatory)] [object]$Binding,\n [Parameter(Mandatory)] [scriptblock]$Register\n )" + }, + { + "Name": "Remove-SnipCoordinatorBitmap", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] $Coordinator)" + }, + { + "Name": "Remove-SnipOverlayWindowEvents", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] $Overlay)" + }, + { + "Name": "Request-SnipCapture", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Coordinator,\n [ValidateSet('Smart', 'Full', 'Window')] [string]$Mode,\n [timespan]$Delay = [timespan]::Zero,\n [string]$Source,\n $Request = $null,\n [switch]$DelayElapsed\n )" + }, + { + "Name": "Resize-SnipAnnotation", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Annotation,\n [Parameter(Mandatory)]\n [ValidateSet('TopLeft','Top','TopRight','Right','BottomRight','Bottom','BottomLeft','Left','Start','End')]\n [string]$Handle,\n [Parameter(Mandatory)] [int]$DeltaX,\n [Parameter(Mandatory)] [int]$DeltaY,\n [Parameter(Mandatory)] [int]$SourceWidth,\n [Parameter(Mandatory)] [int]$SourceHeight\n )" + }, + { + "Name": "Resolve-PreviewKeyCommand", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [string]$FocusedRole,\n [Parameter(Mandatory)] [hashtable]$EditorState,\n [Parameter(Mandatory)] [string]$Key,\n [AllowNull()][AllowEmptyCollection()] [string[]]$Modifiers = @()\n )" + }, + { + "Name": "Resolve-SaveImagePath", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [string]$Path,\n [Parameter(Mandatory)] [ValidateSet('Png','Jpeg','Bmp')] [string]$FilterFormat\n )" + }, + { + "Name": "Resolve-WindowCaptureTarget", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [AllowNull()] $ForegroundHwnd,\n [AllowNull()][AllowEmptyCollection()] $SelfWindowHandles\n )" + }, + { + "Name": "Save-CaptureToFile", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([System.Drawing.Bitmap]$Bitmap)" + }, + { + "Name": "Save-SnipSettings", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [object]$Settings,\n [Parameter(Mandatory)] [string]$Path\n )" + }, + { + "Name": "script:Add-PreviewHandleSet", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [System.Windows.Controls.Canvas]$Layer,\n [Parameter(Mandatory)] $Bounds,\n [Parameter(Mandatory)] [string]$Role,\n [string[]]$Handles = @('TopLeft','Top','TopRight','Right','BottomRight','Bottom','BottomLeft','Left')\n )" + }, + { + "Name": "script:Build-ColorBar", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([scriptblock]$pickColor)" + }, + { + "Name": "script:Do-Redo", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "" + }, + { + "Name": "script:Do-Undo", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "" + }, + { + "Name": "script:Find-AnnotationAt", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([double]$CanvasX, [double]$CanvasY)" + }, + { + "Name": "script:Get-DisplayedImageBounds", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "" + }, + { + "Name": "script:Get-FlattenedBitmap", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "" + }, + { + "Name": "script:Get-PreviewAnnotationBounds", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param($Annotation)" + }, + { + "Name": "script:Get-PreviewAnnotationById", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([AllowNull()][string]$Id)" + }, + { + "Name": "script:Get-PreviewAnnotationIndexById", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([AllowNull()][string]$Id)" + }, + { + "Name": "script:Get-PreviewWpfBrush", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([string]$Color,[int]$Alpha=255)" + }, + { + "Name": "script:Render-Annotations", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "" + }, + { + "Name": "script:Render-PreviewInteraction", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "" + }, + { + "Name": "script:Restore-State", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param($snapshot)" + }, + { + "Name": "script:Snapshot-State", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "" + }, + { + "Name": "script:Test-PreviewAnnotationEqual", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param($Left,$Right)" + }, + { + "Name": "script:Test-PreviewCropEqual", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param($Left,$Right)" + }, + { + "Name": "script:To-WpfColor", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([int]$A, [int]$R, [int]$G, [int]$B)" + }, + { + "Name": "script:Trim-SnipStack", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param($Stack, [int]$Max = $script:UndoStackMaxDepth)" + }, + { + "Name": "Select-SnipAnnotation", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [AllowNull()][AllowEmptyCollection()] $Annotations,\n [Parameter(Mandatory)] [double]$ImageX,\n [Parameter(Mandatory)] [double]$ImageY,\n [double]$Tolerance = 6.0\n )" + }, + { + "Name": "Send-SnipCapturePump", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] $Coordinator)" + }, + { + "Name": "Set-MicaBackdrop", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([System.Windows.Window]$Window)" + }, + { + "Name": "Set-SnipAuxiliarySurface", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Coordinator,\n [Parameter(Mandatory)] $Surface\n )" + }, + { + "Name": "Set-SnipCrop", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [ValidateSet('Apply','Reset')] [string]$Action,\n [AllowNull()] $Candidate,\n [Parameter(Mandatory)] [int]$SourceWidth,\n [Parameter(Mandatory)] [int]$SourceHeight,\n [ValidateSet('Free','Original','1:1','4:3','16:9')] [string]$Preset = 'Free'\n )" + }, + { + "Name": "Set-SnipHotkeyBinding", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [object]$Context,\n [Parameter(Mandatory)] [object]$Candidate,\n [Parameter(Mandatory)] [scriptblock]$Register,\n [Parameter(Mandatory)] [scriptblock]$Unregister\n )" + }, + { + "Name": "Set-SnipOverlayRectangleVisual", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $RectangleControl,\n $Intersection\n )" + }, + { + "Name": "Set-SnipPreviewMenuStyle", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [System.Windows.Controls.ContextMenu]$Menu,\n [Parameter(Mandatory)] $Context\n )" + }, + { + "Name": "Set-SnipPreviewResponsiveMode", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Context,\n [Parameter(Mandatory)] [double]$Width,\n [Parameter(Mandatory)] [double]$Height\n )" + }, + { + "Name": "Set-SnipPreviewStatusPresentation", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] $Context)" + }, + { + "Name": "Set-SnipPropertyIsland", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Context,\n [Parameter(Mandatory)]\n [ValidateSet('Select','Crop','Pen','Highlight','ArrowLine','RectangleEllipse','Text','Steps','BlurPixelate')]\n [string]$Tool\n )" + }, + { + "Name": "Show-AboutWindow", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n $Context = $script:UtilityContext,\n [scriptblock]$TestAction\n )" + }, + { + "Name": "Show-FloatingWidget", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n $Context = $script:UtilityContext,\n [scriptblock]$TestAction\n )" + }, + { + "Name": "Show-OwnSnipITWindowsForCapture", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param($Hidden)" + }, + { + "Name": "Show-PreviewWindow", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [System.Drawing.Bitmap]$Bitmap,\n # Harness hook. When a scriptblock is provided, Show-PreviewWindow\n # still calls ShowDialog() (so its local scope stays alive and the\n # event handlers keep working), but on the Loaded event it invokes\n # $TestAction with a hashtable of handles and then closes the window.\n # The window is positioned off-screen for a headless feel.\n [scriptblock]$TestAction,\n [scriptblock]$OnOwnershipAccepted,\n [scriptblock]$OnSurfaceReady,\n [scriptblock]$OnNewSnip,\n [scriptblock]$OnOutputStarting,\n [scriptblock]$OnOutputCompleted\n )" + }, + { + "Name": "Show-SettingsWindow", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n $Context = $script:UtilityContext,\n [scriptblock]$TestAction\n )" + }, + { + "Name": "Show-SmartOverlay", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [scriptblock]$OnSurfaceReady,\n $Services,\n [scriptblock]$TestAction,\n [scriptblock]$HideWindows = { Hide-OwnSnipITWindowsForCapture },\n [scriptblock]$RestoreWindows = {\n param($hidden)\n Show-OwnSnipITWindowsForCapture -Hidden $hidden\n },\n [scriptblock]$CaptureFactory = {\n param($bounds)\n New-ScreenBitmap -X $bounds.X -Y $bounds.Y `\n -Width $bounds.Width -Height $bounds.Height\n },\n [scriptblock]$BitmapSourceFactory = {\n param($bitmap)\n Convert-BitmapToBitmapSource $bitmap\n }\n )" + }, + { + "Name": "Show-SmartOverlaySet", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [scriptblock]$OnSurfaceReady,\n $Services,\n [scriptblock]$TestAction,\n [scriptblock]$HideWindows,\n [scriptblock]$RestoreWindows,\n [scriptblock]$CaptureFactory,\n [scriptblock]$BitmapSourceFactory\n )" + }, + { + "Name": "Start-DelayedCapture", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([int]$Seconds, [ValidateSet('smart','full','window')] [string]$Type)" + }, + { + "Name": "Stop-SnipCaptureCoordinator", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([Parameter(Mandatory)] $Coordinator)" + }, + { + "Name": "Sync-SnipStartupShortcut", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [object]$Settings,\n [Parameter(Mandatory)] [object]$Paths\n )" + }, + { + "Name": "Test-CaptureRectValid", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [int]$Width,\n [Parameter(Mandatory)] [int]$Height,\n [int]$MinSize = 2\n )" + }, + { + "Name": "Test-IsClickVsDrag", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [double]$AnchorX,\n [Parameter(Mandatory)] [double]$AnchorY,\n [Parameter(Mandatory)] [double]$CurrentX,\n [Parameter(Mandatory)] [double]$CurrentY,\n [double]$Threshold = 4\n )" + }, + { + "Name": "Test-IsSelfWindowHandle", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [AllowNull()] $Hwnd,\n [AllowNull()][AllowEmptyCollection()] $SelfWindowHandles\n )" + }, + { + "Name": "Test-SnipAnnotationHit", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] $Annotation,\n [Parameter(Mandatory)] [double]$ImageX,\n [Parameter(Mandatory)] [double]$ImageY,\n [Parameter(Mandatory)] [double]$Tolerance\n )" + }, + { + "Name": "Test-SnipAtomicSemanticValue", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([AllowNull()] $Value)" + }, + { + "Name": "Test-SnipHotkeyDefinition", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [int]$Modifiers,\n [Parameter(Mandatory)] [int]$VirtualKey\n )" + }, + { + "Name": "Test-SnipPointNearSegment", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [double]$PointX,\n [Parameter(Mandatory)] [double]$PointY,\n [Parameter(Mandatory)] [double]$StartX,\n [Parameter(Mandatory)] [double]$StartY,\n [Parameter(Mandatory)] [double]$EndX,\n [Parameter(Mandatory)] [double]$EndY,\n [Parameter(Mandatory)] [double]$Tolerance\n )" + }, + { + "Name": "Uninstall-SnipIT", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "" + }, + { + "Name": "Unregister-SelfWindowHandle", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param([IntPtr]$Hwnd)" + }, + { + "Name": "Write-SnipDiag", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [string]$Message,\n $ErrorRecord = $null,\n [string]$Path\n )" + }, + { + "Name": "Write-SnipITShortcut", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [string]$Path,\n [Parameter(Mandatory)] [string]$AppDir,\n [Parameter(Mandatory)] [string]$ScriptTarget,\n [string]$IconPath\n )" + }, + { + "Name": "Write-SnipITShortcuts", + "IsFilter": false, + "IsWorkflow": false, + "ParamBlock": "param(\n [Parameter(Mandatory)] [object]$Paths,\n [string]$IconPath\n )" + } + ] +} diff --git a/xaml/AboutWindow.xaml b/xaml/AboutWindow.xaml new file mode 100644 index 0000000..81a7b9e --- /dev/null +++ b/xaml/AboutWindow.xaml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/xaml/PreviewWindow.xaml b/xaml/PreviewWindow.xaml new file mode 100644 index 0000000..b8420b4 --- /dev/null +++ b/xaml/PreviewWindow.xaml @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +