diff --git a/.github/workflows/chocolatey-release.yml b/.github/workflows/chocolatey-release.yml
index 70bd8730..9f143cdb 100644
--- a/.github/workflows/chocolatey-release.yml
+++ b/.github/workflows/chocolatey-release.yml
@@ -37,12 +37,49 @@ jobs:
(Get-Content $nuspec) -replace 'REPLACE_VERSION', $version | Set-Content $nuspec
(Get-Content $install) -replace 'REPLACE_HASH', $hash | Set-Content $install
+ # Chocolatey moderation rejects links it cannot use, so check them before submitting.
+ # A 403 or 429 from this runner is not a broken link: Cloudflare in front of our own sites
+ # and jsDelivr refuse many GitHub runner addresses while the same page loads for people and
+ # for moderators. Only a missing page, an unreachable host or a raw.githubusercontent.com
+ # link stops the push.
+ [xml]$spec = Get-Content $nuspec -Raw
+ $meta = $spec.package.metadata
+ $problems = @()
+ foreach ($field in 'packageSourceUrl','projectUrl','iconUrl','licenseUrl','projectSourceUrl','docsUrl','bugTrackerUrl','releaseNotes') {
+ $url = [string]$meta.$field
+ if ($url -notmatch '^https?://') { continue }
+ if (([uri]$url).Host -eq 'raw.githubusercontent.com') { $problems += "$field uses raw.githubusercontent.com, which Chocolatey does not accept: $url"; continue }
+ try { $null = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 60 }
+ catch {
+ $status = 0
+ if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode }
+ if ($status -eq 403 -or $status -eq 429) {
+ Write-Host "::warning::$field answered $status to this runner, which is bot protection refusing GitHub's addresses, not a missing page: $url"
+ continue
+ }
+ $problems += "$field did not load: $url ($($_.Exception.Message))"
+ }
+ }
+ if ($problems) { $problems | ForEach-Object { Write-Host "::error::$_" }; throw "The nuspec has links Chocolatey moderation will reject." }
+
# Pack and push
choco pack choco\killerscan.nuspec --out "$env:RUNNER_TEMP"
if ($LASTEXITCODE -ne 0) { throw "choco pack failed." }
$package = "$env:RUNNER_TEMP\killerscan.$version.nupkg"
choco push $package --source https://push.chocolatey.org --api-key $env:CHOCO_API_KEY
- if ($LASTEXITCODE -ne 0) { throw "choco push failed." }
+ if ($LASTEXITCODE -ne 0) {
+ # Chocolatey answers most rejections with a bare 403, so report what the package page shows.
+ $page = "https://community.chocolatey.org/packages/killerscan"
+ try {
+ $html = (Invoke-WebRequest $page -UseBasicParsing -TimeoutSec 60).Content
+ foreach ($row in [regex]::Matches($html, '
[\s\S]*?([^<]+)[\s\S]*?')) {
+ $held = $row.Groups[1].Value.Trim()
+ $badge = [regex]::Match($row.Value, 'class="badge[^"]*">([^<]+)<').Groups[1].Value.Trim()
+ if ($badge -and $badge -notmatch 'Approved|Exempted') { Write-Host "::error::Version $held is in moderation: $badge. See $page/$held" }
+ }
+ } catch { Write-Host "Could not read $page : $($_.Exception.Message)" }
+ throw "choco push was rejected. An earlier version in moderation with none approved blocks new pushes. Other causes are an API key that does not own the package or a version that already exists."
+ }
"CHOCO_VERSION=$version" >> $env:GITHUB_ENV
"CHOCO_PACKAGE=$package" >> $env:GITHUB_ENV
@@ -50,6 +87,8 @@ jobs:
# validator sometimes fails with "We were unable to find a package" while its own service
# is degraded; its instruction is to upload the package again. Watch the result, re-push
# once on a failure, and fail this job if validation still fails so it never goes unnoticed.
+ # The package page is cached and can show Pending for hours after validation passes, so the
+ # result is read from the package feed instead.
- name: Wait for Chocolatey validation
shell: pwsh
timeout-minutes: 170
@@ -57,22 +96,23 @@ jobs:
CHOCO_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }}
run: |
$page = "https://community.chocolatey.org/packages/killerscan/$env:CHOCO_VERSION"
+ $feed = "https://community.chocolatey.org/api/v2/Packages(Id='killerscan',Version='$env:CHOCO_VERSION')"
$repushed = $false
$pendingSinceRepush = $false
$deadline = (Get-Date).AddMinutes(160)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Seconds 300
try {
- $html = (Invoke-WebRequest $page -UseBasicParsing -TimeoutSec 90).Content
+ $entry = (Invoke-WebRequest $feed -UseBasicParsing -TimeoutSec 90).Content
} catch {
- Write-Host "Package page unavailable: $($_.Exception.Message). Retrying."
+ Write-Host "Package feed unavailable: $($_.Exception.Message). Retrying."
continue
}
- $state = [regex]::Match($html, 'Validation Testing (Passed|Failed|Pending)').Groups[1].Value
+ $state = [regex]::Match($entry, '(\w+)').Groups[1].Value
Write-Host "$(Get-Date -Format HH:mm:ss) validation: $(if ($state) { $state } else { 'not shown' })"
- if ($state -eq 'Passed') { exit 0 }
- if ($state -eq 'Pending' -and $repushed) { $pendingSinceRepush = $true }
- if ($state -ne 'Failed') { continue }
+ if ($state -like 'Pass*') { exit 0 }
+ if ($repushed -and $state -and $state -notlike 'Fail*') { $pendingSinceRepush = $true }
+ if ($state -notlike 'Fail*') { continue }
if (-not $repushed) {
Write-Host "Validation failed. Pushing the same package again as Chocolatey instructs."
choco push $env:CHOCO_PACKAGE --source https://push.chocolatey.org --api-key $env:CHOCO_API_KEY
@@ -82,4 +122,7 @@ jobs:
throw "Chocolatey validation failed twice. See $page"
}
}
- throw "Chocolatey validation did not pass within 160 minutes. See $page"
+ # Chocolatey's queue often takes longer than this. The package is already pushed, so a
+ # validation that is still pending is a warning, not a failed release.
+ Write-Host "::warning::Chocolatey validation was still pending after 160 minutes. The package was pushed. See $page"
+ exit 0
diff --git a/.github/workflows/winget-release.yml b/.github/workflows/winget-release.yml
index e4cdf2d7..19844b1c 100644
--- a/.github/workflows/winget-release.yml
+++ b/.github/workflows/winget-release.yml
@@ -36,6 +36,11 @@ jobs:
submit:
runs-on: windows-latest
steps:
+ # The WinGet description comes from the Chocolatey nuspec at the released tag.
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.release.tag_name || github.event.inputs.tag }}
+
# Prints what WINGET_TOKEN actually is, so a failure downstream is never guesswork again.
# An empty x-oauth-scopes means a FINE-GRAINED token, which authenticates fine, reads fine,
# and cannot create the branch komac needs. That failure is reported as
@@ -157,6 +162,23 @@ jobs:
Set-Content $installer $text -NoNewline -Encoding utf8
Select-String -Path $installer -Pattern 'InstallerType|DisplayVersion'
+ # komac also copies the previous locale manifest, which carried a Description cut off
+ # mid-word and the old website. Fill both description fields with the nuspec's one-line
+ # summary. The full feature list sent 1.8.2 and 1.8.3 to manual policy review
+ # (Policy-Test-2.7); the short text is what passed automatically before.
+ [xml]$spec = Get-Content (Join-Path $env:GITHUB_WORKSPACE 'choco\killerscan.nuspec') -Raw
+ $meta = $spec.package.metadata
+ $summary = $meta.summary.Trim()
+ if ($summary.Length -gt 256) { Write-Error "The nuspec summary is longer than WinGet's 256-character ShortDescription"; exit 1 }
+ $locale = Join-Path $dir 'SteveTheKiller.KillerScan.locale.en-US.yaml'
+ if (-not (Test-Path $locale)) { Write-Error "komac did not write $locale"; exit 1 }
+ $loc = (Get-Content $locale -Raw) -replace '\r\n', "`n"
+ $loc = [regex]::Replace($loc, '(?m)^PackageUrl:[^\n]*', { param($m) 'PackageUrl: ' + $meta.projectUrl.Trim() })
+ $loc = [regex]::Replace($loc, '(?m)^ShortDescription:[^\n]*', { param($m) "ShortDescription: '" + $summary.Replace("'", "''") + "'" })
+ $loc = [regex]::Replace($loc, '(?m)^Description:[^\n]*\n(?:(?:[ \t]+[^\n]*)?\n)*?(?=[A-Za-z])', { param($m) "Description: '" + $summary.Replace("'", "''") + "'`n" })
+ Set-Content $locale $loc -NoNewline -Encoding utf8
+ Select-String -Path $locale -Pattern '^(PackageUrl|ShortDescription|Description):'
+
# komac picks a fresh GUID for its branch name every run, so a retry never collides
# with a half-finished attempt.
$delays = @(60, 150, 300)
diff --git a/App.xaml b/App.xaml
index a3af6349..19c78f99 100644
--- a/App.xaml
+++ b/App.xaml
@@ -1,9 +1,10 @@
+ xmlns:sys="clr-namespace:System;assembly=mscorlib">
@@ -40,15 +41,17 @@
+ (commercial license) for "Killer", Lacquer (OFL) for the app name and primary headings, and
+ Killer Sans (OFL) for secondary headings and the footer. -->
/KillerScan;component/Fonts/#Punktype/KillerScan;component/Fonts/#Lacquer/KillerScan;component/Fonts/#Killer Sans
+
-
-
-
+
-
+
@@ -1345,12 +1391,13 @@
-
+
+
-
@@ -1413,7 +1460,8 @@
-
+
+
string.Equals(a, "/uiscan", StringComparison.OrdinalIgnoreCase) ||
- string.Equals(a, "--uiscan", StringComparison.OrdinalIgnoreCase));
+ string.Equals(a, "--uiscan", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(a, "uiscan", StringComparison.OrdinalIgnoreCase));
bool startUiScan = uiScanIndex >= 0;
string? uiScanTarget = startUiScan
? string.Join(",", e.Args.Skip(uiScanIndex + 1))
@@ -504,59 +498,13 @@ private static void CreateShortcut(string lnkPath, string targetPath)
/// Add an install directory to PATH exactly once. The self-installer updates the
/// current user; the elevated silent installer updates the machine, so a new terminal can
/// invoke the app as simply "KillerScan" from any directory.
- private static void AddToPath(string directory, EnvironmentVariableTarget target)
- {
- string current = Environment.GetEnvironmentVariable("Path", target) ?? "";
- var entries = current.Split([';'], StringSplitOptions.RemoveEmptyEntries).ToList();
- if (!entries.Any(p => SamePath(p, directory)))
- {
- entries.Add(directory);
- Environment.SetEnvironmentVariable("Path", string.Join(";", entries), target);
- BroadcastEnvironmentChange();
- }
- }
+ private static void AddToPath(string directory, EnvironmentVariableTarget target) =>
+ Services.CommandLinePath.Register(directory, target == EnvironmentVariableTarget.Machine);
/// Remove every spelling of an install directory from PATH without disturbing
/// entries belonging to other programs.
- private static void RemoveFromPath(string directory, EnvironmentVariableTarget target)
- {
- try
- {
- string current = Environment.GetEnvironmentVariable("Path", target) ?? "";
- var entries = current.Split([';'], StringSplitOptions.RemoveEmptyEntries)
- .Where(p => !SamePath(p, directory)).ToList();
- string updated = string.Join(";", entries);
- if (!string.Equals(current.TrimEnd(';'), updated, StringComparison.Ordinal))
- {
- Environment.SetEnvironmentVariable("Path", updated, target);
- BroadcastEnvironmentChange();
- }
- }
- catch { /* uninstall and install-scope migration are best-effort cleanup */ }
- }
-
- private static bool SamePath(string left, string right)
- {
- try
- {
- return string.Equals(
- Path.GetFullPath(Environment.ExpandEnvironmentVariables(left.Trim().Trim('"')))
- .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
- Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
- StringComparison.OrdinalIgnoreCase);
- }
- catch { return false; }
- }
-
- private static void BroadcastEnvironmentChange()
- {
- try
- {
- SendMessageTimeout(HwndBroadcast, WmSettingChange, IntPtr.Zero, "Environment",
- SmtoAbortIfHung, 2000, out _);
- }
- catch { /* a newly signed-in session still reads the persisted PATH */ }
- }
+ private static void RemoveFromPath(string directory, EnvironmentVariableTarget target) =>
+ Services.CommandLinePath.Unregister(directory, target == EnvironmentVariableTarget.Machine);
// ============================================================
// Uninstall
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e71346e5..6dc546ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,9 +4,10 @@ All notable changes to KillerScan are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [Unreleased]
+## [1.8.4] - Unreleased
### Added
+- Slovenian labels for startup update checks and scrollbar page actions, preserving complete coverage of the upstream English dictionary.
- A unified Access list with exact captured HTTP/HTTPS endpoints, protocol/port suggestions, response and authentication evidence, and Open/Copy actions using existing clients. Deep web probes retain nonstandard endpoints; history, exports and PowerShell preserve the optional data. All 20 locales include the new labels.
- Device recognition evidence and heuristic confidence, source/timestamp details, explicitly reported model declarations, read-only device/service/history inspection, and structured evidence in JSON/CSV/HTML and PowerShell. Missing replies alone no longer imply IoT or Mobile; a MAC vendor never establishes a model.
- Location controls and GUI error guidance in all 20 supported languages, plus a two-process location/profile GUI persistence check. Classic input dialog titles follow the action being performed.
@@ -14,6 +15,75 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
- Explicit Windows IPv4 interface selection for scan tabs, profiles, CLI and the terminal toolbar, with pinned TCP/UDP/multicast, source-aware ARP/ICMP/DNS, per-interface classification and cancellation on link changes. Missing selections never fall back to a different adapter.
- Per-tab scan options for neighbor cache, explicit ARP, ICMP, TCP discovery, DNS, mDNS, SSDP, port scanning and individual HTTP/TLS/SSH/SNMP/NetBIOS fingerprints. Profiles save the choices; scan, quick and deep paths enforce them in the engine. CLI `/skip` and PowerShell `-Skip` provide the same choices. Existing profiles and API calls keep their defaults.
+### Fixed
+- The shortcuts card shows shadows at its edges when more content is out of view.
+
+## [1.8.3] - 2026-10-19
+
+### Changed
+- Refreshed the bundled MAC vendor database to 58,575 assignments.
+- Scan History and Scan Profiles open wide enough to show every date in full. Dragging the sidebar narrower draws their text up to 15% smaller before it is cut off.
+
+### Fixed
+- The theme and language menus open beside their own rail buttons instead of at the corner of the device list.
+- A thin black line no longer shows under the title bar. The film grain now covers the whole bar.
+- The dot in a selected radio button sits in the middle of its ring, and radio buttons and checkboxes line up with the capitals of their labels in Killer Sans UI and on 98SE.
+
+## [1.8.2] - 2026-10-19
+
+### Added
+- Commands run as `killerscan scan` without a command prefix. `killerscan --help` lists them, and existing command forms stay compatible. Installed copies use PATH; portable copies run from their folder or full path.
+- KillerScan checks for updates on startup and offers a newer release in a dialog with an Always check on startup box. A Check for updates on startup checkbox on the About card turns the check back on.
+- Alt+Shift+G turns Film Grain on or off. It is listed in the shortcuts overlay, and the keyboard map has a new Alt+Shift layer for it.
+- The website's top bar has a Microsoft Store button on every page, and the MCP page has a Microsoft Store button for KillerMCP.
+- The website's About page lists KillerBench and KillerShot under Also try, with larger app icons.
+
+### Changed
+- Refreshed the bundled MAC vendor database to 58,575 assignments.
+- Film grain is softer and shows on every theme, Black included. Its specks come in many shades, and some lean toward a color opposite the theme accent, such as purple on green and orange-brown on blue and red.
+- Rail buttons and the collapse arrow sit centered across the rail, and the arrow is larger.
+- Dialog headings, the history sidebar heading and the history detail header use Lacquer, the wordmark's app-name face, in place of Killer Sans. 98SE keeps Killer Sans.
+- The history detail header is the same size as the Scan History heading.
+- Context menus use Killer Sans UI like the rest of the interface instead of Segoe UI.
+- The Light theme draws a softer shadow under the toolbars.
+- Toolbar buttons with text under the icon are 6 pixels shorter, without the extra space above and below the icon and label.
+- The website's top bar Download button uses the same font and height as the other top bar buttons.
+- The PATH entry for installed copies now keeps the existing Path value's type and its %variables%, tells open programs about the change, and is removed by the uninstaller.
+- The keyboard focus outline is a thin one-pixel dotted ring in the theme accent instead of a thick dotted line, and uses the text color where the accent would be too faint to see.
+- Primary, outline and secondary buttons sit raised on a soft shadow that lifts on hover and sets down when pressed. 98SE keeps its flat beveled buttons.
+
+### Fixed
+- CSV exports keep device and scan text from running as spreadsheet formulas. (#10)
+- Overlapping scan targets count each address once against the 65,536 address limit, so a /16 with hosts inside it no longer reports as too large. (#11)
+- The public subnet helper rejects oversized ranges before expanding them. (#13)
+- HTML report columns sort from the keyboard. Each column header takes Tab focus with a visible ring, Enter and Space sort it, and screen readers hear the sort direction. (#12)
+- Windows contrast themes now use system colors for text, icons, panels and selections, without decorative grain or shadows.
+- The window opens fully on screen on a small or high-scaling display, or when its saved size came from a bigger monitor, instead of running off the edges.
+- HTML reports show the KillerScan wordmark in the report's theme and accent, loaded from killerscan.net, instead of always falling back to the text wordmark. 98SE keeps the text wordmark.
+- The deep scan status and a few labels that were still in English are translated in every language, and the website's MCP page footer follows the language picker.
+- On the Light theme's Yellow and Orange accents, buttons, links, tabs and accent icons keep their color with a thin gray outline around accent text and icons, instead of turning near-black or brown.
+- Gray, menu and accent text reads at 4.5:1 contrast or better in every theme and accent, and the footer's version and copyright are slightly dimmed.
+- The Dark theme's Red accent stays a true red on buttons, lines, scroll bars and the website's accent swatches, and only small red text uses a lighter red.
+- The website's text and accent colors read at 4.5:1 contrast or better in every theme, and a narrow top bar shrinks the Microsoft Store and MCP buttons to their logos and moves links into a More menu instead of wrapping.
+- On the website's Light theme with the Yellow or Orange accent, links, headings and labels keep their color with a thin gray outline instead of turning olive or brown.
+- The exe's file properties show Steve the Killer as the copyright holder instead of leaving it blank.
+- Screen readers no longer read toolbar, menu, tab and title bar icons as unreadable symbols, and every part of each scroll bar now has a name. File-picker rows announce folder and file names.
+- Opening the shortcuts or About card moves keyboard focus into it, Tab stays inside the card, and closing it returns focus to where it was.
+- Tab no longer stops on hidden links, which screen readers announced as the window.
+- Screen readers and accessibility checkers no longer report empty text or the hidden parts of scroll bars as items on screen.
+- Each group of option buttons is one Tab stop that lands on the selected option, and the arrow keys move between its options.
+- Tab moves past the device, service and history lists in one step instead of visiting every cell, and screen readers read the address and type cells instead of a placeholder and a blank host name, MAC address or vendor as Unknown.
+- Turning off Film Grain removes the grain from the device table, Services and Topology views too, instead of leaving it behind them.
+- The Scan History sidebar can no longer be dragged or zoomed narrower than its dates, so a saved scan's date and time are never cut off.
+- The website top bar no longer leaves extra space under the wordmark and buttons.
+- The file picker's close button turns red on hover without filling a red block, like every other dialog's close, and keeps the classic caption button on 98SE.
+- Theme and language radio buttons and the Film Grain checkbox line up with their labels again.
+- On the website, text on accent-colored buttons, tabs and highlights switches to white or near-black for contrast, so it is never white on yellow.
+- Theme names and the Keep Alive view name are translated in every language.
+- The website's MCP page, contact cards, carousel and section toggles are translated in every language, the site uses the app's names for Keep Alive and Deep Scan, and text that had lost its accents in several languages is restored.
+- On phones, the website's logo and download hash fit the screen instead of making the home page scroll sideways.
+- The Network Scanner subtitle in the title bar sits on the baseline of Killer in the wordmark instead of 2px below it.
+- The footer copyright sits a few pixels further from the corner grip instead of running into it.
## [1.8.1] - 2026-10-14
### Added
diff --git a/Controls/AccentShadow.cs b/Controls/AccentShadow.cs
new file mode 100644
index 00000000..5b126efe
--- /dev/null
+++ b/Controls/AccentShadow.cs
@@ -0,0 +1,213 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Media;
+using System.Windows.Media.Effects;
+
+namespace KillerFamily.Theming
+{
+ ///
+ /// Light with the Yellow accent keeps its accent yellow and draws a thin gray outline around every
+ /// yellow text and glyph so it reads on the light surfaces. A theme turns it on by giving
+ /// WordmarkAccentShadowOpacity a value above 0; every other theme leaves that at 0 and nothing
+ /// changes. Text that already draws its own effect, such as a wordmark halo copy, keeps it.
+ /// Call Initialize once at startup.
+ ///
+ internal static class AccentShadow
+ {
+ /// The outline color, a medium gray that rims the yellow without going black.
+ internal static readonly Color RimColor = Color.FromRgb(0x5A, 0x5A, 0x5A);
+
+ /// The outline width in device-independent pixels.
+ internal const double RimWidth = 1.0;
+
+ private static bool _initialized;
+ private static readonly IValueConverter Converter = new OutlineConverter();
+ private static readonly DependencyProperty TrackedProperty = DependencyProperty.RegisterAttached(
+ "Tracked", typeof(bool), typeof(AccentShadow), new PropertyMetadata(false));
+ private static readonly string[] AccentKeys =
+ { "PrimaryBrush", "PrimaryHoverBrush", "PrimaryPressedBrush", "AccentLogo", "OutlineBtnBrush",
+ "HeaderLineBrush", "SelectionAccent", "RadioAccent" };
+
+ internal static void Initialize()
+ {
+ if (_initialized) return;
+ _initialized = true;
+ EventManager.RegisterClassHandler(typeof(TextBlock), FrameworkElement.SizeChangedEvent,
+ new SizeChangedEventHandler(OnTextSizeChanged), true);
+ }
+
+ private static void OnTextSizeChanged(object sender, SizeChangedEventArgs args)
+ {
+ if (sender is not TextBlock text || (bool)text.GetValue(TrackedProperty)) return;
+ text.SetValue(TrackedProperty, true);
+ if (text.Effect != null || BindingOperations.IsDataBound(text, UIElement.EffectProperty)) return;
+ // Bound to the text's own color, so hover and selection colors and theme changes add or
+ // remove the outline as the color changes. The DPI scale keeps the rim one unit wide on
+ // every monitor.
+ text.SetBinding(UIElement.EffectProperty, new Binding(nameof(TextBlock.Foreground))
+ {
+ RelativeSource = RelativeSource.Self,
+ Converter = Converter,
+ ConverterParameter = VisualTreeHelper.GetDpi(text).DpiScaleX,
+ });
+ }
+
+ /// True when the theme asks for the outline and the color is one of its accent colors.
+ internal static bool Applies(Brush? brush)
+ {
+ var app = Application.Current;
+ if (app == null || brush is not SolidColorBrush solid) return false;
+ if (!(app.TryFindResource("WordmarkAccentShadowOpacity") is double opacity) || opacity <= 0) return false;
+ foreach (string key in AccentKeys)
+ if (app.TryFindResource(key) is SolidColorBrush accent && accent.Color == solid.Color)
+ return true;
+ return false;
+ }
+
+ private sealed class OutlineConverter : IValueConverter
+ {
+ private readonly Dictionary _byScale = new Dictionary();
+
+ public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (!Applies(value as Brush)) return null;
+ double scale = parameter is double d && d > 0 ? d : 1.0;
+ if (!_byScale.TryGetValue(scale, out var effect))
+ {
+ effect = new Outline(RimColor, RimWidth * scale);
+ if (effect.CanFreeze) effect.Freeze();
+ _byScale[scale] = effect;
+ }
+ return effect;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
+ Binding.DoNothing;
+ }
+
+ // Draws a solid rim around every opaque pixel of the element, out to Radius device pixels.
+ // The pixel shader below was compiled from this HLSL with
+ // fxc /T ps_2_0 /E main /Fo Outline.ps Outline.fx (Windows 10 SDK):
+ //
+ // sampler2D input : register(s0);
+ // float4 ddxDdy : register(c0);
+ // float4 rimColor : register(c1);
+ // float radius : register(c2);
+ //
+ // float4 main(float2 uv : TEXCOORD) : COLOR
+ // {
+ // float4 c = tex2D(input, uv);
+ // float2 rx = ddxDdy.xy * radius;
+ // float2 ry = ddxDdy.zw * radius;
+ // float2 d1 = (rx + ry) * 0.7071;
+ // float2 d2 = (rx - ry) * 0.7071;
+ // float a = tex2D(input, uv + rx).a;
+ // a = max(a, tex2D(input, uv - rx).a);
+ // a = max(a, tex2D(input, uv + ry).a);
+ // a = max(a, tex2D(input, uv - ry).a);
+ // a = max(a, tex2D(input, uv + d1).a);
+ // a = max(a, tex2D(input, uv - d1).a);
+ // a = max(a, tex2D(input, uv + d2).a);
+ // a = max(a, tex2D(input, uv - d2).a);
+ // a = max(a, tex2D(input, uv + rx * 0.5).a);
+ // a = max(a, tex2D(input, uv - rx * 0.5).a);
+ // a = max(a, tex2D(input, uv + ry * 0.5).a);
+ // a = max(a, tex2D(input, uv - ry * 0.5).a);
+ // float4 rim = rimColor * a;
+ // return c + rim * (1 - c.a);
+ // }
+ internal sealed class Outline : ShaderEffect
+ {
+ public static readonly DependencyProperty InputProperty =
+ RegisterPixelShaderSamplerProperty("Input", typeof(Outline), 0);
+ public static readonly DependencyProperty RimColorProperty = DependencyProperty.Register(
+ "RimColor", typeof(Color), typeof(Outline), new UIPropertyMetadata(Colors.Black, PixelShaderConstantCallback(1)));
+ public static readonly DependencyProperty RadiusProperty = DependencyProperty.Register(
+ "Radius", typeof(double), typeof(Outline), new UIPropertyMetadata(1.0, PixelShaderConstantCallback(2)));
+
+ private static readonly byte[] Bytecode =
+ {
+ 0x00, 0x02, 0xFF, 0xFF, 0xFE, 0xFF, 0x3C, 0x00, 0x43, 0x54, 0x41, 0x42, 0x1C, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x00, 0x00, 0x00, 0x02, 0xFF, 0xFF,
+ 0x04, 0x00, 0x00, 0x00, 0x1C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0xBD, 0x00, 0x00, 0x00, 0x6C, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
+ 0x01, 0x00, 0x02, 0x00, 0x74, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x84, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00,
+ 0x8C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x9C, 0x00, 0x00, 0x00, 0x02, 0x00, 0x02, 0x00, 0x01, 0x00, 0x0A, 0x00, 0xA4, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0xB4, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x00, 0x06, 0x00, 0x74, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x64, 0x64, 0x78, 0x44, 0x64, 0x79, 0x00, 0xAB, 0x01, 0x00, 0x03, 0x00, 0x01, 0x00, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x69, 0x6E, 0x70, 0x75, 0x74, 0x00, 0xAB, 0xAB, 0x04, 0x00, 0x0C, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x72, 0x61, 0x64, 0x69, 0x75, 0x73, 0x00, 0xAB, 0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x72, 0x69, 0x6D, 0x43, 0x6F, 0x6C, 0x6F, 0x72, 0x00, 0x70, 0x73, 0x5F, 0x32, 0x5F, 0x30, 0x00, 0x4D, 0x69, 0x63, 0x72, 0x6F, 0x73, 0x6F, 0x66,
+ 0x74, 0x20, 0x28, 0x52, 0x29, 0x20, 0x48, 0x4C, 0x53, 0x4C, 0x20, 0x53, 0x68, 0x61, 0x64, 0x65, 0x72, 0x20, 0x43, 0x6F, 0x6D, 0x70, 0x69, 0x6C,
+ 0x65, 0x72, 0x20, 0x31, 0x30, 0x2E, 0x31, 0x00, 0x51, 0x00, 0x00, 0x05, 0x03, 0x00, 0x0F, 0xA0, 0x81, 0x04, 0x35, 0x3F, 0x00, 0x00, 0x00, 0x3F,
+ 0x00, 0x00, 0x80, 0x3F, 0x00, 0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x03, 0xB0, 0x1F, 0x00, 0x00, 0x02,
+ 0x00, 0x00, 0x00, 0x90, 0x00, 0x08, 0x0F, 0xA0, 0x01, 0x00, 0x00, 0x02, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0xE4, 0xA0, 0x04, 0x00, 0x00, 0x04,
+ 0x01, 0x00, 0x03, 0x80, 0x00, 0x00, 0xE4, 0x80, 0x02, 0x00, 0x00, 0xA0, 0x00, 0x00, 0xE4, 0xB0, 0x04, 0x00, 0x00, 0x04, 0x02, 0x00, 0x03, 0x80,
+ 0x00, 0x00, 0xE4, 0x80, 0x02, 0x00, 0x00, 0xA1, 0x00, 0x00, 0xE4, 0xB0, 0x04, 0x00, 0x00, 0x04, 0x03, 0x00, 0x01, 0x80, 0x00, 0x00, 0xAA, 0x80,
+ 0x02, 0x00, 0x00, 0xA0, 0x00, 0x00, 0x00, 0xB0, 0x04, 0x00, 0x00, 0x04, 0x03, 0x00, 0x02, 0x80, 0x00, 0x00, 0xFF, 0x80, 0x02, 0x00, 0x00, 0xA0,
+ 0x00, 0x00, 0x55, 0xB0, 0x04, 0x00, 0x00, 0x04, 0x04, 0x00, 0x01, 0x80, 0x00, 0x00, 0xAA, 0x80, 0x02, 0x00, 0x00, 0xA1, 0x00, 0x00, 0x00, 0xB0,
+ 0x04, 0x00, 0x00, 0x04, 0x04, 0x00, 0x02, 0x80, 0x00, 0x00, 0xFF, 0x80, 0x02, 0x00, 0x00, 0xA1, 0x00, 0x00, 0x55, 0xB0, 0x05, 0x00, 0x00, 0x03,
+ 0x05, 0x00, 0x01, 0x80, 0x00, 0x00, 0xAA, 0x80, 0x02, 0x00, 0x00, 0xA0, 0x05, 0x00, 0x00, 0x03, 0x05, 0x00, 0x02, 0x80, 0x00, 0x00, 0xFF, 0x80,
+ 0x02, 0x00, 0x00, 0xA0, 0x04, 0x00, 0x00, 0x04, 0x00, 0x00, 0x0C, 0x80, 0x00, 0x00, 0x1B, 0x80, 0x02, 0x00, 0x00, 0xA0, 0x05, 0x00, 0x1B, 0x80,
+ 0x04, 0x00, 0x00, 0x04, 0x06, 0x00, 0x03, 0x80, 0x00, 0x00, 0x1B, 0x80, 0x03, 0x00, 0x00, 0xA0, 0x00, 0x00, 0xE4, 0xB0, 0x04, 0x00, 0x00, 0x04,
+ 0x07, 0x00, 0x03, 0x80, 0x00, 0x00, 0x1B, 0x80, 0x03, 0x00, 0x00, 0xA1, 0x00, 0x00, 0xE4, 0xB0, 0x04, 0x00, 0x00, 0x04, 0x00, 0x00, 0x0C, 0x80,
+ 0x00, 0x00, 0x1B, 0x80, 0x02, 0x00, 0x00, 0xA0, 0x05, 0x00, 0x1B, 0x81, 0x04, 0x00, 0x00, 0x04, 0x08, 0x00, 0x03, 0x80, 0x00, 0x00, 0x1B, 0x80,
+ 0x03, 0x00, 0x00, 0xA0, 0x00, 0x00, 0xE4, 0xB0, 0x04, 0x00, 0x00, 0x04, 0x09, 0x00, 0x03, 0x80, 0x00, 0x00, 0x1B, 0x80, 0x03, 0x00, 0x00, 0xA1,
+ 0x00, 0x00, 0xE4, 0xB0, 0x05, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x80, 0x00, 0x00, 0xE4, 0x80, 0x02, 0x00, 0x00, 0xA0, 0x04, 0x00, 0x00, 0x04,
+ 0x0A, 0x00, 0x03, 0x80, 0x00, 0x00, 0xE4, 0x80, 0x03, 0x00, 0x55, 0xA0, 0x00, 0x00, 0xE4, 0xB0, 0x04, 0x00, 0x00, 0x04, 0x00, 0x00, 0x03, 0x80,
+ 0x00, 0x00, 0xE4, 0x80, 0x03, 0x00, 0x55, 0xA1, 0x00, 0x00, 0xE4, 0xB0, 0x42, 0x00, 0x00, 0x03, 0x01, 0x00, 0x0F, 0x80, 0x01, 0x00, 0xE4, 0x80,
+ 0x00, 0x08, 0xE4, 0xA0, 0x42, 0x00, 0x00, 0x03, 0x02, 0x00, 0x0F, 0x80, 0x02, 0x00, 0xE4, 0x80, 0x00, 0x08, 0xE4, 0xA0, 0x42, 0x00, 0x00, 0x03,
+ 0x03, 0x00, 0x0F, 0x80, 0x03, 0x00, 0xE4, 0x80, 0x00, 0x08, 0xE4, 0xA0, 0x42, 0x00, 0x00, 0x03, 0x04, 0x00, 0x0F, 0x80, 0x04, 0x00, 0xE4, 0x80,
+ 0x00, 0x08, 0xE4, 0xA0, 0x42, 0x00, 0x00, 0x03, 0x07, 0x00, 0x0F, 0x80, 0x07, 0x00, 0xE4, 0x80, 0x00, 0x08, 0xE4, 0xA0, 0x42, 0x00, 0x00, 0x03,
+ 0x06, 0x00, 0x0F, 0x80, 0x06, 0x00, 0xE4, 0x80, 0x00, 0x08, 0xE4, 0xA0, 0x42, 0x00, 0x00, 0x03, 0x09, 0x00, 0x0F, 0x80, 0x09, 0x00, 0xE4, 0x80,
+ 0x00, 0x08, 0xE4, 0xA0, 0x42, 0x00, 0x00, 0x03, 0x08, 0x00, 0x0F, 0x80, 0x08, 0x00, 0xE4, 0x80, 0x00, 0x08, 0xE4, 0xA0, 0x42, 0x00, 0x00, 0x03,
+ 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0xE4, 0x80, 0x00, 0x08, 0xE4, 0xA0, 0x42, 0x00, 0x00, 0x03, 0x0A, 0x00, 0x0F, 0x80, 0x0A, 0x00, 0xE4, 0x80,
+ 0x00, 0x08, 0xE4, 0xA0, 0x0B, 0x00, 0x00, 0x03, 0x00, 0x00, 0x01, 0x80, 0x01, 0x00, 0xFF, 0x80, 0x02, 0x00, 0xFF, 0x80, 0x0B, 0x00, 0x00, 0x03,
+ 0x01, 0x00, 0x01, 0x80, 0x00, 0x00, 0x00, 0x80, 0x03, 0x00, 0xFF, 0x80, 0x0B, 0x00, 0x00, 0x03, 0x00, 0x00, 0x01, 0x80, 0x01, 0x00, 0x00, 0x80,
+ 0x04, 0x00, 0xFF, 0x80, 0x0B, 0x00, 0x00, 0x03, 0x01, 0x00, 0x01, 0x80, 0x00, 0x00, 0x00, 0x80, 0x06, 0x00, 0xFF, 0x80, 0x0B, 0x00, 0x00, 0x03,
+ 0x00, 0x00, 0x01, 0x80, 0x01, 0x00, 0x00, 0x80, 0x07, 0x00, 0xFF, 0x80, 0x0B, 0x00, 0x00, 0x03, 0x01, 0x00, 0x01, 0x80, 0x00, 0x00, 0x00, 0x80,
+ 0x08, 0x00, 0xFF, 0x80, 0x0B, 0x00, 0x00, 0x03, 0x00, 0x00, 0x01, 0x80, 0x01, 0x00, 0x00, 0x80, 0x09, 0x00, 0xFF, 0x80, 0x0B, 0x00, 0x00, 0x03,
+ 0x01, 0x00, 0x01, 0x80, 0x00, 0x00, 0x00, 0x80, 0x0A, 0x00, 0xFF, 0x80, 0x0B, 0x00, 0x00, 0x03, 0x02, 0x00, 0x01, 0x80, 0x01, 0x00, 0x00, 0x80,
+ 0x00, 0x00, 0xFF, 0x80, 0x04, 0x00, 0x00, 0x04, 0x00, 0x00, 0x03, 0x80, 0x05, 0x00, 0xE4, 0x80, 0x03, 0x00, 0x55, 0xA0, 0x00, 0x00, 0xE4, 0xB0,
+ 0x04, 0x00, 0x00, 0x04, 0x01, 0x00, 0x03, 0x80, 0x05, 0x00, 0xE4, 0x80, 0x03, 0x00, 0x55, 0xA1, 0x00, 0x00, 0xE4, 0xB0, 0x42, 0x00, 0x00, 0x03,
+ 0x01, 0x00, 0x0F, 0x80, 0x01, 0x00, 0xE4, 0x80, 0x00, 0x08, 0xE4, 0xA0, 0x42, 0x00, 0x00, 0x03, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0xE4, 0x80,
+ 0x00, 0x08, 0xE4, 0xA0, 0x42, 0x00, 0x00, 0x03, 0x03, 0x00, 0x0F, 0x80, 0x00, 0x00, 0xE4, 0xB0, 0x00, 0x08, 0xE4, 0xA0, 0x0B, 0x00, 0x00, 0x03,
+ 0x01, 0x00, 0x01, 0x80, 0x02, 0x00, 0x00, 0x80, 0x00, 0x00, 0xFF, 0x80, 0x0B, 0x00, 0x00, 0x03, 0x00, 0x00, 0x01, 0x80, 0x01, 0x00, 0x00, 0x80,
+ 0x01, 0x00, 0xFF, 0x80, 0x05, 0x00, 0x00, 0x03, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0x00, 0x80, 0x01, 0x00, 0xE4, 0xA0, 0x02, 0x00, 0x00, 0x03,
+ 0x01, 0x00, 0x01, 0x80, 0x03, 0x00, 0xFF, 0x81, 0x03, 0x00, 0xAA, 0xA0, 0x04, 0x00, 0x00, 0x04, 0x00, 0x00, 0x0F, 0x80, 0x00, 0x00, 0xE4, 0x80,
+ 0x01, 0x00, 0x00, 0x80, 0x03, 0x00, 0xE4, 0x80, 0x01, 0x00, 0x00, 0x02, 0x00, 0x08, 0x0F, 0x80, 0x00, 0x00, 0xE4, 0x80, 0xFF, 0xFF, 0x00, 0x00,
+ };
+
+ // Declared after Bytecode: static fields initialize in order.
+ private static readonly PixelShader Shader = LoadShader();
+
+ public Outline(Color rim, double radius)
+ {
+ PixelShader = Shader;
+ DdxUvDdyUvRegisterIndex = 0;
+ PaddingLeft = PaddingTop = PaddingRight = PaddingBottom = radius + 1;
+ SetValue(RimColorProperty, rim);
+ SetValue(RadiusProperty, radius);
+ // A value equal to the default raises no change, so push every register once.
+ UpdateShaderValue(InputProperty);
+ UpdateShaderValue(RimColorProperty);
+ UpdateShaderValue(RadiusProperty);
+ }
+
+ public Color RimColor => (Color)GetValue(RimColorProperty);
+ public double Radius => (double)GetValue(RadiusProperty);
+
+ private static PixelShader LoadShader()
+ {
+ var shader = new PixelShader();
+ shader.SetStreamSource(new MemoryStream(Bytecode));
+ shader.Freeze();
+ return shader;
+ }
+ }
+ }
+}
diff --git a/Controls/AccessibleControls.cs b/Controls/AccessibleControls.cs
index d9412853..e9f0e99a 100644
--- a/Controls/AccessibleControls.cs
+++ b/Controls/AccessibleControls.cs
@@ -6,12 +6,12 @@
using System.Linq;
using System.Windows;
using System.Windows.Automation;
+using System.Windows.Automation.Peers;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Media;
-using System.Windows.Shapes;
namespace KillerFamily.Accessibility
{
@@ -22,6 +22,7 @@ internal static class AccessibleControls
private static readonly IMultiValueConverter RunsName = new RunsNameConverter();
private static Style? _focusStyle;
private static Style? _compactFocusStyle;
+ private static DataTemplate? _glyphTemplate;
private static readonly DependencyProperty OwnedNameProperty = DependencyProperty.RegisterAttached(
"OwnedName", typeof(BindingBase), typeof(AccessibleControls));
private static readonly DependencyProperty TrackedTooltipProperty = DependencyProperty.RegisterAttached(
@@ -35,8 +36,57 @@ internal static void Initialize()
{
if (_initialized) return;
_initialized = true;
+ HighContrastResources.Initialize();
EventManager.RegisterClassHandler(typeof(Control), FrameworkElement.SizeChangedEvent,
new SizeChangedEventHandler(OnControlSizeChanged), true);
+ // WPF still gives Tab focus to a hyperlink whose TextBlock is collapsed: an invisible
+ // stop that a screen reader announces as the window. Focus moves on past it instead.
+ EventManager.RegisterClassHandler(typeof(System.Windows.Documents.Hyperlink),
+ System.Windows.Input.Keyboard.PreviewGotKeyboardFocusEvent,
+ new System.Windows.Input.KeyboardFocusChangedEventHandler(OnHyperlinkPreviewGotFocus));
+ // An element with no size still reports itself on screen, which Accessibility Insights
+ // fails as a null bounding rectangle: a scroll bar's page button at the end of its
+ // track, an arrow button a theme hides, an empty status line. FromClip has UI
+ // Automation report an element with no visible area as off screen.
+ foreach (var type in new[] { typeof(TextBlock), typeof(RepeatButton) })
+ {
+ try
+ {
+ AutomationProperties.IsOffscreenBehaviorProperty.OverrideMetadata(type,
+ new FrameworkPropertyMetadata(IsOffscreenBehavior.FromClip));
+ }
+ catch (Exception error) when (error is ArgumentException or InvalidOperationException) { }
+ }
+ }
+
+ private static void OnHyperlinkPreviewGotFocus(object sender, System.Windows.Input.KeyboardFocusChangedEventArgs args)
+ {
+ if (sender is not System.Windows.Documents.Hyperlink link || !ReferenceEquals(args.NewFocus, link)) return;
+ DependencyObject? host = link.Parent;
+ while (host is FrameworkContentElement content) host = content.Parent;
+ if (host is not UIElement element || element.IsVisible) return;
+ args.Handled = true;
+ var direction = (System.Windows.Input.Keyboard.Modifiers & System.Windows.Input.ModifierKeys.Shift) != 0
+ ? System.Windows.Input.FocusNavigationDirection.Previous
+ : System.Windows.Input.FocusNavigationDirection.Next;
+ link.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
+ new Action(() => link.MoveFocus(new System.Windows.Input.TraversalRequest(direction))));
+ }
+
+ // Tab into a radio group lands on its selected option, as in Windows, rather than on the
+ // option that last had focus or the first one. Only for the keyboard: a click focuses the
+ // option it lands on.
+ private static void OnRadioGroupPreviewGotFocus(object sender, System.Windows.Input.KeyboardFocusChangedEventArgs args)
+ {
+ if (sender is not Panel group || args.NewFocus is not RadioButton target || target.IsChecked == true) return;
+ if (args.OldFocus is Visual old && group.IsAncestorOf(old)) return;
+ if (System.Windows.Input.InputManager.Current.MostRecentInputDevice is not System.Windows.Input.KeyboardDevice) return;
+ var selected = group.Children.OfType()
+ .FirstOrDefault(option => option.IsChecked == true && option.IsEnabled && option.IsVisible);
+ if (selected == null) return;
+ args.Handled = true;
+ selected.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
+ new Action(() => selected.Focus()));
}
private static void OnControlSizeChanged(object sender, SizeChangedEventArgs args)
@@ -63,10 +113,34 @@ private static void OnControlLoaded(object sender, RoutedEventArgs args)
internal static void Prepare(Control control)
{
+ if (control is Window) HighContrastResources.Initialize();
+ else HighContrastResources.PrepareCaption(control);
// The focus adorner is shown by WPF for keyboard navigation, without changing layout.
if (control is not Window && control.Focusable && control.FocusVisualStyle == null)
control.SetCurrentValue(Control.FocusVisualStyleProperty, IsCompact(control) ? CompactFocusStyle : FocusStyle);
+ // A group of radio buttons is one Tab stop, as everywhere else in Windows, and the arrow
+ // keys move between its options. Menus and flyouts, which are not hosted in the window
+ // itself, keep their own keyboard handling.
+ if (control is RadioButton radio && VisualTreeHelper.GetParent(radio) is Panel group &&
+ PresentationSource.FromVisual(radio)?.RootVisual is Window && group.Children.Count > 1 &&
+ group.ReadLocalValue(System.Windows.Input.KeyboardNavigation.TabNavigationProperty) == DependencyProperty.UnsetValue &&
+ group.Children.Cast().All(child => child is RadioButton option && option.GroupName == radio.GroupName))
+ {
+ System.Windows.Input.KeyboardNavigation.SetTabNavigation(group, System.Windows.Input.KeyboardNavigationMode.Once);
+ System.Windows.Input.KeyboardNavigation.SetDirectionalNavigation(group, System.Windows.Input.KeyboardNavigationMode.Cycle);
+ group.PreviewGotKeyboardFocus += OnRadioGroupPreviewGotFocus;
+ }
+
+ // An icon given as string content ("") is drawn by the content presenter's
+ // own TextBlock, which UI Automation reads out as a private-use character. Drawing it
+ // through a Glyph keeps the same text on screen and leaves the name to the control.
+ // A selector rather than a template: when the content later becomes anything else
+ // (a toolbar swapping in an icon and label panel), the presenter's default applies.
+ if (control is ContentControl glyphHost && glyphHost.Content is string glyphText &&
+ IsGlyph(glyphText) && glyphHost.ContentTemplate == null && glyphHost.ContentTemplateSelector == null)
+ glyphHost.SetCurrentValue(ContentControl.ContentTemplateSelectorProperty, GlyphTemplates);
+
UpdateName(control);
}
@@ -195,6 +269,29 @@ private static bool IsReadable(string text)
return false;
}
+ private static bool IsGlyph(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return false;
+ foreach (char character in text)
+ if (!char.IsWhiteSpace(character) &&
+ CharUnicodeInfo.GetUnicodeCategory(character) != UnicodeCategory.PrivateUse)
+ return false;
+ return true;
+ }
+
+ private static readonly DataTemplateSelector GlyphTemplates = new GlyphTemplateSelector();
+
+ private static DataTemplate GlyphTemplate => _glyphTemplate ??= BuildGlyphTemplate();
+
+ private static DataTemplate BuildGlyphTemplate()
+ {
+ var glyph = new FrameworkElementFactory(typeof(Glyph));
+ glyph.SetBinding(TextBlock.TextProperty, new Binding());
+ var template = new DataTemplate { VisualTree = glyph };
+ template.Seal();
+ return template;
+ }
+
private static Style FocusStyle => _focusStyle ??= BuildFocusStyle(2);
///
@@ -203,32 +300,35 @@ private static bool IsReadable(string text)
///
private static Style CompactFocusStyle => _compactFocusStyle ??= BuildFocusStyle(-3);
- private static bool IsCompact(Control control) =>
+ private static bool IsCompact(FrameworkElement control) =>
control.ActualWidth > 0 && control.ActualHeight > 0 &&
(control.ActualWidth < CompactSize || control.ActualHeight < CompactSize);
+ /// The family focus ring for a focusable element that is not a Control, such as a
+ /// code-built appointment chip. Controls receive theirs automatically.
+ internal static Style FocusStyleFor(FrameworkElement element) =>
+ IsCompact(element) ? CompactFocusStyle : FocusStyle;
+
private const double CompactSize = 21;
private static Style BuildFocusStyle(double inset)
{
- var grid = new FrameworkElementFactory(typeof(Grid));
- foreach (var stroke in new[] { Tuple.Create(SystemColors.WindowTextBrushKey, 3.0),
- Tuple.Create(SystemColors.WindowBrushKey, 1.0) })
- {
- var outline = new FrameworkElementFactory(typeof(Rectangle));
- outline.SetValue(FrameworkElement.MarginProperty, new Thickness(inset));
- outline.SetValue(Shape.StrokeThicknessProperty, stroke.Item2);
- outline.SetValue(Shape.StrokeDashArrayProperty, new DoubleCollection { 2, 2 });
- outline.SetResourceReference(Shape.StrokeProperty, stroke.Item1);
- grid.AppendChild(outline);
- }
- var template = new ControlTemplate(typeof(Control)) { VisualTree = grid };
+ var ring = new FrameworkElementFactory(typeof(FocusRing));
+ ring.SetValue(FrameworkElement.MarginProperty, new Thickness(inset));
+ ring.SetValue(FocusRing.InsetProperty, inset);
+ var template = new ControlTemplate(typeof(Control)) { VisualTree = ring };
var style = new Style(typeof(Control));
style.Setters.Add(new Setter(Control.TemplateProperty, template));
style.Seal();
return style;
}
+ private sealed class GlyphTemplateSelector : DataTemplateSelector
+ {
+ public override DataTemplate? SelectTemplate(object? item, DependencyObject container) =>
+ item is string text && IsGlyph(text) ? GlyphTemplate : null;
+ }
+
private sealed class TooltipNameConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
@@ -247,4 +347,147 @@ public object[] ConvertBack(object? value, Type[] targetTypes, object? parameter
[];
}
}
+
+ ///
+ /// The keyboard focus outline: a thin dotted ring in the theme accent, one pixel on and one
+ /// off, rounded like the controls. When the accent is too faint against the window surfaces
+ /// to read as an outline, the ring uses the text color instead, and High Contrast uses the
+ /// system highlight. WPF builds a new outline each time focus moves, so it follows theme
+ /// changes.
+ ///
+ internal sealed class FocusRing : FrameworkElement
+ {
+ internal static readonly DependencyProperty InsetProperty = DependencyProperty.Register(
+ nameof(Inset), typeof(double), typeof(FocusRing), new PropertyMetadata(0.0));
+
+ private const double MinimumContrast = 3;
+ private double _radius;
+
+ public FocusRing()
+ {
+ SnapsToDevicePixels = true;
+ UseLayoutRounding = true;
+ IsHitTestVisible = false;
+ Loaded += (_, _) => Apply();
+ }
+
+ internal double Inset
+ {
+ get => (double)GetValue(InsetProperty);
+ set => SetValue(InsetProperty, value);
+ }
+
+ /// The ring's color, chosen when it loads.
+ internal Brush? Stroke { get; private set; }
+
+ internal double StrokeThickness => 1;
+
+ /// One pixel on, one pixel off.
+ internal DoubleCollection DashArray { get; } = new DoubleCollection { 1, 1 };
+
+ private void Apply()
+ {
+ Stroke = RingBrush();
+ double radius = TryFindResource("ControlCornerRadius") is CornerRadius corner ? corner.TopLeft : 0;
+ // An outline inside the control sits on a smaller curve, one outside it on a larger one.
+ _radius = radius <= 0 ? 0 : Math.Max(0, radius - Inset);
+ InvalidateVisual();
+ }
+
+ // The dots are placed on whole device pixels rather than drawn with a dashed pen, whose
+ // dashes fall between pixels and smear into a faint solid line. A rounded ring leaves out
+ // its corner pixels.
+ protected override void OnRender(DrawingContext dc)
+ {
+ if (Stroke == null) return;
+ var dpi = VisualTreeHelper.GetDpi(this);
+ double px = StrokeThickness / dpi.DpiScaleX, py = StrokeThickness / dpi.DpiScaleY;
+ int w = (int)Math.Round(ActualWidth / px), h = (int)Math.Round(ActualHeight / py);
+ if (w < 3 || h < 3) return;
+ int skip = _radius > 0 ? 1 : 0;
+ int step = (int)(DashArray[0] + DashArray[1]);
+ for (int x = skip; x < w - skip; x += step)
+ {
+ dc.DrawRectangle(Stroke, null, new Rect(x * px, 0, px, py));
+ dc.DrawRectangle(Stroke, null, new Rect(x * px, (h - 1) * py, px, py));
+ }
+ for (int y = 1 + skip + 1; y < h - 1 - skip; y += step)
+ {
+ dc.DrawRectangle(Stroke, null, new Rect(0, y * py, px, py));
+ dc.DrawRectangle(Stroke, null, new Rect((w - 1) * px, y * py, px, py));
+ }
+ }
+
+ private Brush RingBrush()
+ {
+ if (SystemParameters.HighContrast) return SystemColors.HighlightBrush;
+ Brush? text = TryFindResource("TextBrush") as Brush;
+ if (TryFindResource("PrimaryBrush") is SolidColorBrush accent)
+ {
+ var surfaces = new[] { "BackgroundBrush", "PaneBrush" }
+ .SelectMany(key => SurfaceColors(TryFindResource(key) as Brush)).ToList();
+ if (surfaces.All(surface => Contrast(Over(accent.Color, surface), surface) >= MinimumContrast))
+ return accent;
+ }
+ return text ?? SystemColors.WindowTextBrush;
+ }
+
+ private static IEnumerable SurfaceColors(Brush? brush) => brush switch
+ {
+ SolidColorBrush solid => new[] { solid.Color },
+ GradientBrush gradient => gradient.GradientStops.Select(stop => stop.Color),
+ _ => Enumerable.Empty(),
+ };
+
+ private static Color Over(Color top, Color bottom)
+ {
+ double alpha = top.A / 255.0;
+ byte Mix(byte a, byte b) => (byte)Math.Round(a * alpha + b * (1 - alpha));
+ return Color.FromRgb(Mix(top.R, bottom.R), Mix(top.G, bottom.G), Mix(top.B, bottom.B));
+ }
+
+ private static double Contrast(Color a, Color b)
+ {
+ double la = Luminance(a), lb = Luminance(b);
+ return (Math.Max(la, lb) + 0.05) / (Math.Min(la, lb) + 0.05);
+ }
+
+ private static double Luminance(Color color)
+ {
+ double Channel(byte value)
+ {
+ double c = value / 255.0;
+ return c <= 0.03928 ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4);
+ }
+ return 0.2126 * Channel(color.R) + 0.7152 * Channel(color.G) + 0.0722 * Channel(color.B);
+ }
+ }
+
+ ///
+ /// An icon drawn from a symbol font. The control it decorates carries the name, so the
+ /// glyph stays out of the automation tree instead of being read as a private-use character.
+ ///
+ public sealed class Glyph : TextBlock
+ {
+ protected override void OnInitialized(EventArgs e)
+ {
+ base.OnInitialized(e);
+ // A subclass misses the implicit TextBlock style in scope, so it is referenced here.
+ // A TextBlock created by a template does not take implicit styles from outside it, and
+ // an explicit Style wins, so in both cases the glyph is left exactly as a TextBlock is.
+ if (TemplatedParent == null && ReadLocalValue(StyleProperty) == DependencyProperty.UnsetValue)
+ SetResourceReference(StyleProperty, typeof(TextBlock));
+ }
+
+ protected override AutomationPeer OnCreateAutomationPeer() => new GlyphAutomationPeer(this);
+
+ private sealed class GlyphAutomationPeer : TextBlockAutomationPeer
+ {
+ internal GlyphAutomationPeer(TextBlock owner) : base(owner) { }
+
+ protected override bool IsControlElementCore() => false;
+
+ protected override bool IsContentElementCore() => false;
+ }
+ }
}
diff --git a/Controls/CapCenterMargin.cs b/Controls/CapCenterMargin.cs
new file mode 100644
index 00000000..5db5a43b
--- /dev/null
+++ b/Controls/CapCenterMargin.cs
@@ -0,0 +1,72 @@
+#nullable enable
+using System;
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+using System.Windows.Media;
+
+namespace KillerFamily.Layout
+{
+ ///
+ /// Centers a radio ring or checkbox square on its label's capital letters, for whatever font
+ /// the label is drawn in. Bind the indicator's Margin to the row's FontFamily and FontSize;
+ /// ConverterParameter is the margin it would have with no correction ("0,0,8,0").
+ ///
+ ///
+ /// Centering the indicator in the row puts it on the middle of the text's LINE BOX, and where
+ /// the capitals sit inside that box is a property of the font. Killer Sans UI sits low (the
+ /// ring has to come down a pixel at 12px); Microsoft Sans Serif, 98SE's face, sits in the
+ /// middle. A fixed pixel nudge is right for one face at one size and wrong for the other,
+ /// which is how this kept drifting from one fix to the next. The offset here comes from a
+ /// capital H laid out exactly as the label is, through the same font fallback, so it holds for
+ /// every theme, every language fallback and every app size. Reading the family's own metrics
+ /// is not enough: for a fallback list such as "Killer Sans UI, Segoe UI" WPF reports the
+ /// metrics of Segoe UI while the label draws in Killer Sans UI.
+ ///
+ public sealed class CapCenterMargin : IMultiValueConverter
+ {
+ public static readonly CapCenterMargin Instance = new();
+ private static readonly System.Collections.Generic.Dictionary Cache = new();
+
+ public object Convert(object[] values, Type targetType, object? parameter, CultureInfo culture)
+ {
+ Thickness margin = ParseMargin(parameter as string);
+ if (values.Length < 2 || values[0] is not FontFamily family || values[1] is not double size)
+ return margin;
+
+ double shift = Offset(family, size);
+ return new Thickness(margin.Left, margin.Top + shift, margin.Right, margin.Bottom - shift);
+ }
+
+ /// How far below the line box's middle the middle of the capitals sits, in px.
+ public static double Offset(FontFamily family, double size)
+ {
+ string key = (family.BaseUri?.ToString() ?? "") + "|" + family.Source + "|" + size.ToString("R", CultureInfo.InvariantCulture);
+ lock (Cache)
+ if (Cache.TryGetValue(key, out double cached)) return cached;
+ double shift = 0;
+ try
+ {
+ var typeface = new Typeface(family, FontStyles.Normal, FontWeights.Normal, FontStretches.Normal);
+ var text = new FormattedText("H", CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
+ typeface, size, Brushes.Black, 1.0);
+ Rect ink = text.BuildGeometry(new Point(0, 0)).Bounds;
+ if (!ink.IsEmpty) shift = (ink.Top + ink.Bottom) / 2 - text.Height / 2;
+ if (double.IsNaN(shift) || double.IsInfinity(shift)) shift = 0;
+ }
+ catch { shift = 0; } // an unreadable font keeps the plain margin rather than failing layout
+ lock (Cache) Cache[key] = shift;
+ return shift;
+ }
+
+ private static Thickness ParseMargin(string? text)
+ {
+ if (string.IsNullOrEmpty(text)) return new Thickness(0, 0, 8, 0);
+ try { return (Thickness)new ThicknessConverter().ConvertFromInvariantString(text!)!; }
+ catch { return new Thickness(0, 0, 8, 0); }
+ }
+
+ public object[] ConvertBack(object value, Type[] targetTypes, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+ }
+}
diff --git a/Controls/ConfirmDialog.xaml b/Controls/ConfirmDialog.xaml
index 2849c54b..58dfee24 100644
--- a/Controls/ConfirmDialog.xaml
+++ b/Controls/ConfirmDialog.xaml
@@ -86,7 +86,7 @@
is what a user reads - which is why they have to be resources. -->
@@ -94,6 +94,10 @@
+
+
+
+ Margin="{DynamicResource DialogCaptionButtonsMargin}"/>
@@ -197,15 +200,15 @@
ItemContainerStyle="{StaticResource PickerMenuItem}">
@@ -262,7 +265,7 @@
@@ -310,7 +313,7 @@
@@ -452,7 +455,7 @@
@@ -496,7 +499,7 @@
FontFamily="{DynamicResource UiFont}" FontSize="11"
Foreground="{DynamicResource MutedTextBrush}"/>
-
diff --git a/Controls/FileDialog.xaml.cs b/Controls/FileDialog.xaml.cs
index 1c5f3471..0cc2dd23 100644
--- a/Controls/FileDialog.xaml.cs
+++ b/Controls/FileDialog.xaml.cs
@@ -242,10 +242,6 @@ public FileDialog(FileDialogMode mode = FileDialogMode.Open)
{
if (owner != null && owner.IsVisible) Owner = owner;
- // The picker uses the caller's operation title in its caption. Reuse the owner's
- // canonical close-button template so 98SE gets the square raised caption button.
- if (owner?.TryFindResource("ChromeCloseButton") is Style closeStyle)
- CaptionCloseButton.Style = closeStyle;
AcceptButton.Content = Loc(_mode == FileDialogMode.Save ? "Str_Btn_Save" : "Str_Btn_Open");
ConfigureImagePreview();
// Extended, not Multiple: Extended is the Explorer behavior (plain click replaces the
diff --git a/Controls/GlyphInk.cs b/Controls/GlyphInk.cs
new file mode 100644
index 00000000..e990d083
--- /dev/null
+++ b/Controls/GlyphInk.cs
@@ -0,0 +1,73 @@
+#nullable enable
+using System;
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Media;
+
+namespace KillerFamily.Layout
+{
+ ///
+ /// Centers a glyph's ink instead of its advance box. Icon fonts draw many glyphs off-center
+ /// inside their box (a chevron hugs one side, a "?" sits high), so a TextBlock centered in a
+ /// rail button leaves the visible shape a pixel or two nearer one edge. With Center set, the
+ /// TextBlock's margin moves it by the distance between its box center and its ink center, on
+ /// both axes, so the glyph has the same space on every side. The margin is bound to the text
+ /// and font, so it follows a glyph swap (the collapse chevron) or a font change by itself.
+ ///
+ public static class GlyphInk
+ {
+ public static readonly DependencyProperty CenterProperty = DependencyProperty.RegisterAttached(
+ "Center", typeof(bool), typeof(GlyphInk), new PropertyMetadata(false, OnCenterChanged));
+
+ public static bool GetCenter(DependencyObject d) => (bool)d.GetValue(CenterProperty);
+ public static void SetCenter(DependencyObject d, bool value) => d.SetValue(CenterProperty, value);
+
+ private static void OnCenterChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is not TextBlock text) return;
+ if (!(bool)e.NewValue)
+ {
+ BindingOperations.ClearBinding(text, FrameworkElement.MarginProperty);
+ return;
+ }
+ var binding = new MultiBinding { Converter = InkOffset.Instance };
+ foreach (var dp in new[] { TextBlock.TextProperty, TextBlock.FontFamilyProperty, TextBlock.FontSizeProperty,
+ TextBlock.FontStyleProperty, TextBlock.FontWeightProperty, TextBlock.FontStretchProperty })
+ binding.Bindings.Add(new Binding { Path = new PropertyPath(dp), RelativeSource = RelativeSource.Self });
+ BindingOperations.SetBinding(text, FrameworkElement.MarginProperty, binding);
+ }
+
+ /// The margin that moves the glyph's ink center onto its box center.
+ internal static Thickness Offset(string text, FontFamily family, double size, FontStyle style, FontWeight weight, FontStretch stretch)
+ {
+ if (string.IsNullOrEmpty(text) || family == null || size <= 0) return new Thickness(0);
+ var formatted = new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
+ new Typeface(family, style, weight, stretch), size, Brushes.Black, 1.0);
+ Rect ink = formatted.BuildGeometry(new Point(0, 0)).Bounds;
+ if (ink.IsEmpty) return new Thickness(0);
+ double dx = formatted.WidthIncludingTrailingWhitespace / 2 - (ink.X + ink.Width / 2);
+ double dy = formatted.Height / 2 - (ink.Y + ink.Height / 2);
+ // A centered element moves by half the difference between its two margins.
+ return new Thickness(Math.Max(0, 2 * dx), Math.Max(0, 2 * dy), Math.Max(0, -2 * dx), Math.Max(0, -2 * dy));
+ }
+
+ private sealed class InkOffset : IMultiValueConverter
+ {
+ internal static readonly InkOffset Instance = new();
+
+ public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (values.Length < 6 || values[0] is not string text || values[1] is not FontFamily family ||
+ values[2] is not double size || values[3] is not FontStyle style ||
+ values[4] is not FontWeight weight || values[5] is not FontStretch stretch)
+ return new Thickness(0);
+ return Offset(text, family, size, style, weight, stretch);
+ }
+
+ public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+ }
+ }
+}
diff --git a/Controls/GrainTexture.cs b/Controls/GrainTexture.cs
new file mode 100644
index 00000000..c96c8ee2
--- /dev/null
+++ b/Controls/GrainTexture.cs
@@ -0,0 +1,212 @@
+using System;
+using System.Collections.Generic;
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+
+namespace KillerScan.Controls
+{
+ ///
+ /// The procedural film grain the whole app is textured with. One tile, shared by every grain
+ /// brush, so the pattern lines up across the title bar, the panes and the menus instead of each
+ /// surface getting its own noise.
+ ///
+ /// Every pixel is a speck of a continuous gray shade, softened into its neighbours, so the grain
+ /// reads as soft film grain on every theme instead of hard dots on Black. A share of the specks
+ /// leans toward the theme's secondary color: the complement of the accent hue (a green accent
+ /// leans purple, a blue one leans orange-brown, and reds lean orange-brown too instead of
+ /// blue), or of the background hue when the accent has no hue of its own. The tile is
+ /// regenerated when the theme or accent changes. The seed is fixed so a rebuild produces the
+ /// identical pattern.
+ ///
+ internal static class GrainTexture
+ {
+ private const int Size = 256;
+ private const int Seed = 1337;
+ private const byte Alpha = 70;
+ private const double ShadeSpread = 150; // standard deviation of a speck's gray around 128
+ private const double Soften = 0.25; // share of each speck taken from its four neighbours
+ private const double Contrast = 1.2; // restores the contrast the soften step removes
+ private const double LeanShare = 0.40; // share of specks that lean toward the secondary color
+ private const double LeanMix = 0.80; // how far a leaning speck moves toward it
+
+ private static readonly byte[] Shades = new byte[Size * Size];
+ private static readonly bool[] Leaning = new bool[Size * Size];
+ private static readonly Dictionary Cache = new();
+ private static readonly List> Layers = new();
+ private static BitmapSource? _current;
+ private static int _currentKey = int.MinValue;
+ private static bool _hooked;
+
+ static GrainTexture()
+ {
+ var rng = new Random(Seed);
+ var raw = new double[Size * Size];
+ for (int i = 0; i < raw.Length; i++)
+ {
+ double u1 = 1.0 - rng.NextDouble(), u2 = rng.NextDouble();
+ double gauss = Math.Sqrt(-2 * Math.Log(u1)) * Math.Cos(2 * Math.PI * u2);
+ raw[i] = Clamp(128 + ShadeSpread * gauss);
+ Leaning[i] = rng.NextDouble() < LeanShare;
+ }
+
+ for (int y = 0; y < Size; y++)
+ {
+ for (int x = 0; x < Size; x++)
+ {
+ // Wrapping neighbours keep the tile seamless.
+ double around = (raw[((y + Size - 1) % Size) * Size + x] + raw[((y + 1) % Size) * Size + x]
+ + raw[y * Size + (x + Size - 1) % Size] + raw[y * Size + (x + 1) % Size]) / 4;
+ double soft = raw[y * Size + x] * (1 - Soften) + around * Soften;
+ Shades[y * Size + x] = (byte)Math.Round(Clamp(128 + (soft - 128) * Contrast));
+ }
+ }
+ }
+
+ /// Generates the tile, leaning toward when given. Frozen.
+ internal static BitmapSource Generate(Color? lean)
+ {
+ var pixels = new byte[Size * Size * 4];
+ for (int i = 0; i < Shades.Length; i++)
+ {
+ double shade = Shades[i], r = shade, g = shade, b = shade;
+ if (lean is Color c && Leaning[i])
+ {
+ // The leaning speck keeps its own lightness, so it tints without brightening.
+ double k = 0.35 + 1.1 * shade / 255.0;
+ r = shade * (1 - LeanMix) + Clamp(c.R * k) * LeanMix;
+ g = shade * (1 - LeanMix) + Clamp(c.G * k) * LeanMix;
+ b = shade * (1 - LeanMix) + Clamp(c.B * k) * LeanMix;
+ }
+ int p = i * 4;
+ pixels[p] = (byte)Math.Round(b);
+ pixels[p + 1] = (byte)Math.Round(g);
+ pixels[p + 2] = (byte)Math.Round(r);
+ pixels[p + 3] = Alpha;
+ }
+
+ var bmp = new WriteableBitmap(Size, Size, 96, 96, PixelFormats.Bgra32, null);
+ bmp.WritePixels(new Int32Rect(0, 0, Size, Size), pixels, Size * 4, 0);
+ bmp.Freeze();
+ return bmp;
+ }
+
+ ///
+ /// The secondary color the grain leans toward: the complement of the accent hue, or of the
+ /// background hue when the accent is a neutral (white, gray). Null when neither has a hue.
+ ///
+ internal static Color? LeanFor(Color accent, Color background)
+ {
+ if (!TryHue(accent, out double hue) && !TryHue(background, out hue)) return null;
+ // Reds and peaches lean orange-brown like the blues, not to the blue of their complement.
+ if (hue >= 330 || hue < 31) return FromHsv(25, 0.55, 0.85);
+ hue = (hue + 180) % 360;
+ // The plain complement of a green lands on pink; the family leans greens to purple.
+ if (hue >= 300 && hue < 345) hue = 285;
+ return FromHsv(hue, 0.55, 0.85);
+ }
+
+ ///
+ /// Paints the tile into every named ImageBrush the window happens to declare (all optional)
+ /// and keeps them current when the theme changes.
+ ///
+ internal static void Apply(FrameworkElement scope, params string[] brushNames)
+ {
+ Publish();
+ foreach (var name in brushNames)
+ if (scope.FindName(name) is ImageBrush ib) Track(ib);
+ }
+
+ /// Paints the tile into a brush and repaints it whenever the tile changes.
+ internal static void Track(ImageBrush brush)
+ {
+ brush.ImageSource = Publish();
+ foreach (var layer in Layers)
+ if (layer.TryGetTarget(out var known) && ReferenceEquals(known, brush)) return;
+ Layers.Add(new WeakReference(brush));
+ }
+
+ ///
+ /// Generates the tile for the current theme and publishes it as the app-level GrainTileBrush.
+ /// App startup calls this too, so dialogs shown before any main window exists (install,
+ /// uninstall, install repair) are textured like the rest of the app. Theme and accent changes
+ /// republish it on their own.
+ ///
+ internal static BitmapSource Publish()
+ {
+ if (!_hooked)
+ {
+ _hooked = true;
+ Services.ThemeManager.ThemeChanged += () => Publish();
+ }
+
+ var lean = CurrentLean();
+ int key = lean is Color c ? (c.R << 16) | (c.G << 8) | c.B : -1;
+ if (_current != null && key == _currentKey) return _current;
+
+ if (!Cache.TryGetValue(key, out var bmp))
+ {
+ bmp = Generate(lean);
+ Cache[key] = bmp;
+ }
+ _current = bmp;
+ _currentKey = key;
+
+ // The keyed resource brush is frozen, so its ImageSource cannot be set in place. Swap in
+ // a fresh frozen brush instead; DynamicResource consumers re-resolve automatically.
+ var tile = new ImageBrush(bmp)
+ {
+ TileMode = TileMode.Tile,
+ ViewportUnits = BrushMappingMode.Absolute,
+ Viewport = new Rect(0, 0, Size, Size),
+ Stretch = Stretch.None
+ };
+ tile.Freeze();
+ if (Application.Current != null) Application.Current.Resources["GrainTileBrush"] = tile;
+
+ for (int i = Layers.Count - 1; i >= 0; i--)
+ {
+ if (Layers[i].TryGetTarget(out var layer)) layer.ImageSource = bmp;
+ else Layers.RemoveAt(i);
+ }
+ return bmp;
+ }
+
+ private static Color? CurrentLean()
+ {
+ var app = Application.Current;
+ if (app == null) return null;
+ var accent = app.TryFindResource("PrimaryBrush") is SolidColorBrush a ? a.Color : Colors.Gray;
+ var background = app.TryFindResource("BackgroundBrush") is SolidColorBrush b ? b.Color : Colors.Gray;
+ return LeanFor(accent, background);
+ }
+
+ private static bool TryHue(Color c, out double hue)
+ {
+ double r = c.R / 255.0, g = c.G / 255.0, b = c.B / 255.0;
+ double max = Math.Max(r, Math.Max(g, b)), min = Math.Min(r, Math.Min(g, b)), d = max - min;
+ hue = 0;
+ if (max < 0.08 || d / max < 0.25) return false;
+ if (max == r) hue = 60 * (((g - b) / d) % 6);
+ else if (max == g) hue = 60 * ((b - r) / d + 2);
+ else hue = 60 * ((r - g) / d + 4);
+ if (hue < 0) hue += 360;
+ return true;
+ }
+
+ private static Color FromHsv(double hue, double saturation, double value)
+ {
+ double c = value * saturation, x = c * (1 - Math.Abs(hue / 60 % 2 - 1)), m = value - c;
+ double r, g, b;
+ if (hue < 60) { r = c; g = x; b = 0; }
+ else if (hue < 120) { r = x; g = c; b = 0; }
+ else if (hue < 180) { r = 0; g = c; b = x; }
+ else if (hue < 240) { r = 0; g = x; b = c; }
+ else if (hue < 300) { r = x; g = 0; b = c; }
+ else { r = c; g = 0; b = x; }
+ return Color.FromRgb((byte)Math.Round((r + m) * 255), (byte)Math.Round((g + m) * 255), (byte)Math.Round((b + m) * 255));
+ }
+
+ private static double Clamp(double v) => v < 0 ? 0 : v > 255 ? 255 : v;
+ }
+}
diff --git a/Controls/HighContrastResources.cs b/Controls/HighContrastResources.cs
new file mode 100644
index 00000000..12ba6ca4
--- /dev/null
+++ b/Controls/HighContrastResources.cs
@@ -0,0 +1,186 @@
+#nullable enable
+using System;
+using System.ComponentModel;
+using System.Collections.Generic;
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Controls;
+
+namespace KillerFamily.Accessibility
+{
+ /// Windows contrast colors override the app palette without saving a theme change.
+ public static class HighContrastResources
+ {
+ public static readonly DependencyProperty IsActiveProperty = DependencyProperty.RegisterAttached(
+ "IsActive", typeof(bool), typeof(HighContrastResources), new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.Inherits));
+ public static bool GetIsActive(DependencyObject element) => (bool)element.GetValue(IsActiveProperty);
+ public static void SetIsActive(DependencyObject element, bool value) => element.SetValue(IsActiveProperty, value);
+ private static readonly Dictionary
+
+
+
+
+
@@ -140,7 +168,7 @@
-
@@ -241,44 +269,44 @@
PreviewMouseWheel="Menu_ForwardWheelToGrid">
-
+
-
+
-
+
-
+
-
-
+
-
+
-
-
+
@@ -290,19 +318,19 @@
page carrying real SVG. -->
-
+
-
+
-
+
-
+
@@ -328,7 +356,8 @@
Height="{DynamicResource ScanPaneBandSize}" Background="{DynamicResource ScanPaneBottomBrush}"/>
-
+
@@ -381,49 +410,49 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -431,75 +460,75 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -589,7 +618,8 @@
left edge. In the cell rather than a column of its own: a separate
column would sit at index 0 and fight the saved column order, and the
marker belongs to the row rather than being something to sort by. -->
-
+
@@ -608,7 +638,7 @@
-
+
-
+
-
+
-
+
KillerMCP includes all 81 KillerTools utilities plus tools from supported Killer apps in one local connection. The installer detects KillerScan and registers that shared connection with supported agent clients.
Open a new agent chat and ask. Available tools are discovered automatically.
+
Install KillerMCP
KillerMCP includes all 81 KillerTools utilities plus tools from supported Killer apps in one local connection. The installer detects KillerScan and registers that shared connection with supported agent clients.
Open a new agent chat and ask. Available tools are discovered automatically.
Try asking:
killer is the shortest form. Your agent can infer KillerScan from a clear network request. Use killerscan when you want to make the app explicit.
killer what local network am I onkillerscan my networkkiller deep probe 192.168.50.25killer ping gateway.local five timeskiller trace the route to 1.1.1.1killer diagnose 192.168.50.25 on ports 22, 80, and 443killer watch 192.168.50.1 and 192.168.50.25run KillerSpeed test
What it can do
KillerMCP exposes all nine headless KillerScan commands.
Identify the local networkReturn the active interface, local IPv4 address, subnet, gateway, and DNS server without scanning hosts.
Scan the networkRun bounded quick discovery or full fingerprinting and port checks.
Deep probe one hostInspect one IPv4 host, including ports 1 through 1024.
Look up MAC vendorsUse KillerScan's bundled offline OUI database without contacting a lookup service.
Ping a targetMeasure latency and packet loss with up to 20 ICMP checks.
Trace a routeReturn the network path to an IPv4 address or hostname.
Diagnose a hostCheck DNS, ping, route selection, and selected TCP ports.
Watch hostsSample availability and latency for up to 16 IPv4 addresses.
Run KillerSpeedMeasure download, upload, latency, and jitter through speed.killerscan.net.
Data safety and permissions
KillerScan commands run on your computer. Local network identification does not probe other devices. A quick or full scan does send packets from your computer to the requested target, so review the target and scan type and scan only networks you are authorized to test.
How it works
KillerMCP is a small program that lets your AI assistant use supported Killer apps installed on your computer. You install and connect it once, and it automatically finds the apps and tools available on your computer. When you ask your assistant to inspect a network, KillerMCP checks the address and scan limits, asks KillerScan to run the command locally, waits for it to finish, and gives the results back to your assistant. The scan is performed by the installed KillerScan app, not by this webpage.
-
Current limits
Targets must be one IPv4 address or a CIDR from /22 through /32. Scans time out after 60 seconds and return no more than 100 discovered devices. Deep probes accept one IPv4 address. Ping sends up to 20 checks, traces return up to 64 hops, diagnostics accept up to 32 ports, and host watches accept up to 16 IPv4 addresses. KillerSpeed can transfer up to 6 GiB of generated test data plus network overhead.
-
-
-
+
Current limits
Targets must be one IPv4 address or a CIDR from /22 through /32. Scans time out after 60 seconds and return no more than 100 discovered devices. Deep probes accept one IPv4 address. Ping sends up to 20 checks, traces return up to 64 hops, diagnostics accept up to 32 ports, and host watches accept up to 16 IPv4 addresses. KillerSpeed can transfer up to 6 GiB of generated test data plus network overhead.
Why KillerMCP runs twice
Claude Desktop starts each local MCP server twice. One copy serves the app's own chats, and the other serves Claude sessions that use this computer remotely. Seeing two KillerMCP.exe processes under Claude in Task Manager is normal. KillerMCP is not installed twice, and nothing needs to be fixed.