Compare commits

...

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
c412e85cfc Switch AI model from gpt-4o-mini to gpt-4o for better classification accuracy
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/0789f3f1-1c5e-4f3b-9d03-dd771793a01c

Co-authored-by: niels9001 <9866362+niels9001@users.noreply.github.com>
2026-04-18 11:24:22 +00:00
copilot-swe-agent[bot]
049cbccd21 Extract magic numbers to named constants per review feedback
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/5188f8c6-4eea-4d6a-a1f5-44a10c0100d7

Co-authored-by: niels9001 <9866362+niels9001@users.noreply.github.com>
2026-04-18 11:21:47 +00:00
copilot-swe-agent[bot]
4308eb65a9 Add scheduled workflow for auto-labeling issues missing Product-* labels
Agent-Logs-Url: https://github.com/microsoft/PowerToys/sessions/5188f8c6-4eea-4d6a-a1f5-44a10c0100d7

Co-authored-by: niels9001 <9866362+niels9001@users.noreply.github.com>
2026-04-18 11:20:55 +00:00
moooyo
5520ae4cfa [PowerDisplay] Fix startup restore, volume init, and identify window lifecycle (#47051)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
- **Volume initialization**: Read VCP 0x62 on monitor discovery so
`CurrentVolume` reflects actual hardware state instead of staying at the
50% default.
- **Brightness capability check**: Guard brightness init behind
`SupportsBrightness` flag, consistent with contrast/volume handling.
- **IdentifyWindow lifecycle**: Replace fire-and-forget `Task.Delay`
with `DispatcherQueueTimer` (UI-thread-safe, stoppable on dispose). Swap
`Activate`/`PositionOnDisplay` order to eliminate first-show flicker.
- **Startup restore fix**: Change `MonitorStateEntry` fields to `int?`
so unset values (`null`) aren't confused with zero — prevents writing
default 0% brightness/volume to hardware on startup.
- **Restore/profile apply refactor**: Push value validation down to
`Set*Async` (continuous → `Math.Clamp`, discrete → capabilities check),
extract unified `TryRestore` helper, remove redundant `IsValueInRange`
and `> 0` checks.
<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

---------

Co-authored-by: Yu Leng (from Dev Box) <yuleng@microsoft.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:41:14 +00:00
moooyo
beddc3b065 [ImageResizer] Fix JsonPropertyName forwarding in ObservableProperty generator (#47056)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
This issue introduced by our recent WinUI 3 migration

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #47055
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

Co-authored-by: Yu Leng <yuleng@microsoft.com>
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
2026-04-17 15:20:45 +08:00
12 changed files with 379 additions and 110 deletions

View File

@@ -0,0 +1,232 @@
name: Scheduled Issue Product Labeling
on:
schedule:
- cron: "20 */6 * * *" # Every 6 hours at :20
workflow_dispatch: # Allow manual trigger
permissions:
models: read
issues: write
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
label-issues:
runs-on: ubuntu-latest
steps:
- name: Label issues missing Product labels
uses: actions/github-script@v7
with:
script: |
// ── Product label mapping ──────────────────────────────────
// Canonical list of Product-* labels used in this repo,
// derived from .github/skills/release-note-generation/references/step2-labeling.md
const PRODUCT_LABELS = [
"Product-Advanced Paste",
"Product-Always on Top",
"Product-Awake",
"Product-ColorPicker",
"Product-Command not found",
"Product-Command Palette",
"Product-CropAndLock",
"Product-Cursor Wrap",
"Product-Environment Variables",
"Product-FancyZones",
"Product-File Explorer",
"Product-File Locksmith",
"Product-Find My Mouse",
"Product-Hosts",
"Product-Image Resizer",
"Product-Keyboard Manager",
"Product-LightSwitch",
"Product-Mouse Highlighter",
"Product-Mouse Jump",
"Product-Mouse Pointer Crosshairs",
"Product-Mouse Without Borders",
"Product-New+",
"Product-Peek",
"Product-PowerRename",
"Product-PowerToys Run",
"Product-Quick Accent",
"Product-Registry Preview",
"Product-Screen Ruler",
"Product-Settings",
"Product-Shortcut Guide",
"Product-Text Extractor",
"Product-Workspaces",
"Product-ZoomIt",
];
// Map from bug-report "Area(s) with issue?" dropdown values
// to Product-* labels (used as strong hints when the issue body
// contains the area dropdown answer).
const AREA_TO_LABEL = {
"Advanced Paste": "Product-Advanced Paste",
"Always on Top": "Product-Always on Top",
"Awake": "Product-Awake",
"ColorPicker": "Product-ColorPicker",
"Command not found": "Product-Command not found",
"Command Palette": "Product-Command Palette",
"Crop and Lock": "Product-CropAndLock",
"Environment Variables": "Product-Environment Variables",
"FancyZones": "Product-FancyZones",
"FancyZones Editor": "Product-FancyZones",
"File Locksmith": "Product-File Locksmith",
"File Explorer: Preview Pane": "Product-File Explorer",
"File Explorer: Thumbnail preview": "Product-File Explorer",
"Hosts File Editor": "Product-Hosts",
"Image Resizer": "Product-Image Resizer",
"Keyboard Manager": "Product-Keyboard Manager",
"Light Switch": "Product-LightSwitch",
"Mouse Utilities": "Product-Find My Mouse",
"Mouse Without Borders": "Product-Mouse Without Borders",
"New+": "Product-New+",
"Peek": "Product-Peek",
"PowerRename": "Product-PowerRename",
"PowerToys Run": "Product-PowerToys Run",
"Quick Accent": "Product-Quick Accent",
"Registry Preview": "Product-Registry Preview",
"Screen ruler": "Product-Screen Ruler",
"Shortcut Guide": "Product-Shortcut Guide",
"TextExtractor": "Product-Text Extractor",
"Workspaces": "Product-Workspaces",
"ZoomIt": "Product-ZoomIt",
};
// ── Helpers ────────────────────────────────────────────────
function hasProductLabel(labels) {
return labels.some((l) => l.name.startsWith("Product-"));
}
// Try to extract the area from the structured bug-report body
// (the "Area(s) with issue?" dropdown).
function extractAreaFromBody(body) {
if (!body) return null;
// The rendered issue body contains a heading followed by the selected values
const areaMatch = body.match(
/### Area\(s\) with issue\?\s*\n+(.+?)(?:\n###|\n\n|$)/s
);
if (!areaMatch) return null;
const areaText = areaMatch[1].trim();
if (areaText === "_No response_" || areaText === "General") return null;
// Could be comma-separated; take the first specific one
const areas = areaText.split(",").map((a) => a.trim());
for (const area of areas) {
if (AREA_TO_LABEL[area]) return AREA_TO_LABEL[area];
}
return null;
}
// Use GitHub Models to classify an issue when the dropdown area
// is not available or is "General".
const MAX_BODY_LENGTH = 3000; // Truncate body to stay within model token limits while keeping enough context
const MAX_COMPLETION_TOKENS = 60; // Enough for a Product-* label name with some margin
async function classifyWithAI(title, body) {
const truncatedBody = (body || "").slice(0, MAX_BODY_LENGTH);
const labelList = PRODUCT_LABELS.join("\n- ");
const prompt = `You are a GitHub issue triager for the microsoft/PowerToys repository.
Given the issue title and body below, determine which ONE Product label best fits.
Reply with ONLY the label name (e.g. "Product-FancyZones") or "UNKNOWN" if you cannot determine it.
Available labels:
- ${labelList}
Issue title: ${title}
Issue body:
${truncatedBody}`;
try {
const response = await fetch(
"https://models.github.ai/inference/chat/completions",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "openai/gpt-4o",
messages: [{ role: "user", content: prompt }],
max_tokens: MAX_COMPLETION_TOKENS,
temperature: 0,
}),
}
);
if (!response.ok) {
core.warning(`AI classification failed: ${response.status} ${response.statusText}`);
return null;
}
const data = await response.json();
const answer = data.choices?.[0]?.message?.content?.trim();
if (!answer || answer === "UNKNOWN") return null;
// Validate the answer is a known label
if (PRODUCT_LABELS.includes(answer)) return answer;
// Try fuzzy match (the model may include extra text)
const found = PRODUCT_LABELS.find((l) => answer.includes(l));
return found || null;
} catch (err) {
core.warning(`AI classification error: ${err.message}`);
return null;
}
}
// ── Main ───────────────────────────────────────────────────
const MAX_ISSUES = 50; // Process up to 50 issues per run
let labeled = 0;
let skipped = 0;
core.info("Searching for open issues with Needs-Triage but no Product-* label...");
// Paginate through open issues labeled Needs-Triage
for await (const response of github.paginate.iterator(
github.rest.issues.listForRepo,
{
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
labels: "Needs-Triage",
sort: "created",
direction: "desc",
per_page: 100,
}
)) {
for (const issue of response.data) {
if (labeled + skipped >= MAX_ISSUES) break;
// Skip pull requests (the API returns them too)
if (issue.pull_request) continue;
if (hasProductLabel(issue.labels)) continue;
core.info(`Processing #${issue.number}: ${issue.title}`);
// 1) Try structured area dropdown first (fast, no AI needed)
let label = extractAreaFromBody(issue.body);
// 2) Fall back to AI classification
if (!label) {
label = await classifyWithAI(issue.title, issue.body);
}
if (label) {
core.info(` → Applying "${label}" to #${issue.number}`);
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [label],
});
labeled++;
} else {
core.info(` → Could not determine product label for #${issue.number}, skipping.`);
skipped++;
}
}
if (labeled + skipped >= MAX_ISSUES) break;
}
core.info(`Done. Labeled: ${labeled}, Skipped: ${skipped}`);

View File

@@ -18,7 +18,7 @@ namespace ImageResizer.Models
_scaleFormat ??= CompositeFormat.Parse(ResourceLoaderInstance.GetString("Input_AiScaleFormat"));
[ObservableProperty]
[JsonPropertyName("scale")]
[property: JsonPropertyName("scale")]
private int _scale = 2;
/// <summary>

View File

@@ -26,26 +26,26 @@ namespace ImageResizer.Models
};
[ObservableProperty]
[JsonPropertyName("Id")]
[property: JsonPropertyName("Id")]
private int _id;
private string _name;
[ObservableProperty]
[JsonPropertyName("fit")]
[property: JsonPropertyName("fit")]
[NotifyPropertyChangedFor(nameof(ShowHeight))]
private ResizeFit _fit = ResizeFit.Fit;
[ObservableProperty]
[JsonPropertyName("width")]
[property: JsonPropertyName("width")]
private double _width;
[ObservableProperty]
[JsonPropertyName("height")]
[property: JsonPropertyName("height")]
private double _height;
[ObservableProperty]
[JsonPropertyName("unit")]
[property: JsonPropertyName("unit")]
[NotifyPropertyChangedFor(nameof(ShowHeight))]
private ResizeUnit _unit = ResizeUnit.Pixel;

View File

@@ -471,10 +471,19 @@ namespace PowerDisplay.Common.Drivers.DDC
{
InitializeContrast(monitor, candidate.Handle);
}
// Initialize volume if supported
if (monitor.SupportsVolume)
{
InitializeVolume(monitor, candidate.Handle);
}
}
// Initialize brightness (always supported for DDC/CI monitors)
InitializeBrightness(monitor, candidate.Handle);
// Initialize brightness if supported
if (monitor.SupportsBrightness)
{
InitializeBrightness(monitor, candidate.Handle);
}
monitors.Add(monitor);
newHandleMap[monitor.Id] = candidate.Handle;
@@ -541,6 +550,18 @@ namespace PowerDisplay.Common.Drivers.DDC
}
}
/// <summary>
/// Initialize volume value for a monitor using VCP 0x62.
/// </summary>
private static void InitializeVolume(Monitor monitor, IntPtr handle)
{
if (TryGetVcpFeature(handle, VcpCodeVolume, monitor.Id, out uint current, out uint max))
{
var volumeInfo = new VcpFeatureValue((int)current, 0, (int)max);
monitor.CurrentVolume = volumeInfo.ToPercentage();
}
}
/// <summary>
/// Wrapper for GetVCPFeatureAndVCPFeatureReply that logs errors on failure.
/// </summary>
@@ -568,6 +589,12 @@ namespace PowerDisplay.Common.Drivers.DDC
/// </summary>
private static void UpdateMonitorCapabilitiesFromVcp(Monitor monitor, VcpCapabilities vcpCaps)
{
// Check for Brightness support (VCP 0x10)
if (vcpCaps.SupportsVcpCode(VcpCodeBrightness))
{
monitor.Capabilities |= MonitorCapabilities.Brightness;
}
// Check for Contrast support (VCP 0x12)
if (vcpCaps.SupportsVcpCode(VcpCodeContrast))
{

View File

@@ -165,6 +165,11 @@ namespace PowerDisplay.Common.Models
}
}
/// <summary>
/// Gets a value indicating whether the monitor supports brightness adjustment
/// </summary>
public bool SupportsBrightness => Capabilities.HasFlag(MonitorCapabilities.Brightness);
/// <summary>
/// Gets a value indicating whether the monitor supports contrast adjustment
/// </summary>

View File

@@ -14,28 +14,28 @@ namespace PowerDisplay.Common.Models
public sealed class MonitorStateEntry
{
/// <summary>
/// Gets or sets the brightness level (0-100).
/// Gets or sets the brightness level (0-100), or <c>null</c> if not yet read from the monitor.
/// </summary>
[JsonPropertyName("brightness")]
public int Brightness { get; set; }
public int? Brightness { get; set; }
/// <summary>
/// Gets or sets the color temperature VCP value.
/// Gets or sets the color temperature VCP value, or <c>null</c> if not yet read from the monitor.
/// </summary>
[JsonPropertyName("colorTemperature")]
public int ColorTemperatureVcp { get; set; }
public int? ColorTemperatureVcp { get; set; }
/// <summary>
/// Gets or sets the contrast level (0-100).
/// Gets or sets the contrast level (0-100), or <c>null</c> if not yet read from the monitor.
/// </summary>
[JsonPropertyName("contrast")]
public int Contrast { get; set; }
public int? Contrast { get; set; }
/// <summary>
/// Gets or sets the volume level (0-100).
/// Gets or sets the volume level (0-100), or <c>null</c> if not yet read from the monitor.
/// </summary>
[JsonPropertyName("volume")]
public int Volume { get; set; }
public int? Volume { get; set; }
/// <summary>
/// Gets or sets the raw capabilities string from DDC/CI.

View File

@@ -15,7 +15,7 @@ namespace PowerDisplay.Common.Serialization
/// </summary>
[JsonSourceGenerationOptions(
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
IncludeFields = true)]
[JsonSerializable(typeof(MonitorStateFile))]
[JsonSerializable(typeof(MonitorStateEntry))]

View File

@@ -35,13 +35,13 @@ namespace PowerDisplay.Common.Services
/// </summary>
private sealed class MonitorState
{
public int Brightness { get; set; }
public int? Brightness { get; set; }
public int ColorTemperatureVcp { get; set; }
public int? ColorTemperatureVcp { get; set; }
public int Contrast { get; set; }
public int? Contrast { get; set; }
public int Volume { get; set; }
public int? Volume { get; set; }
public string? CapabilitiesRaw { get; set; }
}
@@ -128,7 +128,7 @@ namespace PowerDisplay.Common.Services
/// </summary>
/// <param name="monitorId">The monitor's unique Id (e.g., "DDC_GSM5C6D_1").</param>
/// <returns>A tuple of (Brightness, ColorTemperatureVcp, Contrast, Volume) or null if not found.</returns>
public (int Brightness, int ColorTemperatureVcp, int Contrast, int Volume)? GetMonitorParameters(string monitorId)
public (int? Brightness, int? ColorTemperatureVcp, int? Contrast, int? Volume)? GetMonitorParameters(string monitorId)
{
if (string.IsNullOrEmpty(monitorId))
{

View File

@@ -3,7 +3,7 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Threading.Tasks;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using PowerDisplay.Configuration;
using PowerDisplay.Helpers;
@@ -17,6 +17,8 @@ namespace PowerDisplay.PowerDisplayXAML
public sealed partial class IdentifyWindow : WindowEx, IDisposable
{
private DpiSuppressor? _dpiSuppressor;
private DispatcherQueueTimer? _autoCloseTimer;
private bool _disposed;
public IdentifyWindow(string displayText)
{
@@ -40,14 +42,19 @@ namespace PowerDisplay.PowerDisplayXAML
// Ensure DpiSuppressor is disposed when window closes
this.Closed += (_, _) => Dispose();
// Auto close after 3 seconds
Task.Delay(3000).ContinueWith(_ =>
// Auto close after 3 seconds. DispatcherQueueTimer runs on the UI thread
// and can be deterministically cancelled on Dispose, unlike a detached Task.Delay.
_autoCloseTimer = DispatcherQueue.CreateTimer();
_autoCloseTimer.Interval = TimeSpan.FromSeconds(3);
_autoCloseTimer.IsRepeating = false;
_autoCloseTimer.Tick += (_, _) =>
{
DispatcherQueue.TryEnqueue(() =>
if (!_disposed)
{
Close();
});
});
}
};
_autoCloseTimer.Start();
}
private void ConfigureWindow()
@@ -96,6 +103,15 @@ namespace PowerDisplay.PowerDisplayXAML
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_autoCloseTimer?.Stop();
_autoCloseTimer = null;
_dpiSuppressor?.Dispose();
}
}

View File

@@ -25,10 +25,18 @@ namespace PowerDisplay.ViewModels;
/// </summary>
public partial class MainViewModel
{
/// <summary>
/// Check if a value is within the valid range (inclusive).
/// </summary>
private static bool IsValueInRange(int value, int min, int max) => value >= min && value <= max;
private static void TryRestore(
List<Task> tasks,
int? savedValue,
bool isVisible,
int currentValue,
Func<int, Task> setter)
{
if (savedValue.HasValue && isVisible && savedValue.Value != currentValue)
{
tasks.Add(setter(savedValue.Value));
}
}
/// <summary>
/// Apply settings changes from Settings UI (IPC event handler entry point)
@@ -212,32 +220,16 @@ public partial class MainViewModel
}
// Apply brightness if included in profile
if (setting.Brightness.HasValue &&
IsValueInRange(setting.Brightness.Value, monitorVm.MinBrightness, monitorVm.MaxBrightness))
{
updateTasks.Add(monitorVm.SetBrightnessAsync(setting.Brightness.Value));
}
TryRestore(updateTasks, setting.Brightness, monitorVm.ShowBrightness, monitorVm.Brightness, monitorVm.SetBrightnessAsync);
// Apply contrast if supported and value provided
if (setting.Contrast.HasValue && monitorVm.ShowContrast &&
IsValueInRange(setting.Contrast.Value, monitorVm.MinContrast, monitorVm.MaxContrast))
{
updateTasks.Add(monitorVm.SetContrastAsync(setting.Contrast.Value));
}
TryRestore(updateTasks, setting.Contrast, monitorVm.ShowContrast, monitorVm.Contrast, monitorVm.SetContrastAsync);
// Apply volume if supported and value provided
if (setting.Volume.HasValue && monitorVm.ShowVolume &&
IsValueInRange(setting.Volume.Value, monitorVm.MinVolume, monitorVm.MaxVolume))
{
updateTasks.Add(monitorVm.SetVolumeAsync(setting.Volume.Value));
}
TryRestore(updateTasks, setting.Volume, monitorVm.ShowVolume, monitorVm.Volume, monitorVm.SetVolumeAsync);
// Apply color temperature if included in profile
if (setting.ColorTemperatureVcp.HasValue && setting.ColorTemperatureVcp.Value > 0 &&
monitorVm.ShowColorTemperature)
{
updateTasks.Add(monitorVm.SetColorTemperatureAsync(setting.ColorTemperatureVcp.Value));
}
TryRestore(updateTasks, setting.ColorTemperatureVcp, monitorVm.ShowColorTemperature, monitorVm.ColorTemperature, monitorVm.SetColorTemperatureAsync);
}
// Wait for all updates to complete
@@ -266,36 +258,12 @@ public partial class MainViewModel
continue;
}
// Restore brightness if different from current
if (IsValueInRange(savedState.Value.Brightness, monitorVm.MinBrightness, monitorVm.MaxBrightness) &&
savedState.Value.Brightness != monitorVm.Brightness)
{
updateTasks.Add(monitorVm.SetBrightnessAsync(savedState.Value.Brightness));
}
var (brightness, colorTemp, contrast, volume) = savedState.Value;
// Restore color temperature if different from current
if (monitorVm.ShowColorTemperature &&
savedState.Value.ColorTemperatureVcp > 0 &&
savedState.Value.ColorTemperatureVcp != monitorVm.ColorTemperature)
{
updateTasks.Add(monitorVm.SetColorTemperatureAsync(savedState.Value.ColorTemperatureVcp));
}
// Restore contrast if different from current
if (monitorVm.ShowContrast &&
IsValueInRange(savedState.Value.Contrast, monitorVm.MinContrast, monitorVm.MaxContrast) &&
savedState.Value.Contrast != monitorVm.Contrast)
{
updateTasks.Add(monitorVm.SetContrastAsync(savedState.Value.Contrast));
}
// Restore volume if different from current
if (monitorVm.ShowVolume &&
IsValueInRange(savedState.Value.Volume, monitorVm.MinVolume, monitorVm.MaxVolume) &&
savedState.Value.Volume != monitorVm.Volume)
{
updateTasks.Add(monitorVm.SetVolumeAsync(savedState.Value.Volume));
}
TryRestore(updateTasks, brightness, monitorVm.ShowBrightness, monitorVm.Brightness, monitorVm.SetBrightnessAsync);
TryRestore(updateTasks, colorTemp, monitorVm.ShowColorTemperature, monitorVm.ColorTemperature, monitorVm.SetColorTemperatureAsync);
TryRestore(updateTasks, contrast, monitorVm.ShowContrast, monitorVm.Contrast, monitorVm.SetContrastAsync);
TryRestore(updateTasks, volume, monitorVm.ShowVolume, monitorVm.Volume, monitorVm.SetVolumeAsync);
}
if (updateTasks.Count > 0)

View File

@@ -228,10 +228,13 @@ public partial class MainViewModel : ObservableObject, IDisposable
// Format display text: single number for normal mode, "1|2" for mirror mode
var displayText = string.Join("|", monitorNumbers);
// Create and position identify window
// Create and position identify window.
// Position before Activate so the window appears directly at the target
// location — avoiding a visible flicker from the default spawn position
// and skipping a WM_DPICHANGED round-trip when crossing DPI monitors.
var identifyWindow = new IdentifyWindow(displayText);
identifyWindow.Activate();
identifyWindow.PositionOnDisplay(displayArea);
identifyWindow.Activate();
windowsCreated++;
}
}

View File

@@ -41,6 +41,9 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
public partial bool IsAvailable { get; set; }
// Visibility settings (controlled by Settings UI)
[ObservableProperty]
public partial bool ShowBrightness { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasAdvancedControls))]
public partial bool ShowContrast { get; set; }
@@ -111,12 +114,32 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
await ApplyPropertyToHardwareAsync(nameof(Volume), volume, _monitorManager.SetVolumeAsync);
}
private bool IsDiscreteValueSupported(byte vcpCode, int value)
{
var vcpInfo = VcpCapabilitiesInfo;
if (vcpInfo == null ||
!vcpInfo.SupportedVcpCodes.TryGetValue(vcpCode, out var codeInfo) ||
!codeInfo.HasDiscreteValues ||
!codeInfo.SupportedValues.Contains(value))
{
Logger.LogWarning($"[{Id}] VCP 0x{vcpCode:X2} value 0x{value:X2} not in reported supported values, skipping");
return false;
}
return true;
}
/// <summary>
/// Unified method to apply color temperature with hardware update and state persistence.
/// Always immediate (no debouncing for discrete preset values).
/// </summary>
public async Task SetColorTemperatureAsync(int colorTemperature)
{
if (!IsDiscreteValueSupported(0x14, colorTemperature))
{
return;
}
try
{
var result = await _monitorManager.SetColorTemperatureAsync(Id, colorTemperature);
@@ -193,6 +216,7 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
_monitor.PropertyChanged += OnMonitorPropertyChanged;
// Initialize Show properties based on hardware capabilities
ShowBrightness = monitor.SupportsBrightness;
ShowContrast = monitor.SupportsContrast;
ShowVolume = monitor.SupportsVolume;
ShowInputSource = monitor.SupportsInputSource;
@@ -272,6 +296,8 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
/// </summary>
public bool SupportsContrast => _monitor.SupportsContrast;
public bool SupportsBrightness => _monitor.SupportsBrightness;
/// <summary>
/// Gets a value indicating whether this monitor supports volume control via VCP 0x62
/// </summary>
@@ -458,41 +484,23 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
}
/// <summary>
/// Standard MCCS color temperature presets (VCP 0x14 values) to use as fallback
/// when the monitor doesn't report discrete values in its capabilities string.
/// </summary>
private static readonly int[] StandardColorTemperaturePresets = { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x08, 0x09, 0x0A, 0x0B };
/// <summary>
/// Refresh the list of available color temperature presets based on monitor capabilities
/// Refresh the list of available color temperature presets based on monitor capabilities.
/// Only values explicitly reported in the capabilities string are exposed — no MCCS standard fallback.
/// </summary>
private void RefreshAvailableColorPresets()
{
if (!SupportsColorTemperature)
var vcpInfo = VcpCapabilitiesInfo;
if (!SupportsColorTemperature ||
vcpInfo == null ||
!vcpInfo.SupportedVcpCodes.TryGetValue(0x14, out var colorTempInfo) ||
!colorTempInfo.HasDiscreteValues)
{
_availableColorPresets = null;
OnPropertyChanged(nameof(AvailableColorPresets));
return;
}
IEnumerable<int> presetValues;
var vcpInfo = VcpCapabilitiesInfo;
// Try to get discrete values from capabilities string
if (vcpInfo != null &&
vcpInfo.SupportedVcpCodes.TryGetValue(0x14, out var colorTempInfo) &&
colorTempInfo.HasDiscreteValues &&
colorTempInfo.SupportedValues.Count > 0)
{
// Use values from capabilities string
presetValues = colorTempInfo.SupportedValues;
}
else
{
// Fallback to standard MCCS presets when capabilities don't list discrete values
presetValues = StandardColorTemperaturePresets;
}
_availableColorPresets = presetValues.Select(value => new ColorTemperatureItem
_availableColorPresets = colorTempInfo.SupportedValues.Select(value => new ColorTemperatureItem
{
VcpValue = value,
DisplayName = Common.Utils.VcpNames.GetValueName(0x14, value, _mainViewModel?.CustomVcpMappings, _monitor.Id) is string n ? $"{n} (0x{value:X2})" : $"0x{value:X2}",
@@ -584,6 +592,11 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
/// </summary>
public async Task SetInputSourceAsync(int inputSource)
{
if (!IsDiscreteValueSupported(0x60, inputSource))
{
return;
}
try
{
var result = await _monitorManager.SetInputSourceAsync(Id, inputSource);
@@ -670,6 +683,11 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
/// </summary>
public async Task SetPowerStateAsync(int powerState)
{
if (!IsDiscreteValueSupported(0xD6, powerState))
{
return;
}
try
{
var result = await _monitorManager.SetPowerStateAsync(Id, powerState);