ThemeManager grows a test seam — its singleton ctor now delegates to
three internal seams (isSystemDark / loadPreference / savePreference)
that the production singleton fills with the real registry +
UIPreferences calls. Tests construct via the internal ctor with
stubs so they never touch HKCU or %LOCALAPPDATA% (which would
otherwise flake on CI or pollute the dev's UI state). Apply() and
the SystemEvents subscription are intentionally NOT exercised
here — both require Application.Current and a real dispatcher.
CommandPaletteViewModel.Matches changes from `private static` to
`internal static` — the predicate is the unit worth pinning, and
building a full CommandPaletteViewModel would require a fake
IIsoController + Dispatcher for one test.
New tests:
* src/tests/TeamsISO.App.Tests/Services/ThemeManagerTests.cs (11 cases):
- Set Dark → Light round-trips Preference + ResolveTheme and
persists via the savePreference seam.
- ResolveTheme follows the system probe when Preference is
System (true → Dark, false → Light).
- Toggle from System pins to the opposite of the currently-
resolved theme (not back to System) — explicit click should
have visible effect.
- Toggle from Dark flips Light; Toggle from Light flips Dark.
- Set rejects invalid preferences (case-sensitive: lowercase
"dark", "LIGHT", "", "invalid" all throw ArgumentException
with ParamName=preference).
- Constructor defaults to System when loadPreference returns
null (fresh install / missing prefs file) or an invalid value
(future schema collision).
- Constructor swallows a load exception so the app doesn't lose
theming when ui-prefs.json faults on read.
* src/tests/TeamsISO.App.Tests/ViewModels/CommandPaletteMatchesTests.cs
(16 cases): Theory pinning case-insensitive label / category /
keyword Contains, plus a full-vocabulary spread test counting
hits for "theme" (3), "stop" (1), "ndi" (2), "App" (5 — four
App-category cmds + the Apply transcoder topology substring
match, called out in the assertion because a future move to a
stricter algo has to re-decide that affordance deliberately),
and "xyzzy" (0).
Tests: 56 → 83 in App.Tests; Engine.Tests unchanged at 103.
Total green: 186. Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
8.2 KiB
C#
210 lines
8.2 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.Linq;
|
|
using System.Windows.Threading;
|
|
using TeamsISO.App.Services;
|
|
|
|
namespace TeamsISO.App.ViewModels;
|
|
|
|
/// <summary>
|
|
/// View-model for the v2 Ctrl+K command palette. Owns the static list of
|
|
/// commands the operator can invoke, plus a free-text filter that whittles
|
|
/// the visible list down.
|
|
///
|
|
/// The palette is the v2 redesign's navigation surface — it replaces the
|
|
/// v1 rail's launch / hide / settings buttons (still discoverable in the
|
|
/// 32px header) AND the buried-in-tabs operator actions like "Apply
|
|
/// transcoder topology" or "Stop all ISOs" that previously needed
|
|
/// hunting through menus. Type two letters, press Enter, action invokes.
|
|
///
|
|
/// Match shape: case-insensitive Contains across Label + Category + the
|
|
/// optional Keywords list. Fuzzy (Sublime / Linear style) matching is a
|
|
/// future evolution if Contains proves insufficient; broadcasters have
|
|
/// short attention budgets and Contains is the predictable answer.
|
|
/// </summary>
|
|
public sealed class CommandPaletteViewModel : ObservableObject
|
|
{
|
|
private readonly MainViewModel _main;
|
|
private readonly Dispatcher _dispatcher;
|
|
private readonly List<PaletteCommand> _all;
|
|
private string _filter = string.Empty;
|
|
private PaletteCommand? _selected;
|
|
|
|
public CommandPaletteViewModel(MainViewModel main, Dispatcher dispatcher)
|
|
{
|
|
_main = main;
|
|
_dispatcher = dispatcher;
|
|
_all = BuildCommands();
|
|
Visible = new ObservableCollection<PaletteCommand>(_all);
|
|
Selected = Visible.FirstOrDefault();
|
|
}
|
|
|
|
/// <summary>Free-text filter. Empty string shows all commands.</summary>
|
|
public string Filter
|
|
{
|
|
get => _filter;
|
|
set
|
|
{
|
|
if (!SetField(ref _filter, value)) return;
|
|
ApplyFilter();
|
|
}
|
|
}
|
|
|
|
/// <summary>Filtered command list, bound to the palette ListBox.</summary>
|
|
public ObservableCollection<PaletteCommand> Visible { get; }
|
|
|
|
/// <summary>Currently-highlighted command. Driven by ↑/↓ in the search box and by mouse hover.</summary>
|
|
public PaletteCommand? Selected
|
|
{
|
|
get => _selected;
|
|
set => SetField(ref _selected, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Move the selection up or down within the visible list, wrapping at the
|
|
/// edges. Called from the palette's PreviewKeyDown when the operator
|
|
/// presses ↑ / ↓ while focus is in the search box.
|
|
/// </summary>
|
|
public void MoveSelection(int direction)
|
|
{
|
|
if (Visible.Count == 0) return;
|
|
var idx = Selected is null ? -1 : Visible.IndexOf(Selected);
|
|
idx = (idx + direction + Visible.Count) % Visible.Count;
|
|
Selected = Visible[idx];
|
|
}
|
|
|
|
/// <summary>Invoke the current selection's action. Returns true if something fired.</summary>
|
|
public bool InvokeSelection()
|
|
{
|
|
var sel = Selected;
|
|
if (sel is null) return false;
|
|
try { sel.Invoke(); }
|
|
catch (Exception ex)
|
|
{
|
|
_main.Toast.Warn($"{sel.Label}: {ex.Message}");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private void ApplyFilter()
|
|
{
|
|
var query = _filter.Trim();
|
|
var prevSelected = Selected;
|
|
Visible.Clear();
|
|
if (string.IsNullOrEmpty(query))
|
|
{
|
|
foreach (var c in _all) Visible.Add(c);
|
|
}
|
|
else
|
|
{
|
|
foreach (var c in _all)
|
|
{
|
|
if (Matches(c, query)) Visible.Add(c);
|
|
}
|
|
}
|
|
Selected = Visible.Contains(prevSelected!)
|
|
? prevSelected
|
|
: Visible.FirstOrDefault();
|
|
}
|
|
|
|
internal static bool Matches(PaletteCommand c, string query)
|
|
{
|
|
if (c.Label.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
|
|
if (c.Category.Contains(query, StringComparison.OrdinalIgnoreCase)) return true;
|
|
// Keywords is a single space-separated string of synonyms — Contains
|
|
// over the whole blob suffices for the operator's short-token typing.
|
|
if (!string.IsNullOrEmpty(c.Keywords) &&
|
|
c.Keywords.Contains(query, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build the static command list. Order within a category matters for
|
|
/// keyboard-only operators: the most-frequent command of each category
|
|
/// goes first.
|
|
/// </summary>
|
|
private List<PaletteCommand> BuildCommands()
|
|
{
|
|
var vm = _main;
|
|
return new List<PaletteCommand>
|
|
{
|
|
// ─── QUICK ─── operator's top-of-mind verbs
|
|
new("Quick", "Enable all online", "ISOs enable everyone start everything live", "Ctrl+E",
|
|
() => InvokeIfReady(vm.EnableAllOnlineCommand)),
|
|
new("Quick", "Stop all ISOs", "panic stop everything kill disable", "Ctrl+Shift+S",
|
|
() => InvokeIfReady(vm.StopAllIsosCommand)),
|
|
new("Quick", "Refresh discovery", "rediscover sources rebuild finder NDI", "Ctrl+R",
|
|
() => InvokeIfReady(vm.RefreshDiscoveryCommand)),
|
|
|
|
// ─── TEAMS ─── direct UIA orchestration
|
|
new("Teams", "Mute / unmute", "microphone audio silence toggle", null,
|
|
() => InvokeIfReady(vm.ToggleMuteCommand)),
|
|
new("Teams", "Toggle camera", "video webcam on off", null,
|
|
() => InvokeIfReady(vm.ToggleCameraCommand)),
|
|
new("Teams", "Open share tray", "screen share present", null,
|
|
() => InvokeIfReady(vm.OpenShareTrayCommand)),
|
|
new("Teams", "Leave call", "exit end disconnect quit", null,
|
|
() => InvokeIfReady(vm.LeaveCallCommand)),
|
|
new("Teams", "Launch Microsoft Teams", "start open run app", null,
|
|
() => RunOnUi(() =>
|
|
{
|
|
if (!TeamsLauncher.IsRunning() && TeamsLauncher.TryLaunch(out _))
|
|
vm.Toast.Show("Launching Microsoft Teams…");
|
|
else
|
|
TeamsLauncher.ShowWindows();
|
|
})),
|
|
new("Teams", "Hide Teams windows", "minimize cloak", null,
|
|
() => RunOnUi(() =>
|
|
{
|
|
var n = TeamsLauncher.HideWindows();
|
|
vm.Toast.Show(n > 0 ? $"Hid {n} Teams window(s)" : "No Teams windows to hide");
|
|
})),
|
|
new("Teams", "Show Teams windows", "restore unhide", null,
|
|
() => RunOnUi(() =>
|
|
{
|
|
var n = TeamsLauncher.ShowWindows();
|
|
vm.Toast.Show(n > 0 ? $"Restored {n} Teams window(s)" : "No Teams windows to restore");
|
|
})),
|
|
|
|
// ─── NETWORK ───
|
|
new("Network", "Apply transcoder topology", "ndi groups isolate teamsiso-input private", null,
|
|
() => InvokeIfReady(vm.Settings.ApplyTranscoderTopologyCommand)),
|
|
|
|
// ─── APP ───
|
|
new("App", "Theme: dark", "appearance night mode", null,
|
|
() => RunOnUi(() => ThemeManager.Current.Set("Dark"))),
|
|
new("App", "Theme: light", "appearance day mode bright", null,
|
|
() => RunOnUi(() => ThemeManager.Current.Set("Light"))),
|
|
new("App", "Theme: follow Windows", "system auto", null,
|
|
() => RunOnUi(() => ThemeManager.Current.Set("System"))),
|
|
new("App", "Help", "shortcuts cheatsheet f1", "F1",
|
|
() => InvokeIfReady(vm.ShowHelpCommand)),
|
|
new("App", "Show notes", "show notes daily journal", null,
|
|
() => InvokeIfReady(vm.ShowNotesCommand)),
|
|
};
|
|
}
|
|
|
|
private void RunOnUi(Action action) => _dispatcher.BeginInvoke(action);
|
|
|
|
private static void InvokeIfReady(System.Windows.Input.ICommand cmd)
|
|
{
|
|
if (cmd?.CanExecute(null) == true) cmd.Execute(null);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// One command in the Ctrl+K palette. <see cref="Keywords"/> is an optional
|
|
/// space of additional search terms — the operator might type "ndi" or
|
|
/// "private" and still match "Apply transcoder topology".
|
|
/// </summary>
|
|
public sealed record PaletteCommand(
|
|
string Category,
|
|
string Label,
|
|
string? Keywords,
|
|
string? Shortcut,
|
|
Action Invoke);
|