Mirror lamps onto RGB keyboards via Windows Dynamic Lighting
Opt-in checkbox in the Input group: keys bound to lamp-capable button addresses glow with the panel's palette (red family, yellow for the Secondary/Screen columns) and blink at the panel's own flash rates, so keyboard and on-screen panel stay in step; unbound keys are blacked out so the keyboard reads as the button field. Zone-lit keyboards without per-key addressing (e.g. this laptop's 24-zone ITE board) fall back to mirroring the strongest current lamp board-wide. A picker under the checkbox narrows the mirror to one keyboard when several are attached (hot-plug aware; releases the others back to Windows). KeyboardLampMirror claims keyboard-kind LampArrays via a DeviceWatcher, repaints at 100 ms only when colors actually change, survives paint faults (a leaked Timer exception would kill the process), and logs attach/detach plus IsAvailable transitions with a pointer to the Dynamic Lighting background-control setting. Disabling releases every claim so the LEDs revert to the ambient scene. Also: the wire log gets a horizontal scrollbar (long lines don't wrap). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Forms;
|
||||
using VRio.Core.Device;
|
||||
using VRio.Core.Input;
|
||||
using VRio.Core.Panel;
|
||||
using VRio.Core.Protocol;
|
||||
using Windows.Devices.Enumeration;
|
||||
using Windows.Devices.Lights;
|
||||
using Windows.System;
|
||||
using Windows.UI;
|
||||
using Color = Windows.UI.Color;
|
||||
|
||||
namespace VRio.App;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the host-commanded lamp states onto per-key RGB keyboards through
|
||||
/// Windows Dynamic Lighting (<see cref="LampArray"/>). Keys bound to
|
||||
/// lamp-capable button addresses in the active profile glow with the panel's
|
||||
/// palette — the red family for most groups, yellow for Secondary/Screen —
|
||||
/// and the flash bits blink at the panel's rates; every other key is blacked
|
||||
/// out so the keyboard reads as the button field. Zone-lit keyboards (no
|
||||
/// per-key addressing, common on laptops) fall back to a whole-board mirror
|
||||
/// of the strongest current lamp state.
|
||||
///
|
||||
/// <para>Opt-in: enabling claims keyboard-kind lamp arrays (hot-plug aware),
|
||||
/// disabling releases them so Windows hands the LEDs back to the ambient
|
||||
/// scene — no explicit restore needed. With several keyboards attached,
|
||||
/// <see cref="SetTarget"/> narrows the mirror to one of them
|
||||
/// (<see cref="KeyboardsChanged"/> feeds the picker). Dynamic Lighting gives
|
||||
/// LED control to the <em>foreground</em> app by default; for the keys to
|
||||
/// stay lit while the game has focus, vRIO needs package identity and a pick
|
||||
/// under Settings → Personalization → Dynamic Lighting → "Background light
|
||||
/// control".</para>
|
||||
/// </summary>
|
||||
public sealed class KeyboardLampMirror : IDisposable
|
||||
{
|
||||
// Refresh cadence and flash phases match PanelCanvas, so the keyboard and
|
||||
// the on-screen panel blink in step.
|
||||
private const int RefreshMs = 100;
|
||||
|
||||
private static readonly Stopwatch Clock = Stopwatch.StartNew();
|
||||
|
||||
// Address → panel colouring, from the cockpit layout (yellow = the
|
||||
// Secondary/Screen columns, same test as PanelCanvas).
|
||||
private static readonly Dictionary<int, bool> LampAddressIsYellow = BuildAddressInfo();
|
||||
|
||||
private readonly VRioDevice _device;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, (LampArray Array, bool PerKey)> _claimed = new();
|
||||
private readonly Dictionary<string, string> _known = new(); // every keyboard array seen, id → name
|
||||
|
||||
// One entry per lamp-capable bound key: which lamp drives it, how to paint it.
|
||||
private (int Address, VirtualKey Key, bool Yellow)[] _map = Array.Empty<(int, VirtualKey, bool)>();
|
||||
|
||||
private DeviceWatcher? _watcher;
|
||||
private System.Threading.Timer? _timer;
|
||||
private Color[]? _lastPushed; // parallel to _map; null forces a full repaint
|
||||
private Color _lastAggregate; // last whole-board color for zone keyboards
|
||||
private string? _target; // device id to mirror to; null = all keyboards
|
||||
private bool _enabled;
|
||||
private bool _unsupported; // WinRT/Dynamic Lighting missing — don't retry
|
||||
private bool _anyArraySeen; // a lamp-array device event arrived this session
|
||||
|
||||
public KeyboardLampMirror(VRioDevice device) =>
|
||||
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||||
|
||||
/// <summary>Status/log lines (attach, detach, availability hints).</summary>
|
||||
public event Action<string>? Logged;
|
||||
|
||||
/// <summary>The set of detected keyboards changed (id, name) — for a picker.</summary>
|
||||
public event Action<IReadOnlyList<(string Id, string Name)>>? KeyboardsChanged;
|
||||
|
||||
/// <summary>Mirror on/off. Off releases the LEDs back to Windows.</summary>
|
||||
public bool Enabled
|
||||
{
|
||||
get { lock (_gate) return _enabled; }
|
||||
set
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_enabled == value || _unsupported)
|
||||
return;
|
||||
_enabled = value;
|
||||
}
|
||||
if (value) Start();
|
||||
else Stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mirror to one keyboard by device id, or to all (null).</summary>
|
||||
public void SetTarget(string? deviceId)
|
||||
{
|
||||
LampArray[] release;
|
||||
List<(string Id, string Name)> reclaim = new();
|
||||
lock (_gate)
|
||||
{
|
||||
if (_target == deviceId)
|
||||
return;
|
||||
_target = deviceId;
|
||||
release = _claimed.Where(kv => !Matches(kv.Key))
|
||||
.Select(kv => kv.Value.Array).ToArray();
|
||||
foreach (string id in _claimed.Keys.Where(id => !Matches(id)).ToArray())
|
||||
_claimed.Remove(id);
|
||||
foreach (KeyValuePair<string, string> kv in _known)
|
||||
if (Matches(kv.Key) && !_claimed.ContainsKey(kv.Key))
|
||||
reclaim.Add((kv.Key, kv.Value));
|
||||
_lastPushed = null;
|
||||
}
|
||||
foreach (LampArray array in release)
|
||||
Release(array);
|
||||
foreach ((string id, string name) in reclaim)
|
||||
ReclaimAsync(id, name);
|
||||
}
|
||||
|
||||
/// <summary>Rebuild the key map from a loaded profile (lamp-capable buttons only).</summary>
|
||||
public void SetProfile(BindingProfile profile)
|
||||
{
|
||||
var map = new List<(int, VirtualKey, bool)>();
|
||||
var seen = new HashSet<VirtualKey>();
|
||||
foreach (KeyButtonBinding b in profile.KeyButtons)
|
||||
{
|
||||
if (!LampAddressIsYellow.TryGetValue(b.Address, out bool yellow))
|
||||
continue; // keypads and unknown addresses have no lamp
|
||||
if (!Enum.TryParse(b.Key, ignoreCase: true, out Keys key))
|
||||
continue;
|
||||
// WinForms Keys and WinRT VirtualKey share the Win32 VK number space.
|
||||
var vk = (VirtualKey)(int)(key & Keys.KeyCode);
|
||||
if (seen.Add(vk)) // first binding wins, like the router's key lookup
|
||||
map.Add((b.Address, vk, yellow));
|
||||
}
|
||||
lock (_gate)
|
||||
{
|
||||
_map = map.ToArray();
|
||||
_lastPushed = null; // repaint under the new map
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Enabled = false;
|
||||
Logged = null;
|
||||
KeyboardsChanged = null;
|
||||
}
|
||||
|
||||
// ---- Device lifecycle ---------------------------------------------------
|
||||
|
||||
private void Start()
|
||||
{
|
||||
try
|
||||
{
|
||||
var watcher = DeviceInformation.CreateWatcher(LampArray.GetDeviceSelector());
|
||||
watcher.Added += OnArrayAdded;
|
||||
watcher.Removed += OnArrayRemoved;
|
||||
watcher.Updated += (_, _) => { }; // required for the watcher to progress
|
||||
watcher.EnumerationCompleted += (_, _) =>
|
||||
{
|
||||
// Attaches log themselves (and may still be in flight); only the
|
||||
// empty case needs a line here.
|
||||
bool any;
|
||||
lock (_gate) any = _anyArraySeen;
|
||||
if (!any)
|
||||
Logged?.Invoke("Keyboard lighting: no Dynamic Lighting keyboard found (check Settings → Personalization → Dynamic Lighting)");
|
||||
};
|
||||
lock (_gate) _watcher = watcher;
|
||||
watcher.Start();
|
||||
// A leaked exception in a Timer callback kills the process — never
|
||||
// let a lighting hiccup take vRIO down mid-flight.
|
||||
_timer = new System.Threading.Timer(_ =>
|
||||
{
|
||||
try { Tick(); }
|
||||
catch (Exception ex) { Logged?.Invoke($"Keyboard lighting: paint failed: {ex.Message}"); }
|
||||
}, null, RefreshMs, RefreshMs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_unsupported = true;
|
||||
_enabled = false;
|
||||
}
|
||||
Logged?.Invoke($"Keyboard lighting unavailable (Dynamic Lighting needs Windows 11): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void Stop()
|
||||
{
|
||||
DeviceWatcher? watcher;
|
||||
LampArray[] arrays;
|
||||
lock (_gate)
|
||||
{
|
||||
watcher = _watcher;
|
||||
_watcher = null;
|
||||
arrays = _claimed.Values.Select(a => a.Array).ToArray();
|
||||
_claimed.Clear();
|
||||
_known.Clear();
|
||||
_lastPushed = null;
|
||||
}
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
try { watcher?.Stop(); }
|
||||
catch (Exception ex) when (ex is InvalidOperationException or COMException) { }
|
||||
// Releasing the arrays hands the LEDs back to the system ambient scene.
|
||||
foreach (LampArray array in arrays)
|
||||
Release(array);
|
||||
RaiseKeyboardsChanged();
|
||||
}
|
||||
|
||||
private async void OnArrayAdded(DeviceWatcher sender, DeviceInformation info)
|
||||
{
|
||||
lock (_gate) _anyArraySeen = true;
|
||||
try
|
||||
{
|
||||
LampArray array = await LampArray.FromIdAsync(info.Id);
|
||||
if (array is null || array.LampArrayKind != LampArrayKind.Keyboard)
|
||||
return; // don't paint mice/strips/cases
|
||||
|
||||
lock (_gate) _known[info.Id] = info.Name;
|
||||
if (!TryClaim(info.Id, info.Name, array))
|
||||
Release(array);
|
||||
RaiseKeyboardsChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logged?.Invoke($"Keyboard lighting: could not open {info.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnArrayRemoved(DeviceWatcher sender, DeviceInformationUpdate update)
|
||||
{
|
||||
string? name;
|
||||
LampArray? gone = null;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_known.TryGetValue(update.Id, out name))
|
||||
return;
|
||||
_known.Remove(update.Id);
|
||||
if (_claimed.TryGetValue(update.Id, out (LampArray Array, bool PerKey) entry))
|
||||
{
|
||||
gone = entry.Array;
|
||||
_claimed.Remove(update.Id);
|
||||
}
|
||||
}
|
||||
if (gone is not null)
|
||||
Release(gone);
|
||||
Logged?.Invoke($"Keyboard lighting: {name} disconnected");
|
||||
RaiseKeyboardsChanged();
|
||||
}
|
||||
|
||||
private async void ReclaimAsync(string id, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
LampArray array = await LampArray.FromIdAsync(id);
|
||||
if (array is null)
|
||||
return;
|
||||
if (!TryClaim(id, name, array))
|
||||
Release(array);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logged?.Invoke($"Keyboard lighting: could not reopen {name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Store the array if it is wanted right now; false = caller releases.</summary>
|
||||
private bool TryClaim(string id, string name, LampArray array)
|
||||
{
|
||||
bool perKey = array.SupportsVirtualKeys;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_enabled || !Matches(id) || _claimed.ContainsKey(id))
|
||||
return false;
|
||||
_claimed[id] = (array, perKey);
|
||||
_lastPushed = null; // full repaint including the base coat
|
||||
}
|
||||
Logged?.Invoke(perKey
|
||||
? $"Keyboard lighting: + {name} ({array.LampCount} LEDs, per-key)"
|
||||
: $"Keyboard lighting: + {name} ({array.LampCount} zones — no per-key map, mirroring the strongest lamp board-wide)");
|
||||
HookAvailability(array, name);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool Matches(string id) => _target is null || _target == id;
|
||||
|
||||
private static void Release(LampArray array)
|
||||
{
|
||||
// No IDisposable projection on .NET Framework — drop the COM wrapper.
|
||||
try { Marshal.ReleaseComObject(array); } catch (ArgumentException) { }
|
||||
}
|
||||
|
||||
private void RaiseKeyboardsChanged()
|
||||
{
|
||||
(string, string)[] known;
|
||||
lock (_gate) known = _known.Select(kv => (kv.Key, kv.Value)).ToArray();
|
||||
KeyboardsChanged?.Invoke(known);
|
||||
}
|
||||
|
||||
private void HookAvailability(LampArray array, string name)
|
||||
{
|
||||
// IsAvailable/AvailabilityChanged need Windows 11 22H2+; older builds
|
||||
// just never get the background-control hint.
|
||||
try
|
||||
{
|
||||
bool lastAvailable = array.IsAvailable;
|
||||
if (!lastAvailable)
|
||||
Logged?.Invoke($"Keyboard lighting: Windows is withholding {name} — enable vRIO under " +
|
||||
"Settings → Personalization → Dynamic Lighting → Background light control");
|
||||
array.AvailabilityChanged += (a, _) =>
|
||||
{
|
||||
bool available = a.IsAvailable;
|
||||
if (available == lastAvailable)
|
||||
return; // the event also fires without a state change
|
||||
lastAvailable = available;
|
||||
Logged?.Invoke(available
|
||||
? $"Keyboard lighting: {name} available"
|
||||
: $"Keyboard lighting: {name} withheld (foreground app owns it — see Dynamic Lighting settings)");
|
||||
lock (_gate) _lastPushed = null; // repaint when control returns
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is COMException or InvalidCastException or MissingMethodException) { }
|
||||
}
|
||||
|
||||
// ---- Rendering ----------------------------------------------------------
|
||||
|
||||
private void Tick()
|
||||
{
|
||||
(int Address, VirtualKey Key, bool Yellow)[] map;
|
||||
(LampArray Array, bool PerKey)[] arrays;
|
||||
Color[]? last;
|
||||
Color lastAggregate;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_enabled || _claimed.Count == 0)
|
||||
return;
|
||||
map = _map;
|
||||
arrays = _claimed.Values.ToArray();
|
||||
last = _lastPushed;
|
||||
lastAggregate = _lastAggregate;
|
||||
}
|
||||
|
||||
long tick = Clock.ElapsedMilliseconds;
|
||||
var colors = new Color[map.Length];
|
||||
var keys = new VirtualKey[map.Length];
|
||||
var bestShade = LampBrightness.Off;
|
||||
bool bestYellow = false;
|
||||
bool changed = last is null || last.Length != map.Length;
|
||||
for (int i = 0; i < map.Length; i++)
|
||||
{
|
||||
byte state = _device.GetLamp(map[i].Address);
|
||||
LampBrightness shade = RioLampState.Brightness(state);
|
||||
if (shade != LampBrightness.Off && !FlashPhaseOn(RioLampState.Flash(state), tick))
|
||||
shade = LampBrightness.Off;
|
||||
if (shade > bestShade)
|
||||
(bestShade, bestYellow) = (shade, map[i].Yellow);
|
||||
|
||||
colors[i] = Shade(shade, map[i].Yellow);
|
||||
keys[i] = map[i].Key;
|
||||
if (last is not null)
|
||||
changed |= !colors[i].Equals(last[i]);
|
||||
}
|
||||
Color aggregate = Shade(bestShade, bestYellow);
|
||||
changed |= !aggregate.Equals(lastAggregate);
|
||||
if (!changed)
|
||||
return;
|
||||
|
||||
foreach ((LampArray array, bool perKey) in arrays)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (perKey)
|
||||
{
|
||||
if (last is null) // new claim/map: black out the whole board first
|
||||
array.SetColor(ColorHelper.FromArgb(255, 0, 0, 0));
|
||||
if (map.Length > 0)
|
||||
array.SetColorsForKeys(colors, keys);
|
||||
}
|
||||
else if (map.Length > 0)
|
||||
{
|
||||
// Zone keyboard: the whole board is one big lamp showing
|
||||
// the strongest current state (idle dim, alerts bright/flash).
|
||||
array.SetColor(aggregate);
|
||||
}
|
||||
}
|
||||
catch (COMException) { } // device wobble; the watcher handles removal
|
||||
}
|
||||
lock (_gate)
|
||||
{
|
||||
_lastPushed = colors;
|
||||
_lastAggregate = aggregate;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The panel's lamp palette (PanelCanvas fills), as LED colors.</summary>
|
||||
private static Color Shade(LampBrightness shade, bool yellow) =>
|
||||
yellow
|
||||
? shade switch
|
||||
{
|
||||
LampBrightness.Bright => ColorHelper.FromArgb(255, 245, 210, 60),
|
||||
LampBrightness.Dim => ColorHelper.FromArgb(255, 140, 118, 38),
|
||||
_ => ColorHelper.FromArgb(255, 70, 60, 24),
|
||||
}
|
||||
: shade switch
|
||||
{
|
||||
LampBrightness.Bright => ColorHelper.FromArgb(255, 230, 70, 70),
|
||||
LampBrightness.Dim => ColorHelper.FromArgb(255, 120, 50, 50),
|
||||
_ => ColorHelper.FromArgb(255, 64, 40, 40),
|
||||
};
|
||||
|
||||
// Same half-periods as PanelCanvas, so panel and keyboard blink together.
|
||||
private static bool FlashPhaseOn(LampFlash flash, long tick)
|
||||
{
|
||||
long halfPeriod = flash switch
|
||||
{
|
||||
LampFlash.FlashSlow => 500,
|
||||
LampFlash.FlashMed => 250,
|
||||
LampFlash.FlashFast => 125,
|
||||
_ => 0,
|
||||
};
|
||||
return halfPeriod == 0 || tick / halfPeriod % 2 == 0;
|
||||
}
|
||||
|
||||
private static Dictionary<int, bool> BuildAddressInfo()
|
||||
{
|
||||
var info = new Dictionary<int, bool>();
|
||||
foreach (PanelButton b in CockpitLayout.Buttons())
|
||||
if (b.LampCapable)
|
||||
info[b.Address] = b.Group.Title is "Secondary" or "Screen";
|
||||
return info;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ internal sealed class MainForm : Form
|
||||
private readonly PanelCanvas _canvas = new();
|
||||
private readonly InputRouter _router;
|
||||
private readonly XInputGamepad _gamepad = new();
|
||||
private readonly KeyboardLampMirror _lampMirror;
|
||||
private readonly string _bindingsPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt");
|
||||
|
||||
@@ -57,17 +58,36 @@ internal sealed class MainForm : Form
|
||||
};
|
||||
private readonly Button _reloadBindings = new() { Text = "Reload bindings", Location = new Point(10, 68), Width = 140, Height = 26 };
|
||||
private readonly Button _editBindings = new() { Text = "Edit bindings…", Location = new Point(156, 68), Width = 140, Height = 26 };
|
||||
private readonly CheckBox _kbLights = new()
|
||||
{
|
||||
Text = "Mirror lamps on RGB keyboard (Dynamic Lighting)",
|
||||
Location = new Point(10, 100),
|
||||
AutoSize = true,
|
||||
};
|
||||
private readonly ComboBox _kbLightsTarget = new()
|
||||
{
|
||||
Location = new Point(10, 122),
|
||||
Width = 286,
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
Enabled = false, // populated while the mirror is on
|
||||
};
|
||||
|
||||
/// <summary>A keyboard choice in the lamp-mirror picker (null id = all).</summary>
|
||||
private sealed record KbChoice(string? Id, string Name)
|
||||
{
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
private readonly Label _counters = new()
|
||||
{
|
||||
Location = new Point(12, 336),
|
||||
Location = new Point(12, 390),
|
||||
AutoSize = true,
|
||||
Font = new Font("Consolas", 8f),
|
||||
};
|
||||
|
||||
private readonly Label _help = new()
|
||||
{
|
||||
Location = new Point(12, 392),
|
||||
Location = new Point(12, 446),
|
||||
MaximumSize = new Size(306, 0),
|
||||
AutoSize = true,
|
||||
ForeColor = Color.Gray,
|
||||
@@ -78,10 +98,10 @@ internal sealed class MainForm : Form
|
||||
|
||||
private readonly TextBox _logBox = new()
|
||||
{
|
||||
Location = new Point(12, 476),
|
||||
Location = new Point(12, 530),
|
||||
Multiline = true,
|
||||
ReadOnly = true,
|
||||
ScrollBars = ScrollBars.Vertical,
|
||||
ScrollBars = ScrollBars.Both, // long wire lines don't wrap — scroll to read
|
||||
BackColor = Color.FromArgb(24, 24, 24),
|
||||
ForeColor = Color.Gainsboro,
|
||||
Font = new Font("Consolas", 8f),
|
||||
@@ -112,6 +132,7 @@ internal sealed class MainForm : Form
|
||||
|
||||
_service = new VRioSerialService(_device);
|
||||
_router = new InputRouter(_device);
|
||||
_lampMirror = new KeyboardLampMirror(_device);
|
||||
|
||||
// Panel canvas, scrolled if the window is smaller than the grid.
|
||||
var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
|
||||
@@ -137,6 +158,7 @@ internal sealed class MainForm : Form
|
||||
_device.ResetReceived += _ => RunOnUi(_router.ResetAxisState);
|
||||
_device.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_service.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_lampMirror.Logged += line => RunOnUi(() => PrependLog(line));
|
||||
_service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
|
||||
_service.HostHandshake += high => RunOnUi(() =>
|
||||
PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
|
||||
@@ -166,6 +188,16 @@ internal sealed class MainForm : Form
|
||||
if (!_kbInput.Checked)
|
||||
_router.ReleaseAllKeys();
|
||||
};
|
||||
_kbLights.CheckedChanged += (_, _) =>
|
||||
{
|
||||
_lampMirror.Enabled = _kbLights.Checked;
|
||||
_kbLightsTarget.Enabled = _kbLights.Checked;
|
||||
};
|
||||
_kbLightsTarget.SelectedIndexChanged += (_, _) =>
|
||||
_lampMirror.SetTarget((_kbLightsTarget.SelectedItem as KbChoice)?.Id);
|
||||
_lampMirror.KeyboardsChanged += list => RunOnUi(() => RebuildKeyboardPicker(list));
|
||||
_kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
|
||||
_kbLightsTarget.SelectedIndex = 0;
|
||||
_reloadBindings.Click += (_, _) => LoadBindings();
|
||||
_editBindings.Click += (_, _) => OpenBindingsFile();
|
||||
|
||||
@@ -179,6 +211,7 @@ internal sealed class MainForm : Form
|
||||
{
|
||||
_uiTimer.Dispose();
|
||||
_inputTimer.Dispose();
|
||||
_lampMirror.Dispose();
|
||||
_service.Dispose();
|
||||
};
|
||||
|
||||
@@ -217,13 +250,13 @@ internal sealed class MainForm : Form
|
||||
device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff, _testEnter, _testExit });
|
||||
panel.Controls.Add(device);
|
||||
|
||||
var input = new GroupBox { Text = "Input", Location = new Point(12, 224), Size = new Size(306, 104) };
|
||||
input.Controls.AddRange(new Control[] { _kbInput, _padInput, _padStatus, _reloadBindings, _editBindings });
|
||||
var input = new GroupBox { Text = "Input", Location = new Point(12, 224), Size = new Size(306, 158) };
|
||||
input.Controls.AddRange(new Control[] { _kbInput, _padInput, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget });
|
||||
panel.Controls.Add(input);
|
||||
|
||||
panel.Controls.Add(_counters);
|
||||
panel.Controls.Add(_help);
|
||||
panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 458), AutoSize = true });
|
||||
panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 512), AutoSize = true });
|
||||
|
||||
_logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44);
|
||||
panel.Controls.Add(_logBox);
|
||||
@@ -370,6 +403,7 @@ internal sealed class MainForm : Form
|
||||
|
||||
var profile = BindingProfileFormat.Parse(text, out var errors);
|
||||
_router.Profile = profile;
|
||||
_lampMirror.SetProfile(profile);
|
||||
foreach (string error in errors)
|
||||
PrependLog($"Bindings: {error}");
|
||||
PrependLog($"Bindings loaded: {profile.Count} ({_bindingsPath})");
|
||||
@@ -395,6 +429,26 @@ internal sealed class MainForm : Form
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Refresh the lamp-mirror keyboard picker, keeping the pick if it survived.</summary>
|
||||
private void RebuildKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards)
|
||||
{
|
||||
string? selected = (_kbLightsTarget.SelectedItem as KbChoice)?.Id;
|
||||
_kbLightsTarget.BeginUpdate();
|
||||
_kbLightsTarget.Items.Clear();
|
||||
_kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
|
||||
int select = 0;
|
||||
foreach ((string id, string name) in keyboards)
|
||||
{
|
||||
int idx = _kbLightsTarget.Items.Add(new KbChoice(id, name));
|
||||
if (id == selected)
|
||||
select = idx;
|
||||
}
|
||||
// Falls back to "All keyboards" if the picked device vanished (the
|
||||
// selection-changed handler re-targets the mirror accordingly).
|
||||
_kbLightsTarget.SelectedIndex = select;
|
||||
_kbLightsTarget.EndUpdate();
|
||||
}
|
||||
|
||||
// ---- Status / log ------------------------------------------------------
|
||||
|
||||
private void UpdateStatus()
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<!-- WinRT projections for Windows.Devices.Lights (Dynamic Lighting /
|
||||
LampArray) on .NET Framework — the keyboard lamp mirror. -->
|
||||
<PackageReference Include="Microsoft.Windows.SDK.Contracts" Version="10.0.22621.3233" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user