Skip to content

Commit 45a5c6f

Browse files
committed
feat: complete culture-aware localization
1 parent 49b7a14 commit 45a5c6f

10 files changed

Lines changed: 566 additions & 186 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Changelog
22

3+
- Added: the UI now selects the Windows display language by default, resolves static, runtime, tray, dialog, and automation-accessible text through invariant resource keys with English fallback, and formats displayed values with the selected culture. A `-Culture` override and expanded/accented `qps-ploc` pseudo-locale exercise localization without requiring a translated build.
4+
- Changed: alert severity is explicit data instead of an English-text classifier, and the 200% high-contrast smoke lane now drives pseudo-localized navigation, validation alerts, reflow, and visible-control geometry.
35
- Added: a headless `list`, `get`, `set`, `profile`, and `diagnostics` command line runs without WPF, targets stable monitor identities, supports human text or a schema-v1 JSON envelope, and terminates a hung native worker at a documented timeout.
46
- Added: CLI writes share capability validation, per-identity risky-write unlocks, verified readback, and rollback with the GUI; set-if-needed, relative delta, comma-list cycling, and terse percentage-brightness/input aliases cover one-shot automation without prompts.
57
- Added: profile deletion now stores the exact profile and its removed application/schedule rules in bounded local Trash; the Profiles tab can restore the newest deletion across restarts or permanently empty Trash through a separate confirmation.

MonitorControlPro.ps1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ param(
3131
[string]$Theme = "System",
3232
[ValidateRange(0, 200)]
3333
[int]$TextScalePercent = 0,
34+
[string]$Culture = "",
3435
[string]$RenderDirectory = ""
3536
)
3637

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ A comprehensive Windows GUI utility for controlling monitor settings via DDC/CI
6161
- **Capability Caching and a Known-Bad Model List** — A successful capability read is cached against the monitor's stable identity and replayed on later launches, so the riskiest native call runs once per monitor rather than on every refresh. Monitor models documented upstream as faulting the Windows kernel during that call are skipped before any probe and reported in diagnostics
6262
- **Stable Monitor Identity** — Prefers the Windows DisplayManager device-interface path and raw EDID, with the existing EDID/device-path lookup as a guarded fallback. Existing labels, profile targets, safety unlocks, capability state, timing, and brightness restore settings migrate automatically, while custom labels and saved profiles continue to follow a display after reordering
6363
- **System-Aware Accessibility** — Follows Windows high contrast live across the main window, the tray popup, and the identify and FPS overlays, supports 100–200% text scaling with independently scrollable content and navigation, exposes visible keyboard focus and UI Automation names, and raises native live-region events for inline errors
64+
- **Culture-Aware UI** — Uses the Windows display language by default with invariant resource keys and English fallback across the window, dialogs, tray, runtime status, and accessibility text. The `-Culture <name>` launch option overrides selection; `-Culture qps-ploc` enables the expanded pseudo-locale used by the 200% DPI regression lane
6465
- **Portable Release ZIP** — Local release builds package the script, icon, README, LICENSE, signature status, and SHA256 checksums
6566
- **Verified Risky Writes** — Power, input, reset, color preset, OSD lock, OSD language, auxiliary power, PiP/PbP, and arbitrary commands require a per-monitor identity unlock plus an exact code/value confirmation. The confirmation names the specific consequence for that code, and readback reports verified, mismatched, or unavailable outcomes
6667
- **Async Capabilities Reads** — Monitor `vcp(...)` capabilities load from a background worker after enumeration, keeping refresh responsive

src/MonitorControl.App.ps1

Lines changed: 289 additions & 134 deletions
Large diffs are not rendered by default.

src/MonitorControl.Cli.psm1

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ function Get-CliChildArguments {
103103
[string]$Value,
104104
[long]$Delta,
105105
[string]$Cycle,
106+
[string]$Culture,
106107
[switch]$IfNeeded,
107108
[switch]$Json,
108109
[switch]$AllowRisky,
@@ -117,7 +118,8 @@ function Get-CliChildArguments {
117118
[PSCustomObject]@{ Name = "-Monitor"; Value = $Monitor },
118119
[PSCustomObject]@{ Name = "-Vcp"; Value = $Vcp },
119120
[PSCustomObject]@{ Name = "-Value"; Value = $Value },
120-
[PSCustomObject]@{ Name = "-Cycle"; Value = $Cycle }
121+
[PSCustomObject]@{ Name = "-Cycle"; Value = $Cycle },
122+
[PSCustomObject]@{ Name = "-Culture"; Value = $Culture }
121123
)) {
122124
if (-not [string]::IsNullOrWhiteSpace([string]$pair.Value)) {
123125
$arguments.Add([string]$pair.Name)
@@ -146,6 +148,7 @@ function Invoke-CliChildProcess {
146148
[string]$Value,
147149
[long]$Delta,
148150
[string]$Cycle,
151+
[string]$Culture,
149152
[switch]$IfNeeded,
150153
[switch]$Json,
151154
[switch]$AllowRisky,
@@ -162,7 +165,7 @@ function Invoke-CliChildProcess {
162165
try {
163166
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
164167
$startInfo.FileName = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName
165-
$startInfo.Arguments = Get-CliChildArguments -EntryPath $EntryPath -Command $Command -Argument $Argument -Monitor $Monitor -Vcp $Vcp -Value $Value -Delta $Delta -Cycle $Cycle -IfNeeded:$IfNeeded -Json:$Json -AllowRisky:$AllowRisky -TimeoutSeconds $TimeoutSeconds
168+
$startInfo.Arguments = Get-CliChildArguments -EntryPath $EntryPath -Command $Command -Argument $Argument -Monitor $Monitor -Vcp $Vcp -Value $Value -Delta $Delta -Cycle $Cycle -Culture $Culture -IfNeeded:$IfNeeded -Json:$Json -AllowRisky:$AllowRisky -TimeoutSeconds $TimeoutSeconds
166169
$startInfo.UseShellExecute = $false
167170
$startInfo.CreateNoWindow = $true
168171
$startInfo.RedirectStandardOutput = $true
@@ -600,6 +603,6 @@ function Invoke-MonitorControlCli {
600603
}
601604

602605
if (-not [string]::IsNullOrWhiteSpace([string]$Command) -and -not $CliWorker) {
603-
$cliExitCode = Invoke-CliChildProcess -EntryPath ([string]$script:MonitorControlEntryPath) -Command $Command -Argument $Argument -Monitor $Monitor -Vcp $Vcp -Value $Value -Delta $Delta -Cycle $Cycle -IfNeeded:$IfNeeded -Json:$Json -AllowRisky:$AllowRisky -TimeoutSeconds $TimeoutSeconds
606+
$cliExitCode = Invoke-CliChildProcess -EntryPath ([string]$script:MonitorControlEntryPath) -Command $Command -Argument $Argument -Monitor $Monitor -Vcp $Vcp -Value $Value -Delta $Delta -Cycle $Cycle -Culture $Culture -IfNeeded:$IfNeeded -Json:$Json -AllowRisky:$AllowRisky -TimeoutSeconds $TimeoutSeconds
604607
exit $cliExitCode
605608
}

src/MonitorControl.Core.psm1

Lines changed: 101 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,72 @@ function Get-SystemTextScalePercent {
8989
}
9090
}
9191

92-
function Get-StatusMessageSeverity {
93-
param([string]$Message)
94-
if ([string]::IsNullOrWhiteSpace($Message)) { return "Info" }
95-
if ($Message -match '(?i)\b(fail(?:ed|ure)?|error|invalid|denied|blocked|offline|corrupt|mismatch(?:ed)?|newer than|not found|unavailable|could not|no (?:DDC/CI )?(?:write )?target)\b') {
96-
return "Error"
92+
function Resolve-StatusSeverity {
93+
param([string]$Severity)
94+
if ($Severity -in @("Info", "Warning", "Error")) { return $Severity }
95+
return "Info"
96+
}
97+
98+
function Resolve-UiCultureName {
99+
param([string]$RequestedCulture = "", [string]$WindowsCulture = "")
100+
$candidate = if (-not [string]::IsNullOrWhiteSpace($RequestedCulture)) {
101+
$RequestedCulture.Trim()
102+
} elseif (-not [string]::IsNullOrWhiteSpace($WindowsCulture)) {
103+
$WindowsCulture.Trim()
104+
} else {
105+
[System.Globalization.CultureInfo]::CurrentUICulture.Name
97106
}
98-
if ($Message -match '(?i)\b(warn(?:ing)?|cancel(?:ed|led)?|busy|waiting|unsupported|stale|retry(?:ing)?|disabled|partly|partial(?:ly)?)\b') {
99-
return "Warning"
107+
if ($candidate -eq "qps-ploc") { return $candidate }
108+
try { return [System.Globalization.CultureInfo]::GetCultureInfo($candidate).Name } catch {
109+
throw "Unknown UI culture '$candidate'."
110+
}
111+
}
112+
113+
function Get-UiCultureInfo {
114+
param([string]$CultureName = "")
115+
$name = if ([string]::IsNullOrWhiteSpace($CultureName)) { [string]$script:UiCulture } else { $CultureName }
116+
if ($name -eq "qps-ploc") { return [System.Globalization.CultureInfo]::GetCultureInfo("en-US") }
117+
try { return [System.Globalization.CultureInfo]::GetCultureInfo($name) } catch {
118+
return [System.Globalization.CultureInfo]::GetCultureInfo("en-US")
119+
}
120+
}
121+
122+
function ConvertTo-PseudoLocalizedText {
123+
param([AllowEmptyString()][string]$Text)
124+
if ([string]::IsNullOrEmpty($Text) -or $Text.StartsWith("[!!", [System.StringComparison]::Ordinal)) { return $Text }
125+
if ($Text -cmatch "^[A-Z]{1,3}$") { return $Text }
126+
if ($Text -notmatch '[A-Za-z]') { return $Text }
127+
$upperCodePoints = @(0x00C0,0x0181,0x00C7,0x00D0,0x00CB,0x0191,0x011C,0x0124,0x00CF,0x0134,0x0136,0x013B,0x1E40,0x00D1,0x00D6,0x01A4,0x024A,0x0154,0x0160,0x0162,0x00DC,0x1E7C,0x0174,0x1E8C,0x0178,0x017D)
128+
$lowerCodePoints = @(0x00E0,0x0180,0x00E7,0x010F,0x00EB,0x0192,0x011D,0x0125,0x00EF,0x0135,0x0137,0x013C,0x1E41,0x00F1,0x00F6,0x01A5,0x024B,0x0155,0x0161,0x0163,0x00FC,0x1E7D,0x0175,0x1E8D,0x00FF,0x017E)
129+
$builder = New-Object System.Text.StringBuilder
130+
$insideFormatItem = $false
131+
foreach ($character in $Text.ToCharArray()) {
132+
if ($character -eq '{') { $insideFormatItem = $true }
133+
$characterCode = [int]$character
134+
if (-not $insideFormatItem -and $characterCode -ge 65 -and $characterCode -le 90) {
135+
[void]$builder.Append([char]$upperCodePoints[$characterCode - 65])
136+
} elseif (-not $insideFormatItem -and $characterCode -ge 97 -and $characterCode -le 122) {
137+
[void]$builder.Append([char]$lowerCodePoints[$characterCode - 97])
138+
} else {
139+
[void]$builder.Append($character)
140+
}
141+
if ($character -eq '}') { $insideFormatItem = $false }
142+
}
143+
$paddingLength = [Math]::Max(2, [int][Math]::Ceiling($Text.Length * 0.3))
144+
return "[!! $($builder.ToString()) $('~' * $paddingLength) !!]"
145+
}
146+
147+
function Get-UiResourceKey {
148+
param([string]$Category, [string]$DefaultText)
149+
$sha = $null
150+
try {
151+
$sha = [System.Security.Cryptography.SHA256]::Create()
152+
$bytes = [System.Text.Encoding]::UTF8.GetBytes("$Category`n$DefaultText")
153+
$hash = (($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") }) -join "").Substring(0, 16)
154+
return "$Category.$hash"
155+
} finally {
156+
if ($null -ne $sha) { $sha.Dispose() }
100157
}
101-
return "Info"
102158
}
103159

104160
function Get-NavigationShortcutTarget {
@@ -116,9 +172,43 @@ function Get-NavigationShortcutTarget {
116172
}
117173

118174
function Get-UiString {
119-
param([string]$Key)
120-
if ($script:UiStrings.ContainsKey($Key)) { return [string]$script:UiStrings[$Key] }
121-
return $Key
175+
param([string]$Key, [object[]]$ArgumentList = @())
176+
$text = $Key
177+
if ($null -ne $script:UiCultureResources -and $script:UiCultureResources.ContainsKey([string]$script:UiCulture)) {
178+
$cultureTable = $script:UiCultureResources[[string]$script:UiCulture]
179+
if ($null -ne $cultureTable -and $cultureTable.ContainsKey($Key)) { $text = [string]$cultureTable[$Key] }
180+
}
181+
if ($text -eq $Key -and $null -ne $script:UiCultureResources -and $script:UiCultureResources.ContainsKey("en-US")) {
182+
$englishTable = $script:UiCultureResources["en-US"]
183+
if ($null -ne $englishTable -and $englishTable.ContainsKey($Key)) { $text = [string]$englishTable[$Key] }
184+
}
185+
if ([string]$script:UiCulture -eq "qps-ploc") { $text = ConvertTo-PseudoLocalizedText -Text $text }
186+
if (@($ArgumentList).Count -gt 0) {
187+
return [string]::Format((Get-UiCultureInfo), $text, $ArgumentList)
188+
}
189+
return $text
190+
}
191+
192+
function Get-UiRuntimeText {
193+
param([string]$Category, [string]$DefaultText, [string]$Key = "", [object[]]$ArgumentList = @())
194+
$resourceKey = if ([string]::IsNullOrWhiteSpace($Key)) { Get-UiResourceKey -Category $Category -DefaultText $DefaultText } else { $Key }
195+
if ($null -eq $script:UiCultureResources) { $script:UiCultureResources = @{} }
196+
if (-not $script:UiCultureResources.ContainsKey("en-US")) { $script:UiCultureResources["en-US"] = @{} }
197+
if (-not $script:UiCultureResources["en-US"].ContainsKey($resourceKey)) {
198+
$script:UiCultureResources["en-US"][$resourceKey] = $DefaultText
199+
}
200+
return Get-UiString -Key $resourceKey -ArgumentList $ArgumentList
201+
}
202+
203+
function Format-UiNumber {
204+
param($Value, [string]$Format = "G")
205+
if ($Value -is [System.IFormattable]) { return $Value.ToString($Format, (Get-UiCultureInfo)) }
206+
return [string]$Value
207+
}
208+
209+
function Format-UiDateTime {
210+
param([DateTime]$Value, [string]$Format = "g")
211+
return $Value.ToString($Format, (Get-UiCultureInfo))
122212
}
123213

124214
function Get-CapabilityProbeDecision {

src/MonitorControl.Ddc.psm1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1097,7 +1097,7 @@ function Get-Monitors {
10971097
}
10981098
Sync-DisplayRecoveryInventory
10991099
if ($script:DdcAvailabilityDiagnosis -and [string]$script:DdcAvailabilityDiagnosis.Severity -ne "None") {
1100-
Update-Status ([string]$script:DdcAvailabilityDiagnosis.Headline)
1100+
Update-Status ([string]$script:DdcAvailabilityDiagnosis.Headline) -Severity ([string]$script:DdcAvailabilityDiagnosis.Severity) -Key "Status.DdcAvailability"
11011101
}
11021102
} finally {
11031103
if (-not [MonitorAPI]::ResumeVCPWrites()) {

0 commit comments

Comments
 (0)