2
0

refactor(ci): rename DellMonitorControl to MonitorControl
All checks were successful
Build / build (push) Successful in 9h0m26s
Build and Release / build (push) Successful in 8h0m15s

Rename project from DellMonitorControl to MonitorControl to reflect broader monitor compatibility beyond Dell hardware. Update all references in solution file, workflow, and project paths.

Add DDC/CI rate limiting and throttling logic to prevent command failures:
- Minimum 50ms interval between commands to same monitor
- 8-second grace period after system resume before sending commands
- 3-second cooldown after timeout to allow monitor recovery
- Global semaphore to prevent command collisions

Replace old icon with new generic monitor icon (ico and png formats).
This commit is contained in:
2026-01-29 18:14:58 -05:00
parent 3c6cc15281
commit 404e6f42da
24 changed files with 262 additions and 53 deletions

12
MonitorControl/App.xaml Normal file
View File

@@ -0,0 +1,12 @@
<Application x:Class="MonitorControl.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:tb="clr-namespace:Hardcodet.Wpf.TaskbarNotification;assembly=Hardcodet.Wpf.TaskbarNotification.Net6"
xmlns:local="clr-namespace:MonitorControl"
Startup="Application_Startup">
<Application.Resources>
<tb:TaskbarIcon x:Key="TrayIcon"
IconSource="/MonitorIcon.ico"
ToolTipText="Monitor Control"/>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,85 @@
using CMM.Library.Method;
using Hardcodet.Wpf.TaskbarNotification;
using Microsoft.Win32;
using System.Windows;
namespace MonitorControl
{
public partial class App : Application
{
private TaskbarIcon? _trayIcon;
private MainWindow? _mainWindow;
private UpdateInfo? _pendingUpdate;
private async void Application_Startup(object sender, StartupEventArgs e)
{
_trayIcon = (TaskbarIcon)FindResource("TrayIcon");
_trayIcon.TrayLeftMouseUp += TrayIcon_Click;
_trayIcon.TrayBalloonTipClicked += TrayIcon_BalloonTipClicked;
_mainWindow = new MainWindow();
// Listen for system sleep/resume to protect monitors during wake
SystemEvents.PowerModeChanged += OnPowerModeChanged;
// Check for updates in background
await CheckForUpdatesAsync();
}
private void OnPowerModeChanged(object sender, PowerModeChangedEventArgs e)
{
if (e.Mode == PowerModes.Resume)
{
// Notify the DDC/CI layer that monitors need time to wake up
CMMCommand.NotifySystemResumed();
DebugLogger.Log("System resumed from sleep - DDC/CI grace period activated");
}
}
private async System.Threading.Tasks.Task CheckForUpdatesAsync()
{
try
{
_pendingUpdate = await UpdateChecker.CheckForUpdateAsync();
if (_pendingUpdate != null && _trayIcon != null)
{
_trayIcon.ShowBalloonTip(
"Update Available",
$"Monitor Control v{_pendingUpdate.LatestVersion} is available.\nClick to download.",
BalloonIcon.Info);
}
}
catch { }
}
private void TrayIcon_BalloonTipClicked(object sender, RoutedEventArgs e)
{
if (_pendingUpdate != null && !string.IsNullOrEmpty(_pendingUpdate.DownloadUrl))
{
UpdateChecker.OpenDownloadPage(_pendingUpdate.DownloadUrl);
}
}
private async void TrayIcon_Click(object sender, RoutedEventArgs e)
{
if (_mainWindow == null) return;
if (_mainWindow.IsVisible)
{
_mainWindow.Hide();
}
else
{
_mainWindow.ShowNearTray();
await _mainWindow.LoadMonitors();
}
}
protected override void OnExit(ExitEventArgs e)
{
SystemEvents.PowerModeChanged -= OnPowerModeChanged;
_trayIcon?.Dispose();
base.OnExit(e);
}
}
}

View File

@@ -0,0 +1,10 @@
using System.Windows;
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

View File

@@ -0,0 +1,118 @@
<Window x:Class="MonitorControl.ConfigWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Port Configuration"
Width="400" Height="450"
WindowStartupLocation="CenterScreen"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
ResizeMode="NoResize">
<Window.Resources>
<!-- Dark Button Style -->
<Style x:Key="DarkButton" TargetType="Button">
<Setter Property="Background" Value="#464646"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderBrush" Value="#646464"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}"
CornerRadius="3">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#0056A0"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="#004080"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Primary Button Style (Save) -->
<Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="Background" Value="#0078D4"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderBrush" Value="#0078D4"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}"
CornerRadius="3">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#0056A0"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="#004080"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Border Background="#F0333333" CornerRadius="8" BorderBrush="#555" BorderThickness="1" Margin="5">
<Border.Effect>
<DropShadowEffect BlurRadius="10" ShadowDepth="2" Opacity="0.5"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Header -->
<Border Grid.Row="0" Background="#444" CornerRadius="8,8,0,0" Padding="12,10">
<TextBlock Name="tbHeader" Text="Configure Ports" Foreground="White" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center"/>
</Border>
<!-- Port List -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="12">
<StackPanel Name="spPorts"/>
</ScrollViewer>
<!-- Footer Buttons -->
<Border Grid.Row="2" Background="#3A3A3A" CornerRadius="0,0,8,8" Padding="12,10">
<Grid>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<Button Content="Reset" Width="70" Margin="0,0,8,0" Click="ResetButton_Click"
Style="{StaticResource DarkButton}" ToolTip="Clear all custom labels and unhide all ports"/>
<Button Name="btnDetect" Content="Detect" Width="70" Click="DetectButton_Click"
Style="{StaticResource DarkButton}" ToolTip="Try to detect available input ports"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Cancel" Width="80" Margin="0,0,8,0" Click="CancelButton_Click"
Style="{StaticResource DarkButton}"/>
<Button Content="Save" Width="80" Click="SaveButton_Click"
Style="{StaticResource PrimaryButton}"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,344 @@
using CMM.Library.Config;
using CMM.Library.Method;
using CMM.Library.ViewModel;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace MonitorControl;
public partial class ConfigWindow : Window
{
private readonly string _serialNumber;
private readonly string _monitorName;
private readonly List<InputSourceOption> _availablePorts;
private readonly List<PortConfigRow> _portRows = new();
private CancellationTokenSource? _detectCts;
public bool ConfigChanged { get; private set; }
public ConfigWindow(string serialNumber, string monitorName, List<InputSourceOption> availablePorts)
{
InitializeComponent();
_serialNumber = serialNumber;
_monitorName = monitorName;
_availablePorts = availablePorts;
tbHeader.Text = $"Configure: {monitorName}";
LoadPortConfiguration();
}
private void LoadPortConfiguration()
{
var config = MonitorConfigManager.GetMonitorConfig(_serialNumber);
spPorts.Children.Clear();
_portRows.Clear();
// Show unsupported message if no ports available
if (_availablePorts == null || _availablePorts.Count == 0)
{
spPorts.Children.Add(new TextBlock
{
Text = "Unsupported",
Foreground = Brushes.Gray,
FontSize = 16,
FontStyle = FontStyles.Italic,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 40, 0, 40)
});
return;
}
foreach (var port in _availablePorts)
{
var existingPortConfig = config.Ports.FirstOrDefault(p => p.VcpValue == port.Value);
var row = new PortConfigRow
{
VcpValue = port.Value,
DefaultName = port.Name,
CustomLabel = existingPortConfig?.CustomLabel ?? "",
IsHidden = existingPortConfig?.IsHidden ?? false,
ShowInQuickSwitch = existingPortConfig?.ShowInQuickSwitch ?? false
};
_portRows.Add(row);
spPorts.Children.Add(CreatePortRow(row));
}
}
private UIElement CreatePortRow(PortConfigRow row)
{
var container = new Border
{
Background = new SolidColorBrush(Color.FromRgb(60, 60, 60)),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(10),
Margin = new Thickness(0, 0, 0, 8)
};
var grid = new Grid();
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
// Row 1: Port name and Hide checkbox
var row1 = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
row1.Children.Add(new TextBlock
{
Text = row.DefaultName,
Foreground = Brushes.White,
FontWeight = FontWeights.SemiBold,
FontSize = 13,
Width = 120,
VerticalAlignment = VerticalAlignment.Center
});
var hideCheck = new CheckBox
{
Content = "Hide",
IsChecked = row.IsHidden,
Foreground = Brushes.LightGray,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(10, 0, 0, 0)
};
hideCheck.Checked += (s, e) => row.IsHidden = true;
hideCheck.Unchecked += (s, e) => row.IsHidden = false;
row1.Children.Add(hideCheck);
Grid.SetRow(row1, 0);
grid.Children.Add(row1);
// Row 2: Custom label
var row2 = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
row2.Children.Add(new TextBlock
{
Text = "Label:",
Foreground = Brushes.LightGray,
FontSize = 12,
Width = 50,
VerticalAlignment = VerticalAlignment.Center
});
var labelBox = new TextBox
{
Text = row.CustomLabel,
Width = 200,
Background = new SolidColorBrush(Color.FromRgb(50, 50, 50)),
Foreground = Brushes.White,
BorderBrush = new SolidColorBrush(Color.FromRgb(80, 80, 80)),
Padding = new Thickness(6, 4, 6, 4)
};
labelBox.TextChanged += (s, e) => row.CustomLabel = labelBox.Text;
row2.Children.Add(labelBox);
Grid.SetRow(row2, 1);
grid.Children.Add(row2);
// Row 3: Quick switch checkbox
var quickSwitchCheck = new CheckBox
{
Content = "Show in quick-switch toolbar",
IsChecked = row.ShowInQuickSwitch,
Foreground = Brushes.LightGray,
FontSize = 12
};
quickSwitchCheck.Checked += (s, e) => row.ShowInQuickSwitch = true;
quickSwitchCheck.Unchecked += (s, e) => row.ShowInQuickSwitch = false;
Grid.SetRow(quickSwitchCheck, 2);
grid.Children.Add(quickSwitchCheck);
container.Child = grid;
return container;
}
private void SaveButton_Click(object sender, RoutedEventArgs e)
{
var config = new MonitorConfig
{
SerialNumber = _serialNumber,
MonitorName = _monitorName,
Ports = _portRows.Select(r => new PortConfig
{
VcpValue = r.VcpValue,
DefaultName = r.DefaultName,
CustomLabel = r.CustomLabel,
IsHidden = r.IsHidden,
ShowInQuickSwitch = r.ShowInQuickSwitch
}).ToList()
};
MonitorConfigManager.SaveMonitorConfig(config);
ConfigChanged = true;
Close();
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
Close();
}
private void ResetButton_Click(object sender, RoutedEventArgs e)
{
// Reset all port rows to default values
foreach (var row in _portRows)
{
row.CustomLabel = "";
row.IsHidden = false;
row.ShowInQuickSwitch = false;
}
// Reload the UI to reflect changes
LoadPortConfiguration();
// Clear any discovered ports from config
var config = MonitorConfigManager.GetMonitorConfig(_serialNumber);
config.Ports.Clear();
MonitorConfigManager.SaveMonitorConfig(config);
MonitorConfigManager.ClearCache();
}
private async void DetectButton_Click(object sender, RoutedEventArgs e)
{
// If detection is already running, cancel it
if (_detectCts != null)
{
_detectCts.Cancel();
return;
}
// Warn the user about what this does
var confirm = MessageBox.Show(
"Port detection will temporarily switch your monitor's input source to probe for available ports. " +
"Your screen may go dark briefly during each probe.\n\n" +
"This is safe for most monitors, but avoid running it repeatedly in a short time.\n\n" +
"You can click the Cancel button to stop detection at any time.\n\n" +
"Continue?",
"Port Detection", MessageBoxButton.YesNo, MessageBoxImage.Warning);
if (confirm != MessageBoxResult.Yes)
return;
_detectCts = new CancellationTokenSource();
var ct = _detectCts.Token;
btnDetect.Content = "Cancel";
try
{
// Only probe modern ports (DP/HDMI) by default — legacy ports (VGA/DVI) are rarely used
// and probing them on monitors without those connectors wastes time and stresses the scaler
var commonPorts = new[] { 15, 16, 17, 18 };
var detectedPorts = new List<int>();
// Get current input so we can restore it
var currentInput = await CMMCommand.GetInputSource(_serialNumber);
foreach (var vcpValue in commonPorts)
{
ct.ThrowIfCancellationRequested();
// Skip ports we already know about
if (_availablePorts.Any(p => p.Value == vcpValue))
continue;
try
{
// Try to set the input - if it succeeds, the port exists
await CMMCommand.SetInputSource(_serialNumber, vcpValue);
// Give the monitor's scaler time to attempt signal lock on the new input.
// 3 seconds is the minimum safe settle time for most scalers.
await Task.Delay(3000, ct);
// Check if the input actually changed
var newInput = await CMMCommand.GetInputSource(_serialNumber);
if (newInput == vcpValue)
{
detectedPorts.Add(vcpValue);
}
}
catch (OperationCanceledException) { throw; }
catch
{
// Port doesn't exist or isn't supported — continue to next
}
// Extra settle time between probes to let the scaler fully recover
try { await Task.Delay(1000, ct); }
catch (OperationCanceledException) { throw; }
}
// Restore original input — give it extra time to settle after all the switching
if (currentInput.HasValue)
{
await CMMCommand.SetInputSource(_serialNumber, currentInput.Value);
await Task.Delay(2000);
}
if (detectedPorts.Count > 0)
{
foreach (var vcpValue in detectedPorts)
{
var name = CMMCommand.GetInputSourceName(vcpValue);
_availablePorts.Add(new InputSourceOption(vcpValue, name));
MonitorConfigManager.AddDiscoveredPort(_serialNumber, _monitorName, vcpValue, name);
}
MonitorConfigManager.ClearCache();
LoadPortConfiguration();
MessageBox.Show($"Detected {detectedPorts.Count} new port(s):\n" +
string.Join("\n", detectedPorts.Select(v => $" • {CMMCommand.GetInputSourceName(v)}")),
"Detection Complete", MessageBoxButton.OK, MessageBoxImage.Information);
}
else
{
MessageBox.Show("No additional ports were detected.", "Detection Complete",
MessageBoxButton.OK, MessageBoxImage.Information);
}
}
catch (OperationCanceledException)
{
// User cancelled — try to restore original input before exiting
try
{
var currentInput = await CMMCommand.GetInputSource(_serialNumber);
// Only restore if we can still talk to the monitor
if (currentInput.HasValue)
await CMMCommand.SetInputSource(_serialNumber, currentInput.Value);
}
catch { }
MessageBox.Show("Port detection was cancelled.", "Detection Cancelled",
MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
MessageBox.Show($"Detection failed: {ex.Message}", "Error",
MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
_detectCts?.Dispose();
_detectCts = null;
btnDetect.Content = "Detect";
}
}
private class PortConfigRow
{
public int VcpValue { get; set; }
public string DefaultName { get; set; } = "";
public string CustomLabel { get; set; } = "";
public bool IsHidden { get; set; }
public bool ShowInQuickSwitch { get; set; }
}
}

View File

@@ -0,0 +1,14 @@
<UserControl x:Class="MonitorControl.ControlPanel"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
mc:Ignorable="d"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
Width="280" Height="300">
<Grid Background="#333333">
<StackPanel Name="sp" Margin="10">
<TextBlock Text="Monitor Control" Foreground="White" FontSize="16" FontWeight="Bold"/>
<TextBlock Text="Loading monitors..." Foreground="LightGray" FontSize="12" Margin="0,10,0,0"/>
</StackPanel>
</Grid>
</UserControl>

View File

@@ -0,0 +1,492 @@
using CMM.Language;
using CMM.Library.Method;
using CMM.Library.ViewModel;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace MonitorControl;
/// <summary>
/// Interaction logic for ControlPanel.xaml
/// </summary>
public partial class ControlPanel : UserControl
{
public ControlPanel()
{
InitializeComponent();
}
public async Task Refresh()
{
try
{
sp.Children.Clear();
await CMMCommand.ScanMonitor();
var monitors = (await CMMCommand.ReadMonitorsData()).ToList();
if (!monitors.Any())
{
sp.Children.Add(new TextBlock
{
Text = "No DDC/CI monitors detected",
Foreground = System.Windows.Media.Brushes.White,
FontSize = 14
});
return;
}
foreach (var m in monitors)
{
var brightness = await CMMCommand.GetBrightness(m.SerialNumber) ?? 50;
var contrast = await CMMCommand.GetContrast(m.SerialNumber) ?? 50;
var inputSource = await CMMCommand.GetInputSource(m.SerialNumber);
var inputOptions = await CMMCommand.GetInputSourceOptions(m.SerialNumber);
var powerStatus = await CMMCommand.GetMonPowerStatus(m.SerialNumber) ?? "Unknown";
// Monitor name header
sp.Children.Add(new TextBlock
{
Text = m.MonitorName,
Foreground = System.Windows.Media.Brushes.White,
FontSize = 14,
FontWeight = FontWeights.Bold,
Margin = new Thickness(0, 10, 0, 5)
});
// Brightness row
var brightnessRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 3) };
brightnessRow.Children.Add(new TextBlock { Text = "Brightness", Foreground = System.Windows.Media.Brushes.LightGray, Width = 70, FontSize = 12, VerticalAlignment = VerticalAlignment.Center });
var bSlider = new Slider { Minimum = 0, Maximum = 100, Value = brightness, Width = 140, VerticalAlignment = VerticalAlignment.Center, Tag = m.SerialNumber };
var bValue = new TextBlock { Text = brightness.ToString(), Foreground = System.Windows.Media.Brushes.White, Width = 30, FontSize = 12, Margin = new Thickness(5, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center };
bSlider.ValueChanged += (s, e) => bValue.Text = ((int)e.NewValue).ToString();
bSlider.PreviewMouseUp += async (s, e) => { if (s is Slider sl && sl.Tag is string sn) await CMMCommand.SetBrightness(sn, (int)sl.Value); };
brightnessRow.Children.Add(bSlider);
brightnessRow.Children.Add(bValue);
sp.Children.Add(brightnessRow);
// Contrast row
var contrastRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 3) };
contrastRow.Children.Add(new TextBlock { Text = "Contrast", Foreground = System.Windows.Media.Brushes.LightGray, Width = 70, FontSize = 12, VerticalAlignment = VerticalAlignment.Center });
var cSlider = new Slider { Minimum = 0, Maximum = 100, Value = contrast, Width = 140, VerticalAlignment = VerticalAlignment.Center, Tag = m.SerialNumber };
var cValue = new TextBlock { Text = contrast.ToString(), Foreground = System.Windows.Media.Brushes.White, Width = 30, FontSize = 12, Margin = new Thickness(5, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center };
cSlider.ValueChanged += (s, e) => cValue.Text = ((int)e.NewValue).ToString();
cSlider.PreviewMouseUp += async (s, e) => { if (s is Slider sl && sl.Tag is string sn) await CMMCommand.SetContrast(sn, (int)sl.Value); };
contrastRow.Children.Add(cSlider);
contrastRow.Children.Add(cValue);
sp.Children.Add(contrastRow);
// Input source row (if options available)
if (inputOptions.Count > 0)
{
var inputRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 3) };
inputRow.Children.Add(new TextBlock { Text = "Input", Foreground = System.Windows.Media.Brushes.LightGray, Width = 70, FontSize = 12, VerticalAlignment = VerticalAlignment.Center });
var combo = new ComboBox { Width = 170, ItemsSource = inputOptions, DisplayMemberPath = "Name", Tag = m.SerialNumber };
if (inputSource.HasValue) combo.SelectedItem = inputOptions.Find(o => o.Value == inputSource.Value);
combo.SelectionChanged += async (s, e) => { if (s is ComboBox cb && cb.Tag is string sn && cb.SelectedItem is InputSourceOption opt) await CMMCommand.SetInputSource(sn, opt.Value); };
inputRow.Children.Add(combo);
sp.Children.Add(inputRow);
}
// Power row
var powerRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 3) };
powerRow.Children.Add(new TextBlock { Text = "Power", Foreground = System.Windows.Media.Brushes.LightGray, Width = 70, FontSize = 12, VerticalAlignment = VerticalAlignment.Center });
var powerBtn = new Button { Content = powerStatus, Width = 170, Tag = m.SerialNumber };
powerBtn.Click += async (s, e) =>
{
if (s is Button btn && btn.Tag is string sn)
{
var status = await CMMCommand.GetMonPowerStatus(sn);
if (status == "Sleep" || status == "PowerOff") await CMMCommand.PowerOn(sn);
else await CMMCommand.Sleep(sn);
await Task.Delay(1000);
btn.Content = await CMMCommand.GetMonPowerStatus(sn);
}
};
powerRow.Children.Add(powerBtn);
sp.Children.Add(powerRow);
}
}
catch (Exception ex)
{
sp.Children.Clear();
sp.Children.Add(new TextBlock
{
Text = $"Error: {ex.Message}",
Foreground = System.Windows.Media.Brushes.Red,
FontSize = 12,
TextWrapping = TextWrapping.Wrap
});
}
}
private StackPanel CreateControlSimple(
XMonitor monitorModel,
int? brightness,
int? contrast,
int? currentInputSource,
List<InputSourceOption> inputOptions,
string powerStatus)
{
var container = new StackPanel
{
Orientation = Orientation.Vertical,
Margin = new Thickness(0, 5, 0, 10)
};
// Monitor name
container.Children.Add(new TextBlock
{
Text = $"{monitorModel.MonitorName} ({monitorModel.SerialNumber})",
Foreground = System.Windows.Media.Brushes.White,
FontSize = 14,
FontWeight = FontWeights.Bold,
Margin = new Thickness(0, 0, 0, 5)
});
// Brightness
var brightnessPanel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 2) };
brightnessPanel.Children.Add(new TextBlock { Text = "Brightness:", Foreground = System.Windows.Media.Brushes.LightGray, Width = 80, FontSize = 12 });
var brightnessSlider = new Slider { Minimum = 0, Maximum = 100, Value = brightness ?? 50, Width = 150, Tag = monitorModel.SerialNumber };
var brightnessValue = new TextBlock { Text = (brightness ?? 50).ToString(), Foreground = System.Windows.Media.Brushes.White, Width = 30, FontSize = 12, Margin = new Thickness(5, 0, 0, 0) };
brightnessSlider.ValueChanged += (s, e) => brightnessValue.Text = ((int)e.NewValue).ToString();
brightnessSlider.PreviewMouseUp += async (s, e) =>
{
var sld = s as Slider;
if (sld?.Tag is string sn)
await CMMCommand.SetBrightness(sn, (int)sld.Value);
};
brightnessPanel.Children.Add(brightnessSlider);
brightnessPanel.Children.Add(brightnessValue);
container.Children.Add(brightnessPanel);
// Contrast
var contrastPanel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 2) };
contrastPanel.Children.Add(new TextBlock { Text = "Contrast:", Foreground = System.Windows.Media.Brushes.LightGray, Width = 80, FontSize = 12 });
var contrastSlider = new Slider { Minimum = 0, Maximum = 100, Value = contrast ?? 50, Width = 150, Tag = monitorModel.SerialNumber };
var contrastValue = new TextBlock { Text = (contrast ?? 50).ToString(), Foreground = System.Windows.Media.Brushes.White, Width = 30, FontSize = 12, Margin = new Thickness(5, 0, 0, 0) };
contrastSlider.ValueChanged += (s, e) => contrastValue.Text = ((int)e.NewValue).ToString();
contrastSlider.PreviewMouseUp += async (s, e) =>
{
var sld = s as Slider;
if (sld?.Tag is string sn)
await CMMCommand.SetContrast(sn, (int)sld.Value);
};
contrastPanel.Children.Add(contrastSlider);
contrastPanel.Children.Add(contrastValue);
container.Children.Add(contrastPanel);
// Input source (if available)
if (inputOptions.Count > 0)
{
var inputPanel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 2) };
inputPanel.Children.Add(new TextBlock { Text = "Input:", Foreground = System.Windows.Media.Brushes.LightGray, Width = 80, FontSize = 12 });
var combo = new ComboBox { Width = 150, ItemsSource = inputOptions, DisplayMemberPath = "Name", Tag = monitorModel.SerialNumber };
if (currentInputSource.HasValue)
combo.SelectedItem = inputOptions.Find(o => o.Value == currentInputSource.Value);
combo.SelectionChanged += async (s, e) =>
{
var cb = s as ComboBox;
if (cb?.Tag is string sn && cb.SelectedItem is InputSourceOption opt)
await CMMCommand.SetInputSource(sn, opt.Value);
};
inputPanel.Children.Add(combo);
container.Children.Add(inputPanel);
}
// Power
var powerPanel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 2) };
powerPanel.Children.Add(new TextBlock { Text = "Power:", Foreground = System.Windows.Media.Brushes.LightGray, Width = 80, FontSize = 12 });
var powerBtn = new Button { Content = powerStatus, Width = 150, Tag = monitorModel.SerialNumber };
powerBtn.Click += async (s, e) =>
{
var btn = s as Button;
if (btn?.Tag is string sn)
{
var status = await CMMCommand.GetMonPowerStatus(sn);
if (status == "Sleep" || status == "PowerOff")
await CMMCommand.PowerOn(sn);
else
await CMMCommand.Sleep(sn);
await Task.Delay(1000);
btn.Content = await CMMCommand.GetMonPowerStatus(sn);
}
};
powerPanel.Children.Add(powerBtn);
container.Children.Add(powerPanel);
return container;
}
private StackPanel CreateControl(
XMonitor monitorModel,
int? brightness,
int? contrast,
int? currentInputSource,
List<InputSourceOption> inputOptions,
string powerStatus)
{
var container = new StackPanel
{
Orientation = Orientation.Vertical,
Margin = new Thickness(10, 5, 10, 10)
};
// Monitor name header
var header = new TextBlock
{
Text = $"{monitorModel.MonitorName} ({monitorModel.SerialNumber})",
HorizontalAlignment = HorizontalAlignment.Left,
Style = (Style)FindResource("LableStyle"),
Margin = new Thickness(0, 0, 0, 5)
};
container.Children.Add(header);
// Brightness slider
var brightnessPanel = CreateSliderControl(
Lang.Find("Brightness"),
brightness ?? 50,
monitorModel.SerialNumber,
async (sn, value) => await CMMCommand.SetBrightness(sn, value));
container.Children.Add(brightnessPanel);
// Contrast slider
var contrastPanel = CreateSliderControl(
Lang.Find("Contrast"),
contrast ?? 50,
monitorModel.SerialNumber,
async (sn, value) => await CMMCommand.SetContrast(sn, value));
container.Children.Add(contrastPanel);
// Input source dropdown
if (inputOptions.Count > 0)
{
var inputPanel = CreateInputSourceControl(
monitorModel.SerialNumber,
currentInputSource,
inputOptions);
container.Children.Add(inputPanel);
}
// Power button
var powerPanel = CreatePowerControl(monitorModel.SerialNumber, powerStatus);
container.Children.Add(powerPanel);
return container;
}
private StackPanel CreateSliderControl(
string label,
int currentValue,
string monitorSN,
Func<string, int, Task> onValueChanged)
{
var panel = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 3, 0, 3)
};
var labelBlock = new TextBlock
{
Text = label,
Width = 80,
VerticalAlignment = VerticalAlignment.Center,
Style = (Style)FindResource("LableStyle")
};
var slider = new Slider
{
Style = (Style)FindResource("Horizontal_Slider"),
Minimum = 0,
Maximum = 100,
Value = currentValue,
Tag = monitorSN,
Width = 180
};
var valueBlock = new TextBlock
{
Text = currentValue.ToString(),
Width = 35,
VerticalAlignment = VerticalAlignment.Center,
Style = (Style)FindResource("LableStyle"),
TextAlignment = TextAlignment.Right
};
// Update value display while dragging
slider.ValueChanged += (s, e) =>
{
valueBlock.Text = ((int)e.NewValue).ToString();
};
// Only send command on release (PreviewMouseUp)
slider.PreviewMouseUp += async (s, e) =>
{
var sld = s as Slider;
var sn = sld?.Tag?.ToString();
if (!string.IsNullOrEmpty(sn) && sld != null)
{
await onValueChanged(sn, (int)sld.Value);
}
};
panel.Children.Add(labelBlock);
panel.Children.Add(slider);
panel.Children.Add(valueBlock);
return panel;
}
private StackPanel CreateInputSourceControl(
string monitorSN,
int? currentInputSource,
List<InputSourceOption> options)
{
var panel = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 3, 0, 3)
};
var labelBlock = new TextBlock
{
Text = Lang.Find("InputSource"),
Width = 80,
VerticalAlignment = VerticalAlignment.Center,
Style = (Style)FindResource("LableStyle")
};
var comboBox = new ComboBox
{
Width = 180,
Tag = monitorSN,
ItemsSource = options,
DisplayMemberPath = "Name"
};
// Set current selection
if (currentInputSource.HasValue)
{
var currentOption = options.Find(o => o.Value == currentInputSource.Value);
if (currentOption != null)
{
comboBox.SelectedItem = currentOption;
}
}
comboBox.SelectionChanged += async (s, e) =>
{
var cb = s as ComboBox;
var sn = cb?.Tag?.ToString();
var selectedOption = cb?.SelectedItem as InputSourceOption;
if (!string.IsNullOrEmpty(sn) && selectedOption != null)
{
await CMMCommand.SetInputSource(sn, selectedOption.Value);
}
};
panel.Children.Add(labelBlock);
panel.Children.Add(comboBox);
return panel;
}
private StackPanel CreatePowerControl(string monitorSN, string powerStatus)
{
var panel = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 5, 0, 0)
};
var labelBlock = new TextBlock
{
Text = Lang.Find("Power"),
Width = 80,
VerticalAlignment = VerticalAlignment.Center,
Style = (Style)FindResource("LableStyle")
};
var btn = new Button
{
Tag = monitorSN,
Content = GetLocalizedPowerStatus(powerStatus),
Style = (Style)FindResource("TextButtonStyle"),
Width = 180
};
btn.Click += async (s, e) => await TogglePower(s, e);
panel.Children.Add(labelBlock);
panel.Children.Add(btn);
return panel;
}
private string GetLocalizedPowerStatus(string status)
{
return status switch
{
"PowerOn" => Lang.Find("PowerOn"),
"Sleep" => Lang.Find("Sleep"),
"PowerOff" => Lang.Find("PowerOff"),
_ => status
};
}
private async Task TogglePower(object sender, RoutedEventArgs e)
{
var btn = sender as Button;
var tag = btn?.Tag?.ToString();
if (string.IsNullOrEmpty(tag)) return;
var currentStatus = await CMMCommand.GetMonPowerStatus(tag);
if (currentStatus == "Sleep" || currentStatus == "PowerOff")
{
await CMMCommand.PowerOn(tag);
}
else
{
await CMMCommand.Sleep(tag);
}
await Task.Delay(1000);
var newStatus = await CMMCommand.GetMonPowerStatus(tag);
btn!.Content = GetLocalizedPowerStatus(newStatus);
}
private void Border_MouseLeave(object sender, MouseEventArgs e)
{
}
private void UserControl_Loaded(object sender, RoutedEventArgs e)
{
// Use Dispatcher to let the popup fully render first
Dispatcher.BeginInvoke(new Action(async () =>
{
try
{
await Refresh();
}
catch (Exception ex)
{
sp.Children.Clear();
sp.Children.Add(new TextBlock
{
Text = $"Error: {ex.Message}",
Foreground = System.Windows.Media.Brushes.Red,
FontSize = 12,
TextWrapping = TextWrapping.Wrap
});
}
}), System.Windows.Threading.DispatcherPriority.Background);
}
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace MonitorControl;
public static class DebugLogger
{
private static readonly List<string> _logs = new();
private static readonly object _lock = new();
private static readonly string _logFilePath;
static DebugLogger()
{
var tempPath = Path.Combine(Path.GetTempPath(), "CMM");
Directory.CreateDirectory(tempPath);
_logFilePath = Path.Combine(tempPath, "debug.log");
}
public static void Log(string message)
{
var entry = $"[{DateTime.Now:HH:mm:ss.fff}] {message}";
lock (_lock)
{
_logs.Add(entry);
try
{
File.AppendAllText(_logFilePath, entry + Environment.NewLine);
}
catch { }
}
}
public static void LogError(string message, Exception? ex = null)
{
var entry = ex != null
? $"[{DateTime.Now:HH:mm:ss.fff}] ERROR: {message} - {ex.GetType().Name}: {ex.Message}"
: $"[{DateTime.Now:HH:mm:ss.fff}] ERROR: {message}";
lock (_lock)
{
_logs.Add(entry);
if (ex != null)
_logs.Add($" Stack: {ex.StackTrace}");
try
{
File.AppendAllText(_logFilePath, entry + Environment.NewLine);
if (ex != null)
File.AppendAllText(_logFilePath, $" Stack: {ex.StackTrace}" + Environment.NewLine);
}
catch { }
}
}
public static string GetLogs()
{
lock (_lock)
{
return string.Join(Environment.NewLine, _logs);
}
}
public static void Clear()
{
lock (_lock)
{
_logs.Clear();
try
{
File.WriteAllText(_logFilePath, "");
}
catch { }
}
}
public static string LogFilePath => _logFilePath;
}

View File

@@ -0,0 +1,24 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style x:Key="PathFill" TargetType="Path">
<Setter Property="Stretch" Value="Uniform"/>
<Setter Property="Fill" Value="{DynamicResource F3}"/>
</Style>
<!--Path Minimized-->
<Style x:Key="PathMinimized" TargetType="Path" BasedOn="{StaticResource PathFill}">
<Setter Property="Data" Value="F1 M512,512z M0,0z M107.38,241.56C108.86,241.12 110.41,241.03 111.95,241 207.98,241 304.01,241 400.05,241 407.85,240.59 415.24,247.15 415.52,255.01 416.16,261.91 411.26,268.64 404.62,270.43 401.48,271.29 398.2,270.94 395,271.01 300.65,270.99 206.3,271.01 111.95,271 104.15,271.4 96.76,264.84 96.48,256.99 95.83,250.08 100.74,243.35 107.38,241.56z"/>
</Style>
<!--Path Frame-->
<Style x:Key="PathFrame" TargetType="Path" BasedOn="{StaticResource PathFill}">
<Setter Property="Data" Value="F1 M512,512z M0,0z M94.22,0L417.79,0C441.21,0.19 464.38,9.46 481.47,25.48 500.23,42.75 511.5,67.76 512,93.26L512,420.97C510.85,442.12 502.78,462.87 488.82,478.86 471.32,499.41 444.76,511.82 417.76,512L94.27,512C73.21,511.75 52.27,504.36 35.88,491.09 14,473.85 0.5,446.62 0,418.76L0,93.25C0.49,68.71 10.9,44.57 28.49,27.46 45.77,10.27 69.85,0.21 94.22,0 M85.61,30.72C68.33,33.24 52.3,43.14 42.24,57.39 34.1,68.48 29.86,82.28 30,96 30,202.67 30,309.34 30,416.01 29.89,427.61 32.9,439.23 38.74,449.26 48.08,465.41 64.52,477.36 82.9,480.79 90.5,482.38 98.29,481.94 105.99,482 209.31,482 312.63,482 415.95,482 422.69,482.03 429.44,481.08 435.89,479.11 457.54,472.56 475.05,454 480.17,431.95 482.47,423.13 481.96,413.95 482,404.92 481.99,301.59 482.02,198.26 481.99,94.93 481.99,76.98 474.07,59.28 460.78,47.24 449.05,36.27 433.08,30.04 417.03,30.01 310.01,29.99 202.99,30.01 95.97,30 92.5,30 89.03,30.22 85.61,30.72z"/>
</Style>
<!--Path Cancel-->
<Style x:Key="PathCancel" TargetType="Path" BasedOn="{StaticResource PathFill}">
<Setter Property="Data" Value="F1 M512,512z M0,0z M54.26,54.35C61.42,47.02 73.03,44.46 82.6,48.18 87.07,49.61 90.87,52.58 94.06,55.96 148.04,109.95 202.04,163.92 256,217.93 310.65,163.25 365.32,108.6 419.98,53.94 427.23,47.02 438.47,44.53 447.88,48.13 461.06,52.46 469.23,68.21 464.66,81.42 463.27,86.72 459.86,91.21 455.92,94.91 414.26,136.58 372.59,178.24 330.93,219.92 318.68,231.96 306.81,244.43 294.33,256.22 347.56,309.47 400.81,362.7 454.05,415.95 457.08,419.02 460.43,421.95 462.46,425.83 465.45,431.73 467.03,438.67 465.14,445.17 463.2,453.74 456.84,461.18 448.63,464.31 442.05,466.87 434.34,466.81 427.88,463.9 421.38,461.28 416.98,455.6 412.07,450.88 360.24,399.05 308.37,347.23 256.58,295.37 255.19,294.59 254.35,296.7 253.42,297.41 201.92,348.91 150.42,400.42 98.92,451.92 94.31,456.42 90.02,461.67 83.83,464.05 74.13,468.23 61.94,466.09 54.47,458.53 49.1,453.57 46.23,446.25 46.02,439.02 46.26,432.03 48.92,424.97 53.92,419.99 108.51,365.39 163.14,310.84 217.68,256.2 202.85,242.07 188.64,227.26 174.05,212.87 134.04,172.88 94.06,132.85 54.04,92.87 48.91,87.63 46.02,80.34 46.03,73.02 46.43,66.1 49.16,59.16 54.26,54.35z"/>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,243 @@
<Window x:Class="MonitorControl.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Monitor Control"
Width="300" MinHeight="200" MaxHeight="500"
SizeToContent="Height"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
ResizeMode="NoResize"
ShowInTaskbar="False"
Topmost="True"
Deactivated="Window_Deactivated">
<Window.Resources>
<!-- Dark Button Style with proper hover -->
<Style x:Key="DarkButton" TargetType="Button">
<Setter Property="Background" Value="#464646"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderBrush" Value="#646464"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="8,4"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}"
CornerRadius="3">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#0056A0"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="#004080"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Dark ComboBox Style -->
<Style x:Key="DarkComboBox" TargetType="ComboBox">
<Setter Property="Background" Value="#3A3A3A"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderBrush" Value="#555"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="6,4"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<ToggleButton x:Name="ToggleButton"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
ClickMode="Press">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="3">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition Width="20"/>
</Grid.ColumnDefinitions>
<ContentPresenter Grid.Column="0"/>
<Path Grid.Column="1" Data="M0,0 L4,4 L8,0" Stroke="White" StrokeThickness="1.5"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#4A4A4A"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<ContentPresenter x:Name="ContentSite"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
Margin="8,4,28,4"
VerticalAlignment="Center"
HorizontalAlignment="Left"
IsHitTestVisible="False"/>
<Popup x:Name="Popup" Placement="Bottom" IsOpen="{TemplateBinding IsDropDownOpen}"
AllowsTransparency="True" Focusable="False" PopupAnimation="Slide">
<Border x:Name="DropDown" Background="#3A3A3A" BorderBrush="#555" BorderThickness="1"
MinWidth="{TemplateBinding ActualWidth}" MaxHeight="{TemplateBinding MaxDropDownHeight}"
CornerRadius="3" Margin="0,2,0,0">
<ScrollViewer SnapsToDevicePixels="True">
<StackPanel IsItemsHost="True"/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Dark ComboBoxItem Style -->
<Style x:Key="DarkComboBoxItem" TargetType="ComboBoxItem">
<Setter Property="Background" Value="#3A3A3A"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="Padding" Value="8,4"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter Property="Background" Value="#0056A0"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#4A4A4A"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Quick Switch Button Style -->
<Style x:Key="QuickSwitchButton" TargetType="Button">
<Setter Property="Background" Value="#3C3C3C"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderBrush" Value="#0078D4"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,5"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}"
CornerRadius="3">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#0056A0"/>
<Setter Property="BorderBrush" Value="#0078D4"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Background" Value="#004080"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Spinner Animation -->
<Storyboard x:Key="SpinnerAnimation" RepeatBehavior="Forever">
<DoubleAnimation Storyboard.TargetName="SpinnerRotate"
Storyboard.TargetProperty="Angle"
From="0" To="360" Duration="0:0:1"/>
</Storyboard>
</Window.Resources>
<Border Background="#F0333333" CornerRadius="8" BorderBrush="#555" BorderThickness="1" Margin="5">
<Border.Effect>
<DropShadowEffect BlurRadius="10" ShadowDepth="2" Opacity="0.5"/>
</Border.Effect>
<Grid MinHeight="180">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Header -->
<Border Grid.Row="0" Background="#444" CornerRadius="8,8,0,0" Padding="12,10">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<TextBlock Text="Monitor Control" Foreground="White" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="About" Margin="0,0,6,0" Click="AboutButton_Click"
Style="{StaticResource DarkButton}" FontSize="11"/>
<Button Content="Exit" Click="ExitButton_Click"
Style="{StaticResource DarkButton}" FontSize="11"/>
</StackPanel>
</Grid>
<!-- Update Banner -->
<Border Name="updateBanner" Grid.Row="1" Background="#0078D4" CornerRadius="3"
Padding="8,4" Margin="0,8,0,0" Visibility="Collapsed" Cursor="Hand"
MouseLeftButtonUp="UpdateBanner_Click">
<TextBlock Name="updateText" Text="Update available!" Foreground="White"
FontSize="11" HorizontalAlignment="Center"/>
</Border>
</Grid>
</Border>
<!-- Quick Switch Toolbar -->
<WrapPanel Name="quickSwitchPanel" Grid.Row="1" Margin="8,8,8,0" Visibility="Collapsed"/>
<!-- Content -->
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto" Padding="12">
<StackPanel Name="sp" VerticalAlignment="Center">
<!-- Loading indicator with spinner -->
<StackPanel Name="loadingPanel" HorizontalAlignment="Center" Margin="0,30,0,30">
<Ellipse Width="24" Height="24" StrokeThickness="3" HorizontalAlignment="Center" Margin="0,0,0,10"
RenderTransformOrigin="0.5,0.5">
<Ellipse.Stroke>
<LinearGradientBrush>
<GradientStop Color="#0078D4" Offset="0"/>
<GradientStop Color="Transparent" Offset="1"/>
</LinearGradientBrush>
</Ellipse.Stroke>
<Ellipse.RenderTransform>
<RotateTransform x:Name="SpinnerRotate" Angle="0"/>
</Ellipse.RenderTransform>
</Ellipse>
<TextBlock Name="loadingText" Text="Loading..." Foreground="LightGray" FontSize="12"
HorizontalAlignment="Center"/>
<Button Name="showLogButton" Content="Show Log" Margin="0,10,0,0"
Style="{StaticResource DarkButton}" FontSize="10"
Click="ShowLogButton_Click" Visibility="Collapsed"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,643 @@
using CMM.Library.Config;
using CMM.Library.Method;
using CMM.Library.ViewModel;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Threading;
namespace MonitorControl;
public partial class MainWindow : Window
{
private List<(XMonitor Monitor, List<InputSourceOption> Options)> _loadedMonitors = new();
private Storyboard? _spinnerStoryboard;
private DispatcherTimer? _showLogButtonTimer;
private UpdateInfo? _pendingUpdate;
private DateTime _lastUpdateCheck = DateTime.MinValue;
// WM_DISPLAYCHANGE: sent when display resolution changes or monitors are connected/disconnected
private const int WM_DISPLAYCHANGE = 0x007E;
private bool _displayChangeReloadPending;
public MainWindow()
{
InitializeComponent();
_spinnerStoryboard = (Storyboard)FindResource("SpinnerAnimation");
SourceInitialized += MainWindow_SourceInitialized;
DebugLogger.Log("MainWindow initialized");
}
private void MainWindow_SourceInitialized(object? sender, EventArgs e)
{
var source = HwndSource.FromHwnd(new WindowInteropHelper(this).Handle);
source?.AddHook(WndProc);
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == WM_DISPLAYCHANGE && !_displayChangeReloadPending)
{
_displayChangeReloadPending = true;
DebugLogger.Log("WM_DISPLAYCHANGE received - scheduling monitor refresh");
// Debounce: display changes often fire multiple messages in quick succession.
// Wait 2 seconds for things to settle, then reload if window is visible.
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(2) };
timer.Tick += async (s, args) =>
{
timer.Stop();
_displayChangeReloadPending = false;
if (IsVisible)
{
DebugLogger.Log("Reloading monitors after display change");
await LoadMonitors();
}
};
timer.Start();
}
return IntPtr.Zero;
}
public void ShowNearTray()
{
DebugLogger.Log("ShowNearTray called");
var workArea = SystemParameters.WorkArea;
Left = workArea.Right - Width - 10;
Top = workArea.Bottom - 350;
Show();
Activate();
// Start spinner
_spinnerStoryboard?.Begin(this, true);
// Show log button after 3 seconds if still loading
showLogButton.Visibility = Visibility.Collapsed;
_showLogButtonTimer?.Stop();
_showLogButtonTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
_showLogButtonTimer.Tick += (s, e) =>
{
_showLogButtonTimer.Stop();
if (loadingPanel.Visibility == Visibility.Visible || sp.Children.Contains(loadingPanel))
{
showLogButton.Visibility = Visibility.Visible;
DebugLogger.Log("Show Log button displayed (loading took > 3s)");
}
};
_showLogButtonTimer.Start();
// Check for updates in background (max once per hour)
if ((DateTime.Now - _lastUpdateCheck).TotalMinutes > 60)
{
_lastUpdateCheck = DateTime.Now;
_ = CheckForUpdatesAsync();
}
Dispatcher.BeginInvoke(new Action(() =>
{
Top = workArea.Bottom - ActualHeight - 10;
}), DispatcherPriority.Loaded);
}
private async Task CheckForUpdatesAsync()
{
try
{
var update = await UpdateChecker.CheckForUpdateAsync();
if (update != null)
{
_pendingUpdate = update;
await Dispatcher.InvokeAsync(() => ShowUpdateBanner(update));
}
}
catch
{
// Silently ignore update check failures
}
}
private void ShowUpdateBanner(UpdateInfo update)
{
updateBanner.Visibility = Visibility.Visible;
updateText.Text = $"v{update.LatestVersion} available - Click to update";
}
private void UpdateBanner_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (_pendingUpdate != null && !string.IsNullOrEmpty(_pendingUpdate.DownloadUrl))
{
UpdateChecker.OpenDownloadPage(_pendingUpdate.DownloadUrl);
}
}
private void Window_Deactivated(object sender, EventArgs e)
{
Hide();
_spinnerStoryboard?.Stop(this);
}
private void ExitButton_Click(object sender, RoutedEventArgs e)
{
Application.Current.Shutdown();
}
private void AboutButton_Click(object sender, RoutedEventArgs e)
{
var version = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
var versionStr = $"{version?.Major}.{version?.Minor}.{version?.Build}";
var aboutWindow = new Window
{
Title = "About Monitor Control",
Width = 300,
Height = 240,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
ResizeMode = ResizeMode.NoResize,
WindowStyle = WindowStyle.None,
AllowsTransparency = true,
Background = Brushes.Transparent
};
var border = new Border
{
Background = new SolidColorBrush(Color.FromArgb(0xF0, 0x33, 0x33, 0x33)),
CornerRadius = new CornerRadius(8),
BorderBrush = new SolidColorBrush(Color.FromRgb(0x55, 0x55, 0x55)),
BorderThickness = new Thickness(1),
Padding = new Thickness(20)
};
var stack = new StackPanel { VerticalAlignment = VerticalAlignment.Center };
var logo = new System.Windows.Controls.Image
{
Source = new System.Windows.Media.Imaging.BitmapImage(new Uri("pack://application:,,,/MonitorIcon.png")),
Width = 48,
Height = 48,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 0, 0, 10)
};
stack.Children.Add(logo);
stack.Children.Add(new TextBlock
{
Text = "Monitor Control",
Foreground = Brushes.White,
FontSize = 18,
FontWeight = FontWeights.SemiBold,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 0, 0, 5)
});
stack.Children.Add(new TextBlock
{
Text = $"Version {versionStr}",
Foreground = Brushes.LightGray,
FontSize = 12,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 0, 0, 10)
});
stack.Children.Add(new TextBlock
{
Text = "by David H. Friedel Jr",
Foreground = Brushes.Gray,
FontSize = 11,
HorizontalAlignment = HorizontalAlignment.Center
});
stack.Children.Add(new TextBlock
{
Text = "MarketAlly",
Foreground = Brushes.Gray,
FontSize = 11,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 0, 0, 15)
});
var closeBtn = new Button
{
Content = "OK",
Width = 80,
HorizontalAlignment = HorizontalAlignment.Center,
Style = (Style)FindResource("DarkButton")
};
closeBtn.Click += (s, args) => aboutWindow.Close();
stack.Children.Add(closeBtn);
border.Child = stack;
aboutWindow.Content = border;
aboutWindow.ShowDialog();
}
private void ShowLogButton_Click(object sender, RoutedEventArgs e)
{
var logWindow = new Window
{
Title = "Debug Log",
Width = 600,
Height = 400,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Background = new SolidColorBrush(Color.FromRgb(45, 45, 45))
};
var grid = new Grid();
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var textBox = new TextBox
{
Text = DebugLogger.GetLogs(),
IsReadOnly = true,
Background = new SolidColorBrush(Color.FromRgb(30, 30, 30)),
Foreground = Brushes.LightGray,
FontFamily = new FontFamily("Consolas"),
FontSize = 11,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
TextWrapping = TextWrapping.NoWrap,
Margin = new Thickness(10)
};
Grid.SetRow(textBox, 0);
grid.Children.Add(textBox);
var buttonPanel = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(10) };
var copyBtn = new Button { Content = "Copy to Clipboard", Padding = new Thickness(10, 5, 10, 5), Margin = new Thickness(0, 0, 10, 0) };
copyBtn.Click += (s, args) =>
{
Clipboard.SetText(DebugLogger.GetLogs());
MessageBox.Show("Log copied to clipboard!", "Debug Log", MessageBoxButton.OK, MessageBoxImage.Information);
};
buttonPanel.Children.Add(copyBtn);
var openFileBtn = new Button { Content = "Open Log File", Padding = new Thickness(10, 5, 10, 5), Margin = new Thickness(0, 0, 10, 0) };
openFileBtn.Click += (s, args) =>
{
try
{
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{DebugLogger.LogFilePath}\"");
}
catch { }
};
buttonPanel.Children.Add(openFileBtn);
var closeBtn = new Button { Content = "Close", Padding = new Thickness(10, 5, 10, 5) };
closeBtn.Click += (s, args) => logWindow.Close();
buttonPanel.Children.Add(closeBtn);
Grid.SetRow(buttonPanel, 1);
grid.Children.Add(buttonPanel);
logWindow.Content = grid;
logWindow.Show();
}
public async Task LoadMonitors()
{
DebugLogger.Log("LoadMonitors started");
var newChildren = new List<UIElement>();
_loadedMonitors.Clear();
try
{
DebugLogger.Log("Scanning for monitors...");
await CMMCommand.ScanMonitor();
DebugLogger.Log("Scan complete, reading monitor data...");
var monitors = (await CMMCommand.ReadMonitorsData()).ToList();
DebugLogger.Log($"Found {monitors.Count} monitor(s)");
if (!monitors.Any())
{
DebugLogger.Log("No DDC/CI monitors detected");
newChildren.Add(new TextBlock
{
Text = "No DDC/CI monitors detected",
Foreground = Brushes.LightGray,
FontSize = 12,
HorizontalAlignment = HorizontalAlignment.Center,
Margin = new Thickness(0, 30, 0, 30)
});
}
else
{
foreach (var m in monitors)
{
DebugLogger.Log($"Processing monitor: {m.MonitorName} (SN: {m.SerialNumber})");
DebugLogger.Log($" Getting brightness...");
var brightness = await CMMCommand.GetBrightness(m.SerialNumber) ?? 50;
DebugLogger.Log($" Brightness: {brightness}");
DebugLogger.Log($" Getting contrast...");
var contrast = await CMMCommand.GetContrast(m.SerialNumber) ?? 50;
DebugLogger.Log($" Contrast: {contrast}");
DebugLogger.Log($" Getting input source...");
var inputSource = await CMMCommand.GetInputSource(m.SerialNumber);
DebugLogger.Log($" Input source: {inputSource}");
DebugLogger.Log($" Getting input options...");
var inputOptions = await CMMCommand.GetInputSourceOptions(m.SerialNumber);
DebugLogger.Log($" Input options count: {inputOptions.Count}");
// Add any previously discovered ports from config
var discoveredPorts = MonitorConfigManager.GetDiscoveredPorts(m.SerialNumber, inputOptions);
foreach (var port in discoveredPorts)
{
inputOptions.Insert(0, port);
DebugLogger.Log($" Added discovered port from config: {port.Value} ({port.Name})");
}
// Some monitors don't report current input in their possible values list
// Add it if missing and save to config for future
if (inputSource.HasValue && !inputOptions.Any(o => o.Value == inputSource.Value))
{
var currentInputName = CMMCommand.GetInputSourceName(inputSource.Value);
inputOptions.Insert(0, new InputSourceOption(inputSource.Value, currentInputName));
DebugLogger.Log($" Added missing current input: {inputSource.Value} ({currentInputName})");
// Save this discovered port so it appears in future even when not current
MonitorConfigManager.AddDiscoveredPort(m.SerialNumber, m.MonitorName, inputSource.Value, currentInputName);
DebugLogger.Log($" Saved discovered port to config");
}
DebugLogger.Log($" Getting power status...");
var powerStatus = await CMMCommand.GetMonPowerStatus(m.SerialNumber) ?? "Unknown";
DebugLogger.Log($" Power status: {powerStatus}");
_loadedMonitors.Add((m, inputOptions));
// Apply config to filter hidden ports and use custom labels
// Pass currentInput so we never hide the currently active port
var filteredOptions = MonitorConfigManager.ApplyConfigToOptions(m.SerialNumber, inputOptions, inputSource);
DebugLogger.Log($" Filtered options count: {filteredOptions.Count}");
// Monitor name header with Config button
newChildren.Add(CreateMonitorHeader(m, inputOptions));
newChildren.Add(CreateSliderRow("Brightness", brightness, m.SerialNumber, CMMCommand.SetBrightness));
newChildren.Add(CreateSliderRow("Contrast", contrast, m.SerialNumber, CMMCommand.SetContrast));
if (filteredOptions.Count > 0)
newChildren.Add(CreateInputRow(inputSource, filteredOptions, m.SerialNumber));
newChildren.Add(CreatePowerRow(powerStatus, m.SerialNumber));
// Separator
newChildren.Add(new Border { Height = 1, Background = new SolidColorBrush(Color.FromRgb(80, 80, 80)), Margin = new Thickness(0, 10, 0, 2) });
DebugLogger.Log($" Monitor {m.MonitorName} processing complete");
}
// Remove last separator
if (newChildren.Count > 0 && newChildren.Last() is Border)
newChildren.RemoveAt(newChildren.Count - 1);
}
// Load quick-switch toolbar
DebugLogger.Log("Loading quick-switch toolbar...");
LoadQuickSwitchToolbar();
DebugLogger.Log("Quick-switch toolbar loaded");
}
catch (Exception ex)
{
DebugLogger.LogError("LoadMonitors failed", ex);
newChildren.Clear();
newChildren.Add(new TextBlock { Text = $"Error: {ex.Message}", Foreground = Brushes.OrangeRed, FontSize = 11, TextWrapping = TextWrapping.Wrap });
}
// Stop spinner and timer
_spinnerStoryboard?.Stop(this);
_showLogButtonTimer?.Stop();
sp.VerticalAlignment = VerticalAlignment.Top;
sp.Children.Clear();
foreach (var child in newChildren)
sp.Children.Add(child);
DebugLogger.Log("LoadMonitors complete, UI updated");
Dispatcher.BeginInvoke(new Action(() =>
{
var workArea = SystemParameters.WorkArea;
Top = workArea.Bottom - ActualHeight - 10;
}), DispatcherPriority.Loaded);
}
private void LoadQuickSwitchToolbar()
{
quickSwitchPanel.Children.Clear();
var quickItems = MonitorConfigManager.GetQuickSwitchItems();
if (quickItems.Count == 0)
{
quickSwitchPanel.Visibility = Visibility.Collapsed;
return;
}
quickSwitchPanel.Visibility = Visibility.Visible;
foreach (var item in quickItems)
{
var btn = new Button
{
Content = $"{item.PortLabel}",
ToolTip = $"{item.MonitorName}: {item.PortLabel}",
Margin = new Thickness(0, 0, 6, 6),
Style = (Style)FindResource("QuickSwitchButton"),
Tag = item
};
btn.Click += async (s, e) =>
{
if (s is Button b && b.Tag is QuickSwitchItem qsi)
{
await CMMCommand.SetInputSource(qsi.MonitorSerialNumber, qsi.PortVcpValue);
UpdateInputDropdown(qsi.MonitorSerialNumber, qsi.PortVcpValue);
}
};
quickSwitchPanel.Children.Add(btn);
}
}
private void UpdateInputDropdown(string serialNumber, int newValue)
{
foreach (var child in sp.Children)
{
if (child is StackPanel row)
{
foreach (var element in row.Children)
{
if (element is ComboBox cb && cb.Tag is string sn && sn == serialNumber)
{
var options = cb.ItemsSource as List<InputSourceOption>;
if (options != null)
{
var index = options.FindIndex(o => o.Value == newValue);
if (index >= 0)
cb.SelectedIndex = index;
}
return;
}
}
}
}
}
private StackPanel CreateMonitorHeader(XMonitor monitor, List<InputSourceOption> allOptions)
{
var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 8, 0, 6) };
row.Children.Add(new TextBlock
{
Text = monitor.MonitorName,
Foreground = Brushes.White,
FontSize = 13,
FontWeight = FontWeights.SemiBold,
VerticalAlignment = VerticalAlignment.Center
});
var configBtn = new Button
{
Content = "Config",
Margin = new Thickness(10, 0, 0, 0),
FontSize = 11,
Style = (Style)FindResource("DarkButton"),
Tag = (monitor, allOptions)
};
configBtn.Click += ConfigButton_Click;
row.Children.Add(configBtn);
return row;
}
private async void ConfigButton_Click(object sender, RoutedEventArgs e)
{
if (sender is Button btn && btn.Tag is ValueTuple<XMonitor, List<InputSourceOption>> data)
{
var (monitor, options) = data;
var configWindow = new ConfigWindow(monitor.SerialNumber, monitor.MonitorName, options);
configWindow.Owner = this;
configWindow.ShowDialog();
if (configWindow.ConfigChanged)
{
MonitorConfigManager.ClearCache();
await LoadMonitors();
}
}
}
private StackPanel CreateSliderRow(string label, int value, string serialNumber, Func<string, int, Task> setCommand)
{
var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 3) };
row.Children.Add(new TextBlock { Text = label, Foreground = Brushes.LightGray, Width = 70, FontSize = 12, VerticalAlignment = VerticalAlignment.Center });
var slider = new Slider { Minimum = 0, Maximum = 100, Value = value, Width = 140, Tag = serialNumber };
var valueText = new TextBlock { Text = value.ToString(), Foreground = Brushes.White, Width = 30, FontSize = 12, Margin = new Thickness(5, 0, 0, 0) };
slider.ValueChanged += (s, e) => valueText.Text = ((int)e.NewValue).ToString();
slider.PreviewMouseUp += async (s, e) =>
{
if (s is Slider sl && sl.Tag is string sn)
await setCommand(sn, (int)sl.Value);
};
row.Children.Add(slider);
row.Children.Add(valueText);
return row;
}
private StackPanel CreateInputRow(int? currentInput, List<InputSourceOption> options, string serialNumber)
{
DebugLogger.Log($" CreateInputRow: currentInput={currentInput}, optionCount={options.Count}");
foreach (var opt in options)
DebugLogger.Log($" Option: Value={opt.Value}, Name={opt.Name}");
var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 3) };
row.Children.Add(new TextBlock { Text = "Input", Foreground = Brushes.LightGray, Width = 70, FontSize = 12, VerticalAlignment = VerticalAlignment.Center });
var combo = new ComboBox
{
Width = 170,
ItemsSource = options,
DisplayMemberPath = "Name",
Tag = serialNumber,
Style = (Style)FindResource("DarkComboBox"),
ItemContainerStyle = (Style)FindResource("DarkComboBoxItem")
};
row.Children.Add(combo);
// Set selection AFTER adding to visual tree, BEFORE adding event handler
if (currentInput.HasValue)
{
var index = options.FindIndex(o => o.Value == currentInput.Value);
DebugLogger.Log($" Selection: Looking for Value={currentInput.Value}, found at index={index}");
if (index >= 0)
combo.SelectedIndex = index;
}
else
{
DebugLogger.Log($" Selection: currentInput is null, no selection");
}
// Add event handler AFTER setting the initial selection
combo.SelectionChanged += async (s, e) =>
{
// Only trigger if user actually changed the selection (not initial load)
if (s is ComboBox cb && cb.Tag is string sn && cb.SelectedItem is InputSourceOption opt && e.AddedItems.Count > 0 && e.RemovedItems.Count > 0)
await CMMCommand.SetInputSource(sn, opt.Value);
};
return row;
}
private StackPanel CreatePowerRow(string status, string serialNumber)
{
var row = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 3, 0, 3) };
row.Children.Add(new TextBlock { Text = "Power", Foreground = Brushes.LightGray, Width = 70, FontSize = 12, VerticalAlignment = VerticalAlignment.Center });
var isUnsupported = string.IsNullOrEmpty(status) || status == "Unknown";
var btn = new Button
{
Content = isUnsupported ? "Power Unsupported" : status,
Width = 170,
Tag = serialNumber,
Style = (Style)FindResource("DarkButton"),
IsEnabled = !isUnsupported
};
if (!isUnsupported)
{
btn.Click += async (s, e) =>
{
if (s is Button b && b.Tag is string sn)
{
var current = await CMMCommand.GetMonPowerStatus(sn);
if (current == "Sleep" || current == "PowerOff")
await CMMCommand.PowerOn(sn);
else
await CMMCommand.Sleep(sn);
await Task.Delay(1000);
b.Content = await CMMCommand.GetMonPowerStatus(sn);
}
};
}
row.Children.Add(btn);
return row;
}
}

View File

@@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<AssemblyName>MonitorControl</AssemblyName>
<RootNamespace>MonitorControl</RootNamespace>
<Product>ControlMyMonitorManagement</Product>
<UseWPF>true</UseWPF>
<Company>MarketAlly</Company>
<Authors>David H. Friedel Jr</Authors>
<Copyright>Copyright © MarketAlly $([System.DateTime]::Now.ToString(yyyy))</Copyright>
<ApplicationIcon>MonitorIcon.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup>
<Version>1.1.5</Version>
<AssemblyVersion>1.1.5.0</AssemblyVersion>
<FileVersion>1.1.5.0</FileVersion>
</PropertyGroup>
<ItemGroup>
<None Remove="MonitorIcon.ico" />
<None Remove="MonitorIcon.png" />
</ItemGroup>
<ItemGroup>
<Resource Include="MonitorIcon.ico" />
<Resource Include="MonitorIcon.png" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Hardcodet.Wpf.TaskbarNotification.Net6" Version="1.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Library\Library.csproj" />
<ProjectReference Include="..\Language\Language.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,44 @@
#define MyAppName "Monitor Control"
#define MyAppVersion "1.1.5"
#define MyAppPublisher "MarketAlly"
#define MyAppExeName "MonitorControl.exe"
#define MyAppIcon "MonitorIcon.ico"
#define SourcePath "bin\Release\net9.0-windows"
[Setup]
SourceDir=.
AppId={{8F3E4A2B-1C5D-4E6F-9A8B-7C2D1E3F4A5B}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
AllowNoIcons=yes
OutputDir=installer
OutputBaseFilename=MonitorControl-Setup-{#MyAppVersion}
Compression=lzma
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=admin
SetupIconFile={#MyAppIcon}
UninstallDisplayIcon={app}\{#MyAppExeName}
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
Name: "startupicon"; Description: "Start with Windows"; GroupDescription: "Startup:"
[Files]
Source: "bin\Release\net9.0-windows\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#MyAppIcon}"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\{#MyAppIcon}"
Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\{#MyAppIcon}"; Tasks: desktopicon
Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\{#MyAppIcon}"; Tasks: startupicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -0,0 +1,125 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style x:Key="BaseButton" TargetType="{x:Type ButtonBase}" BasedOn="{x:Null}">
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Foreground" Value="{DynamicResource H3}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.7"/>
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="TextBorderStyle" TargetType="{x:Type Border}">
<Setter Property="Background" Value="{StaticResource F3}"/>
<Setter Property="BorderBrush" Value="#C1272C"/>
<Setter Property="BorderThickness" Value="0"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#A12126"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.3"/>
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="TextButtonStyle" TargetType="{x:Type Button}" BasedOn="{StaticResource BaseButton}">
<Setter Property="Margin" Value="5"/>
<Setter Property="Width" Value="180"/>
<Setter Property="Height" Value="25"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border CornerRadius="8" Style="{StaticResource TextBorderStyle}">
<ContentPresenter Margin="2" HorizontalAlignment="Center" VerticalAlignment="Center" RecognizesAccessKey="True"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Foreground" Value="{DynamicResource F3}" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="ImageButton" TargetType="{x:Type ButtonBase}" BasedOn="{x:Null}">
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Style.Triggers>
<Trigger Property="IsEnabled" Value="false">
<Setter Property="Foreground" Value="{StaticResource DisabledForegroundBrush}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Cursor" Value="Hand" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="BaseBorderStyle" TargetType="{x:Type Border}">
<Setter Property="Background" Value="{DynamicResource H2}"/>
<Setter Property="BorderBrush" Value="{DynamicResource F3}"/>
<Setter Property="BorderThickness" Value="1"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource F3}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.3"/>
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="CancelButton" TargetType="{x:Type ButtonBase}" BasedOn="{StaticResource BaseButton}">
<Setter Property="Margin" Value="3,0,0,0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border>
<Grid Background="{StaticResource AlphaBrush}">
<Path Style="{DynamicResource PathCancel}" Margin="3"/>
<Path Style="{DynamicResource PathFrame}"/>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="NUDButtonStyle" TargetType="{x:Type RepeatButton}" BasedOn="{x:Null}">
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="FontSize" Value="20" />
<Setter Property="Foreground" Value="{DynamicResource F3}" />
<Setter Property="FontFamily" Value="Marlett" />
<Setter Property="Width" Value="30" />
<Setter Property="Height" Value="35" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton">
<Border Style="{StaticResource BaseBorderStyle}">
<ContentPresenter Margin="2" HorizontalAlignment="Center" VerticalAlignment="Center" RecognizesAccessKey="True"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsEnabled" Value="false">
<Setter Property="Foreground" Value="{StaticResource DisabledForegroundBrush}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Foreground" Value="{DynamicResource F2}" />
</Trigger>
</Style.Triggers>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,37 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Color x:Key="H1Color">#C1272C</Color>
<Color x:Key="H2Color">#66424242</Color>
<Color x:Key="H3Color">#535353</Color>
<Color x:Key="F1Color">#232323</Color>
<Color x:Key="F2Color">#8A3D40</Color>
<Color x:Key="F3Color">#FFFFFF</Color>
<Color x:Key="DisableColor">#B2626262</Color>
<Color x:Key="DisableH2Color">#CCADADAD</Color>
<Color x:Key="NormalGray">#FF686868</Color>
<SolidColorBrush x:Key="NormalForeground" Color="White" />
<SolidColorBrush x:Key="Unexplainable" Color="{StaticResource F2Color}" />
<SolidColorBrush x:Key="Unnecessary" Color="#a08b72" />
<SolidColorBrush x:Key="Selected" Color="{StaticResource H2Color}" />
<SolidColorBrush x:Key="Title" Color="White" />
<SolidColorBrush x:Key="H1" Color="{StaticResource H1Color}" />
<SolidColorBrush x:Key="H2" Color="{StaticResource H2Color}" />
<SolidColorBrush x:Key="H3" Color="{StaticResource H3Color}" />
<SolidColorBrush x:Key="F1" Color="{StaticResource F1Color}" />
<SolidColorBrush x:Key="F2" Color="{StaticResource F2Color}" />
<SolidColorBrush x:Key="F3" Color="{StaticResource F3Color}" />
<SolidColorBrush x:Key="BlurBrush" Color="#CC6C6161" />
<SolidColorBrush x:Key="AlphaBrush" Color="#00FFFFFF" />
<SolidColorBrush x:Key="NormalBrush" Color="{StaticResource NormalGray}" />
<SolidColorBrush x:Key="DisabledForegroundBrush" Color="{StaticResource DisableColor}" />
</ResourceDictionary>

View File

@@ -0,0 +1,58 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib">
<sys:Double x:Key="FontSize">26</sys:Double>
<Style x:Key="TitleStyle" TargetType="{x:Type TextBlock}">
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Background" Value="{x:Null}" />
<Setter Property="Foreground" Value="{DynamicResource F3}" />
<Setter Property="FontSize" Value="{DynamicResource FontSize}" />
<Setter Property="FontWeight" Value="Bold" />
</Style>
<Style x:Key="LableStyle" TargetType="{x:Type TextBlock}" BasedOn="{StaticResource TitleStyle}">
<Setter Property="Foreground" Value="{DynamicResource F3}" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="FontSize" Value="{DynamicResource FontSize}" />
<Setter Property="Margin" Value="10,0,10,0"/>
<Setter Property="HorizontalAlignment" Value="Left" />
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.40"/>
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="TextBoxStyle" TargetType="TextBox">
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="TextAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="MinWidth" Value="60" />
<Setter Property="Foreground" Value="{StaticResource F3}" />
<Setter Property="FontSize" Value="{DynamicResource FontSize}" />
<Setter Property="Margin" Value="0,2,0,2"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type TextBoxBase}">
<Border x:Name="border" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="#33FFFFFF" SnapsToDevicePixels="True">
<ScrollViewer x:Name="PART_ContentHost" Focusable="False" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" TargetName="border" Value="0.40"/>
</Trigger>
<Trigger Property="IsFocused" Value="True">
<Setter Property="BorderBrush" Value="{StaticResource DisabledForegroundBrush}"/>
<Setter Property="BorderThickness" Value="2"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,283 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/Style/Color.xaml"/>
</ResourceDictionary.MergedDictionaries>
<Style x:Key="SliderRepeatButton" TargetType="RepeatButton">
<Setter Property="SnapsToDevicePixels" Value="true" />
<Setter Property="OverridesDefaultStyle" Value="true" />
<Setter Property="IsTabStop" Value="false" />
<Setter Property="Focusable" Value="false" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton">
<Border BorderThickness="1" BorderBrush="{x:Null}" Background="#CCB9B6B6" Height="6"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="SliderRepeatButton1" TargetType="RepeatButton">
<Setter Property="SnapsToDevicePixels" Value="true" />
<Setter Property="OverridesDefaultStyle" Value="true" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RepeatButton">
<Border SnapsToDevicePixels="True" BorderThickness="1" Height="6">
<Border.Background>
<LinearGradientBrush EndPoint="1,0" StartPoint="0,0">
<GradientStop Color="{DynamicResource H2Color}"/>
<GradientStop Color="{DynamicResource H3Color}" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="SliderThumb" TargetType="Thumb">
<Setter Property="SnapsToDevicePixels" Value="true" />
<Setter Property="OverridesDefaultStyle" Value="true" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Ellipse Height="20" Width="20" Fill="#99FFFFFF"></Ellipse>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<ControlTemplate x:Key="Slider" TargetType="Slider">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto" MinHeight="{TemplateBinding MinHeight}"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Track Grid.Row="1" x:Name="PART_Track">
<Track.DecreaseRepeatButton>
<RepeatButton Style="{StaticResource SliderRepeatButton1}" Command="Slider.DecreaseLarge" />
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource SliderThumb}"/>
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Style="{StaticResource SliderRepeatButton}" Command="Slider.IncreaseLarge" />
</Track.IncreaseRepeatButton>
</Track>
</Grid>
</ControlTemplate>
<Style x:Key="Horizontal_Slider" TargetType="Slider">
<Setter Property="Focusable" Value="False"/>
<Setter Property="SnapsToDevicePixels" Value="true"/>
<Setter Property="OverridesDefaultStyle" Value="true"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Width" Value="300"/>
<Setter Property="Height" Value="25"/>
<Setter Property="Foreground" Value="{x:Null}"/>
<Setter Property="Margin" Value="5,0,10,0"/>
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="MinHeight" Value="25"/>
<Setter Property="MinWidth" Value="104"/>
<Setter Property="Template" Value="{StaticResource Slider}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="false">
<Setter Property="Opacity" Value="0.4"/>
</Trigger>
</Style.Triggers>
</Style>
<LinearGradientBrush x:Key="ListBoxBackgroundBrush" StartPoint="0,0" EndPoint="1,0.001">
<GradientBrush.GradientStops>
<GradientStopCollection>
<GradientStop Color="White" Offset="0.0"/>
<GradientStop Color="White" Offset="0.6"/>
<GradientStop Color="#DDDDDD" Offset="1.2"/>
</GradientStopCollection>
</GradientBrush.GradientStops>
</LinearGradientBrush>
<LinearGradientBrush x:Key="StandardBrush" StartPoint="0,0" EndPoint="0,1">
<GradientBrush.GradientStops>
<GradientStopCollection>
<GradientStop Color="#FFF" Offset="0.0"/>
<GradientStop Color="#CCC" Offset="1.0"/>
</GradientStopCollection>
</GradientBrush.GradientStops>
</LinearGradientBrush>
<LinearGradientBrush x:Key="PressedBrush" StartPoint="0,0" EndPoint="0,1">
<GradientBrush.GradientStops>
<GradientStopCollection>
<GradientStop Color="#BBB" Offset="0.0"/>
<GradientStop Color="#EEE" Offset="0.1"/>
<GradientStop Color="#EEE" Offset="0.9"/>
<GradientStop Color="#FFF" Offset="1.0"/>
</GradientStopCollection>
</GradientBrush.GradientStops>
</LinearGradientBrush>
<Style x:Key="ScrollBarBackground" TargetType="{x:Type Border}">
<Setter Property="Margin" Value="2"/>
<Setter Property="CornerRadius" Value="6"/>
<Setter Property="Background" Value="{DynamicResource H2}"/>
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Background" Value="{x:Null}"/>
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="ScrollBarLineButton" TargetType="{x:Type RepeatButton}">
<!--上下按鈕 不顯示-->
<Setter Property="Visibility" Value="Hidden"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="OverridesDefaultStyle" Value="true"/>
<Setter Property="Focusable" Value="false"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type RepeatButton}">
<Border Name="Border" Margin="1" CornerRadius="2" Background="{DynamicResource H2}" BorderBrush="{DynamicResource H2}" BorderThickness="1">
<Path HorizontalAlignment="Center" VerticalAlignment="Center" Fill="{DynamicResource H2}" Data="{Binding Path=Content, RelativeSource={RelativeSource TemplatedParent}}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsPressed" Value="true">
<Setter TargetName="Border" Property="Background" Value="{StaticResource PressedBrush}" />
</Trigger>
<Trigger Property="IsEnabled" Value="false">
<Setter Property="Foreground" Value="{StaticResource DisabledForegroundBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ScrollBarPageButton" TargetType="{x:Type RepeatButton}">
<Setter Property="Visibility" Value="Visible"/>
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="OverridesDefaultStyle" Value="true"/>
<Setter Property="IsTabStop" Value="false"/>
<Setter Property="Focusable" Value="false"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type RepeatButton}">
<Border Background="{x:Null}" Margin="1"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ScrollBarThumb" TargetType="{x:Type Thumb}">
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="OverridesDefaultStyle" Value="true"/>
<Setter Property="IsTabStop" Value="false"/>
<Setter Property="Focusable" Value="false"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Thumb}">
<Border CornerRadius="4" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="0" Width="7"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<ControlTemplate x:Key="VerticalScrollBar" TargetType="{x:Type ScrollBar}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition MaxHeight="5"/>
<RowDefinition Height="0.00001*"/>
<RowDefinition MaxHeight="5"/>
</Grid.RowDefinitions>
<!--直向拉竿背景-->
<Border Grid.RowSpan="4" Style="{StaticResource ScrollBarBackground}"/>
<!--往上按鈕 ※隱藏-->
<RepeatButton Grid.Row="0" Style="{StaticResource ScrollBarLineButton}" Height="18" Command="ScrollBar.LineUpCommand" Content="M 0 4 L 8 4 L 4 0 Z" />
<Track Name="PART_Track" Grid.Row="1" IsDirectionReversed="true" IsEnabled="{TemplateBinding IsMouseOver}" >
<Track.DecreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollBarPageButton}" Command="ScrollBar.PageUpCommand" />
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource ScrollBarThumb}" Margin="1,0,1,0" Background="{DynamicResource H3}" BorderBrush="{DynamicResource H3}" />
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollBarPageButton}" Command="ScrollBar.PageDownCommand" />
</Track.IncreaseRepeatButton>
</Track>
<!--往下按鈕 ※隱藏-->
<RepeatButton Grid.Row="3" Style="{StaticResource ScrollBarLineButton}" Height="18" Command="ScrollBar.LineDownCommand" Content="M 0 0 L 4 4 L 8 0 Z"/>
</Grid>
</ControlTemplate>
<ControlTemplate x:Key="HorizontalScrollBar" TargetType="{x:Type ScrollBar}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition MaxWidth="18"/>
<ColumnDefinition Width="0.00001*"/>
<ColumnDefinition MaxWidth="18"/>
</Grid.ColumnDefinitions>
<!--橫向拉竿背景-->
<Border Grid.ColumnSpan="3" CornerRadius="2" Background="{x:Null}"/>
<RepeatButton Grid.Column="0" Style="{StaticResource ScrollBarLineButton}" Width="18" Command="ScrollBar.LineLeftCommand" Content="M 4 0 L 4 8 L 0 4 Z" />
<Track Name="PART_Track" Grid.Column="1" IsDirectionReversed="False">
<Track.DecreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollBarPageButton}" Command="ScrollBar.PageLeftCommand" />
</Track.DecreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource ScrollBarThumb}" Margin="0,1,0,1" Background="{DynamicResource H3}" BorderBrush="{DynamicResource H3}" />
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Style="{StaticResource ScrollBarPageButton}" Command="ScrollBar.PageRightCommand" />
</Track.IncreaseRepeatButton>
</Track>
<RepeatButton Grid.Column="3" Style="{StaticResource ScrollBarLineButton}" Width="18" Command="ScrollBar.LineRightCommand" Content="M 0 0 L 4 4 L 0 8 Z"/>
</Grid>
</ControlTemplate>
<Style x:Key="{x:Type ScrollBar}" TargetType="{x:Type ScrollBar}">
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="OverridesDefaultStyle" Value="true"/>
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Width" Value="Auto"/>
<Setter Property="Height" Value="18" />
<Setter Property="Template" Value="{StaticResource HorizontalScrollBar}" />
</Trigger>
<Trigger Property="Orientation" Value="Vertical">
<Setter Property="Width" Value="18"/>
<Setter Property="Height" Value="Auto" />
<Setter Property="Template" Value="{StaticResource VerticalScrollBar}" />
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="FavsScrollViewer" TargetType="{x:Type ScrollViewer}">
<Setter Property="OverridesDefaultStyle" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ScrollViewer}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ScrollContentPresenter Grid.Column="1"/>
<ScrollBar Name="PART_VerticalScrollBar" Value="{TemplateBinding VerticalOffset}" Maximum="{TemplateBinding ScrollableHeight}" ViewportSize="{TemplateBinding ViewportHeight}" Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}"/>
<ScrollBar Name="PART_HorizontalScrollBar" Orientation="Horizontal" Grid.Row="1" Grid.Column="1" Value="{TemplateBinding HorizontalOffset}" Maximum="{TemplateBinding ScrollableWidth}" ViewportSize="{TemplateBinding ViewportWidth}" Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}"/>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,119 @@
using System;
using System.Diagnostics;
using System.Net.Http;
using System.Reflection;
using System.Text.Json;
using System.Threading.Tasks;
namespace MonitorControl;
public class UpdateChecker
{
private const string ReleasesApiUrl = "https://git.marketally.com/api/v1/repos/misc/ControlMyMonitorManagement/releases/latest";
private static readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(10) };
public static Version CurrentVersion => Assembly.GetExecutingAssembly().GetName().Version ?? new Version(1, 0, 0);
public static async Task<UpdateInfo?> CheckForUpdateAsync()
{
try
{
DebugLogger.Log($"Checking for updates... Current version: {CurrentVersion}");
var response = await _httpClient.GetStringAsync(ReleasesApiUrl);
var release = JsonSerializer.Deserialize<GiteaRelease>(response);
if (release == null || string.IsNullOrEmpty(release.tag_name))
{
DebugLogger.Log("No release info found");
return null;
}
var latestVersionStr = release.tag_name.TrimStart('v', 'V');
if (!Version.TryParse(latestVersionStr, out var latestVersion))
{
DebugLogger.Log($"Could not parse version: {release.tag_name}");
return null;
}
DebugLogger.Log($"Latest version: {latestVersion}");
if (latestVersion > CurrentVersion)
{
// Find the installer asset
string? downloadUrl = null;
if (release.assets != null)
{
foreach (var asset in release.assets)
{
if (asset.name?.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) == true)
{
downloadUrl = asset.browser_download_url;
break;
}
}
}
// Fallback to release page if no direct download
downloadUrl ??= release.html_url;
DebugLogger.Log($"Update available: {latestVersion}, URL: {downloadUrl}");
return new UpdateInfo
{
CurrentVersion = CurrentVersion,
LatestVersion = latestVersion,
DownloadUrl = downloadUrl ?? "",
ReleaseNotes = release.body ?? ""
};
}
DebugLogger.Log("No update available");
return null;
}
catch (Exception ex)
{
DebugLogger.LogError("Update check failed", ex);
return null;
}
}
public static void OpenDownloadPage(string url)
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = url,
UseShellExecute = true
});
}
catch (Exception ex)
{
DebugLogger.LogError("Failed to open download page", ex);
}
}
}
public class UpdateInfo
{
public Version CurrentVersion { get; set; } = new(1, 0, 0);
public Version LatestVersion { get; set; } = new(1, 0, 0);
public string DownloadUrl { get; set; } = "";
public string ReleaseNotes { get; set; } = "";
}
public class GiteaRelease
{
public string? tag_name { get; set; }
public string? name { get; set; }
public string? body { get; set; }
public string? html_url { get; set; }
public GiteaAsset[]? assets { get; set; }
}
public class GiteaAsset
{
public string? name { get; set; }
public string? browser_download_url { get; set; }
}