feat(wpf): v2 task 39+40 - studio table redesign + Ctrl+K command palette
Some checks failed
CI / build-and-test (push) Failing after 30s

Task 39: 5-column participants table - state LED, name+codec caption, 5-bar audio meter, mono output name, ISO pill. Row height 52, full-row active-speaker tint (no left stripe). New converter LevelThresholdConverter, OutputName property on ParticipantViewModel.

Task 40: Ctrl+K / Ctrl+P command palette - chromeless centered floating window, fuzzy Contains match across Label/Category/Keywords, arrow nav, Enter invoke, Esc close. Quick/Teams/Network/App categories cover top operator verbs and theme switching.

Also: log startup exceptions to Serilog before the modal MessageBox fires - much better triage signal than user-pasted dialog text.
This commit is contained in:
Zac Gaetano 2026-05-15 11:15:00 -04:00
parent c27130302f
commit d282e1b0f8
11 changed files with 784 additions and 33 deletions

View file

@ -286,6 +286,13 @@ public partial class App : Application
} }
catch (Exception ex) catch (Exception ex)
{ {
// Log the full exception (incl. stack + inner) to Serilog BEFORE the
// modal MessageBox fires — diagnostic logs are far more useful than a
// user-pasted "TeamsISO failed to start..." line when triaging a
// startup crash. The logger may itself have been the failure target
// so guard the call.
try { _loggerFactory?.CreateLogger<App>().LogCritical(ex, "OnStartup failed before main loop"); }
catch { /* defensive */ }
MessageBox.Show( MessageBox.Show(
"TeamsISO failed to start.\n\nDetails: " + ex, "TeamsISO failed to start.\n\nDetails: " + ex,
"TeamsISO — startup error", "TeamsISO — startup error",

View file

@ -0,0 +1,42 @@
using System.Globalization;
using System.Windows.Data;
namespace TeamsISO.App.Converters;
/// <summary>
/// Maps an audio level (0.01.0) to an opacity for a single audio-meter
/// segment. The XAML binds five copies, each with a different
/// <see cref="Binding.ConverterParameter"/> threshold (0.2, 0.4, 0.6,
/// 0.8, 1.0). A segment renders at full opacity when the live level
/// exceeds its threshold; below that it dims to a faint silhouette so the
/// inactive segments still read as "the meter has 5 steps" rather than
/// blank space.
///
/// Designed for the v2 "Studio Terminal" participants table's audio meter.
/// Broadcast engineers expect instantaneous (non-averaged) bars; the
/// converter is stateless and trusts the caller to push raw levels.
/// </summary>
public sealed class LevelThresholdConverter : IValueConverter
{
/// <summary>Opacity for an above-threshold segment. Defaults to 1.0.</summary>
public double ActiveOpacity { get; set; } = 1.0;
/// <summary>Opacity for a below-threshold segment. Defaults to 0.18 — visible enough to read the segment shape but clearly off.</summary>
public double InactiveOpacity { get; set; } = 0.18;
public object Convert(object value, System.Type targetType, object parameter, CultureInfo culture)
{
var level = value switch
{
double d => d,
float f => f,
_ => 0.0,
};
if (!double.TryParse(parameter?.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var threshold))
threshold = 1.0;
return level >= threshold ? ActiveOpacity : InactiveOpacity;
}
public object ConvertBack(object value, System.Type targetType, object parameter, CultureInfo culture) =>
System.Windows.Data.Binding.DoNothing;
}

View file

@ -0,0 +1,24 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace TeamsISO.App.Converters;
/// <summary>
/// Tiny utility converter — null or empty string ⇒ Collapsed, otherwise
/// Visible. Used by the v2 command palette's optional shortcut chip
/// (e.g. "Ctrl+R") so rows without a bound shortcut don't render the
/// empty pill outline.
/// </summary>
public sealed class NullToCollapsedConverter : IValueConverter
{
public object Convert(object value, System.Type targetType, object parameter, CultureInfo culture)
{
if (value is null) return Visibility.Collapsed;
if (value is string s && string.IsNullOrEmpty(s)) return Visibility.Collapsed;
return Visibility.Visible;
}
public object ConvertBack(object value, System.Type targetType, object parameter, CultureInfo culture) =>
System.Windows.Data.Binding.DoNothing;
}

View file

@ -38,6 +38,7 @@
TrueValue="Collapsed" TrueValue="Collapsed"
FalseValue="Visible"/> FalseValue="Visible"/>
<conv:EnumDescriptionConverter x:Key="EnumDesc"/> <conv:EnumDescriptionConverter x:Key="EnumDesc"/>
<conv:LevelThresholdConverter x:Key="LevelGate"/>
</Window.Resources> </Window.Resources>
<Window.InputBindings> <Window.InputBindings>
@ -45,10 +46,11 @@
<KeyBinding Key="S" Modifiers="Ctrl+Shift" Command="{Binding StopAllIsosCommand}"/> <KeyBinding Key="S" Modifiers="Ctrl+Shift" Command="{Binding StopAllIsosCommand}"/>
<KeyBinding Key="R" Modifiers="Ctrl" Command="{Binding RefreshDiscoveryCommand}"/> <KeyBinding Key="R" Modifiers="Ctrl" Command="{Binding RefreshDiscoveryCommand}"/>
<KeyBinding Key="T" Modifiers="Ctrl" Command="{Binding ToggleThemeCommand}"/> <KeyBinding Key="T" Modifiers="Ctrl" Command="{Binding ToggleThemeCommand}"/>
<!-- Ctrl+K — command palette (next session). For now opens the same <!-- Ctrl+K (and Ctrl+P alias) — open the v2 command palette. The
settings drawer surface; replaced with the palette window once KeyBinding fires on the window; the handler is wired via the
it lands. --> palette button's Click in the header. -->
<KeyBinding Key="K" Modifiers="Ctrl" Command="{Binding ShowHelpCommand}"/> <KeyBinding Key="K" Modifiers="Ctrl" Command="{Binding OpenCommandPaletteCommand}"/>
<KeyBinding Key="P" Modifiers="Ctrl" Command="{Binding OpenCommandPaletteCommand}"/>
<KeyBinding Key="NumPad1" Command="{Binding ToggleByIndexCommand}" CommandParameter="1"/> <KeyBinding Key="NumPad1" Command="{Binding ToggleByIndexCommand}" CommandParameter="1"/>
<KeyBinding Key="NumPad2" Command="{Binding ToggleByIndexCommand}" CommandParameter="2"/> <KeyBinding Key="NumPad2" Command="{Binding ToggleByIndexCommand}" CommandParameter="2"/>
<KeyBinding Key="NumPad3" Command="{Binding ToggleByIndexCommand}" CommandParameter="3"/> <KeyBinding Key="NumPad3" Command="{Binding ToggleByIndexCommand}" CommandParameter="3"/>
@ -405,10 +407,31 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Participants table — DataGrid placeholder pending task 39's redesign. <!--
For v2's first cut the existing column structure is kept so the Participants table — v2 "Studio Terminal" layout.
shell rebuild doesn't also change row semantics. The 5-column
"Studio Terminal" table comes in the next commit. --> Five columns:
1. State LED 24px — 8×8 hard-edged square. Filled cyan
when LIVE; filled coral on ERROR;
filled amber on NO SIGNAL / STARTING;
hollow neutral when OFF.
2. Name + caption * — DisplayName (Inter 13/Medium) plus
source machine + state label below
in JetBrains Mono 11/Tertiary.
3. Audio meter 110px — five vertical hard-edged bars,
each lit when DisplayedAudioLevel
crosses its threshold (0.2, 0.4,
0.6, 0.8, 1.0). No averaging.
4. Output name 150px — JetBrains Mono 12 — the NDI source
name TeamsISO broadcasts as.
5. ISO toggle pill 110px — LIVE = cyan-muted fill with cyan
text; OFF = hollow neutral; ERROR
gets the existing trigger swap.
Row height 52 (down from 56). Active speaker = full-row
bg.active-speaker tint set by the global DataGridRow style
(avoids the impeccable side-stripe-border ban).
-->
<Border Grid.Row="3" <Border Grid.Row="3"
Margin="20,0,20,12" Margin="20,0,20,12"
BorderBrush="{DynamicResource Wd.Border}" BorderBrush="{DynamicResource Wd.Border}"
@ -428,22 +451,143 @@
CanUserResizeRows="False" CanUserResizeRows="False"
SelectionMode="Single" SelectionMode="Single"
SelectionUnit="FullRow" SelectionUnit="FullRow"
RowHeight="56"> RowHeight="52">
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTextColumn Header="Name" <!-- Col 1 — State LED. 8×8 hard-edged Rectangle.
Binding="{Binding DisplayName}" Default fill is hollow (transparent with stroke). DataTriggers
Width="*" swap the fill based on StateLabel: LIVE → cyan filled,
IsReadOnly="True"/> NO SIGNAL / STARTING → amber filled, ERROR → coral filled.
<DataGridTextColumn Header="Source" No rounding — broadcast vocabulary uses sharp LEDs. -->
Binding="{Binding SourceFullName}" <DataGridTemplateColumn Header="" Width="24" IsReadOnly="True">
Width="*" <DataGridTemplateColumn.CellTemplate>
IsReadOnly="True"/> <DataTemplate>
<DataGridTemplateColumn Header="ISO" Width="120"> <Rectangle Width="8" Height="8"
HorizontalAlignment="Center"
VerticalAlignment="Center"
StrokeThickness="1.5">
<Rectangle.Style>
<Style TargetType="Rectangle">
<Setter Property="Fill" Value="Transparent"/>
<Setter Property="Stroke" Value="{DynamicResource Wd.BorderStrong}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding StateLabel}" Value="LIVE">
<Setter Property="Fill" Value="{DynamicResource Wd.Accent.Cyan}"/>
<Setter Property="Stroke" Value="{DynamicResource Wd.Accent.Cyan}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StateLabel}" Value="ERROR">
<Setter Property="Fill" Value="{DynamicResource Wd.Accent.Coral}"/>
<Setter Property="Stroke" Value="{DynamicResource Wd.Accent.Coral}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StateLabel}" Value="NO SIGNAL">
<Setter Property="Fill" Value="{DynamicResource Wd.Status.Warn}"/>
<Setter Property="Stroke" Value="{DynamicResource Wd.Status.Warn}"/>
</DataTrigger>
<DataTrigger Binding="{Binding StateLabel}" Value="STARTING">
<Setter Property="Fill" Value="{DynamicResource Wd.Status.Warn}"/>
<Setter Property="Stroke" Value="{DynamicResource Wd.Status.Warn}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Rectangle.Style>
</Rectangle>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 2 — Name + caption. Display name (Inter 13/Medium) above,
source machine + state caption (Mono 11/Tertiary) below. -->
<DataGridTemplateColumn Header="Participant" Width="*" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding DisplayName}"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="13"
FontWeight="Medium"
Foreground="{DynamicResource Wd.Text.Primary}"
TextTrimming="CharacterEllipsis"/>
<StackPanel Orientation="Horizontal" Margin="0,2,0,0">
<TextBlock Text="{Binding SourceMachine}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"
TextTrimming="CharacterEllipsis"
MaxWidth="180"/>
<TextBlock Text=" · "
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Disabled}"/>
<TextBlock Text="{Binding StateLabel}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
Foreground="{DynamicResource Wd.Text.Tertiary}"/>
</StackPanel>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 3 — Audio meter. 5 vertical bars; each lit when
DisplayedAudioLevel crosses its threshold. -->
<DataGridTemplateColumn Header="Audio" Width="110" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal"
VerticalAlignment="Center"
HorizontalAlignment="Left">
<Rectangle Width="6" Height="6"
Margin="0,0,3,0"
Fill="{DynamicResource Wd.Accent.Cyan}"
Opacity="{Binding DisplayedAudioLevel, Converter={StaticResource LevelGate}, ConverterParameter=0.2}"
VerticalAlignment="Bottom"/>
<Rectangle Width="6" Height="10"
Margin="0,0,3,0"
Fill="{DynamicResource Wd.Accent.Cyan}"
Opacity="{Binding DisplayedAudioLevel, Converter={StaticResource LevelGate}, ConverterParameter=0.4}"
VerticalAlignment="Bottom"/>
<Rectangle Width="6" Height="14"
Margin="0,0,3,0"
Fill="{DynamicResource Wd.Accent.Cyan}"
Opacity="{Binding DisplayedAudioLevel, Converter={StaticResource LevelGate}, ConverterParameter=0.6}"
VerticalAlignment="Bottom"/>
<Rectangle Width="6" Height="18"
Margin="0,0,3,0"
Fill="{DynamicResource Wd.Status.Warn}"
Opacity="{Binding DisplayedAudioLevel, Converter={StaticResource LevelGate}, ConverterParameter=0.8}"
VerticalAlignment="Bottom"/>
<Rectangle Width="6" Height="22"
Fill="{DynamicResource Wd.Accent.Coral}"
Opacity="{Binding DisplayedAudioLevel, Converter={StaticResource LevelGate}, ConverterParameter=0.95}"
VerticalAlignment="Bottom"/>
</StackPanel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 4 — Output name (mono). The NDI source name TeamsISO
will broadcast this participant as. -->
<DataGridTemplateColumn Header="Output" Width="150" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding OutputName}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Secondary}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<!-- Col 5 — ISO toggle pill. LIVE = cyan-muted fill + cyan border + cyan text.
OFF = hollow neutral. Error states use the existing IsoToggle style. -->
<DataGridTemplateColumn Header="ISO" Width="110">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate> <DataTemplate>
<Button Command="{Binding ToggleIsoCommand}" <Button Command="{Binding ToggleIsoCommand}"
Margin="0,0,12,0" Margin="0,0,12,0"
Padding="14,6"> Padding="14,6"
VerticalAlignment="Center">
<Button.Style> <Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource Wd.Button.IsoToggle}"> <Style TargetType="Button" BasedOn="{StaticResource Wd.Button.IsoToggle}">
<Setter Property="Content" Value="Enable"/> <Setter Property="Content" Value="Enable"/>

View file

@ -23,6 +23,10 @@ public partial class MainWindow : Window
public MainWindow(MainViewModel viewModel) : this() public MainWindow(MainViewModel viewModel) : this()
{ {
DataContext = viewModel; DataContext = viewModel;
// Hand the view-model the palette-opener callback so Ctrl+K's
// KeyBinding (which lives on the VM as an ICommand) can reach
// back into the view layer to materialize the window.
viewModel.RegisterCommandPaletteOpener(() => OnCommandPaletteClick(this, new RoutedEventArgs()));
} }
/// <summary> /// <summary>
@ -218,16 +222,18 @@ public partial class MainWindow : Window
} }
/// <summary> /// <summary>
/// Command palette placeholder. Task 40 builds the real Ctrl+K floating /// Open the v2 Ctrl+K command palette. Bound to the header ⌘K button and
/// window; for the v2 shell commit, the header button opens the same /// to the Ctrl+K keyboard binding. The palette is a chromeless floating
/// help dialog that F1 does so the affordance isn't dead. /// window owned by this MainWindow so it centers correctly, closes on
/// Deactivated (click outside), and inherits z-order. We construct a
/// fresh view-model each time so the filter starts empty.
/// </summary> /// </summary>
private void OnCommandPaletteClick(object sender, RoutedEventArgs e) private void OnCommandPaletteClick(object sender, RoutedEventArgs e)
{ {
if (DataContext is MainViewModel vm && vm.ShowHelpCommand.CanExecute(null)) if (DataContext is not MainViewModel vm) return;
{ var paletteVm = new ViewModels.CommandPaletteViewModel(vm, Dispatcher);
vm.ShowHelpCommand.Execute(null); var palette = new Views.CommandPaletteWindow(paletteVm) { Owner = this };
} palette.ShowDialog();
} }
/// <summary> /// <summary>

View file

@ -738,13 +738,12 @@
<Trigger Property="IsSelected" Value="True"> <Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource Wd.SurfaceElevated}"/> <Setter Property="Background" Value="{DynamicResource Wd.SurfaceElevated}"/>
</Trigger> </Trigger>
<!-- Active speaker highlight: set by MainViewModel at the 1Hz <!-- Active speaker highlight (v2): full-row cyan-muted background
stats tick based on DisplayedAudioLevel. Cyan left border tint. NOT a left-edge stripe — that would trip the impeccable
(3px) makes whoever's talking pop without changing the side-stripe-border ban and reads as AI-design vocabulary.
background color (which IsMouseOver / IsSelected own). --> Full-row tint is bigger and broadcast-engineering-vocabulary:
the row whose channel is hot stands out across the screen. -->
<DataTrigger Binding="{Binding IsActiveSpeaker}" Value="True"> <DataTrigger Binding="{Binding IsActiveSpeaker}" Value="True">
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Accent.Cyan}"/>
<Setter Property="BorderThickness" Value="3,0,0,1"/>
<Setter Property="Background" Value="{DynamicResource Wd.Accent.CyanMuted}"/> <Setter Property="Background" Value="{DynamicResource Wd.Accent.CyanMuted}"/>
</DataTrigger> </DataTrigger>
</Style.Triggers> </Style.Triggers>

View file

@ -0,0 +1,210 @@
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();
}
private 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);

View file

@ -166,6 +166,23 @@ public sealed class MainViewModel : ObservableObject, IDisposable
/// </summary> /// </summary>
public RelayCommand ToggleThemeCommand { get; } public RelayCommand ToggleThemeCommand { get; }
/// <summary>
/// Ctrl+K binding — opens the v2 command palette. The actual window
/// open call lives in <see cref="MainWindow"/> (view-side concern);
/// this command delegates through an Action callback the view sets
/// after construction so the VM stays unaware of WPF Window types.
/// </summary>
public RelayCommand OpenCommandPaletteCommand { get; }
private Action? _openCommandPalette;
/// <summary>
/// Wire the view's palette-opening callback. Called by MainWindow's
/// constructor right after DataContext is set. Idempotent — second
/// call replaces the first.
/// </summary>
public void RegisterCommandPaletteOpener(Action openPalette) =>
_openCommandPalette = openPalette;
/// <summary>Opens the inline notes viewer for today's show-notes file.</summary> /// <summary>Opens the inline notes viewer for today's show-notes file.</summary>
public RelayCommand ShowNotesCommand { get; } public RelayCommand ShowNotesCommand { get; }
@ -368,6 +385,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
Services.ThemeManager.Current.Toggle(); Services.ThemeManager.Current.Toggle();
}); });
OpenCommandPaletteCommand = new RelayCommand(() => _openCommandPalette?.Invoke());
ShowHelpCommand = new RelayCommand(() => ShowHelpCommand = new RelayCommand(() =>
{ {
// Showing a Window from a VM violates strict MVVM, but TeamsISO doesn't // Showing a Window from a VM violates strict MVVM, but TeamsISO doesn't

View file

@ -419,9 +419,27 @@ public sealed class ParticipantViewModel : ObservableObject
public string CustomName public string CustomName
{ {
get => _customName; get => _customName;
set => SetField(ref _customName, value); set
{
if (SetField(ref _customName, value))
OnPropertyChanged(nameof(OutputName));
}
} }
/// <summary>
/// The NDI source name TeamsISO will broadcast this participant as. Prefers
/// the operator's <see cref="CustomName"/> when set; otherwise renders the
/// engine's default template (typically <c>TEAMSISO_{guid}</c>). Bound by
/// the v2 participants table's mono "output name" column.
/// </summary>
public string OutputName =>
string.IsNullOrWhiteSpace(_customName)
? Services.OutputNameTemplate.Render(
Services.OutputNameTemplate.Get(),
_participant.Id,
_participant.DisplayName)
: _customName;
public AsyncRelayCommand ToggleIsoCommand { get; } public AsyncRelayCommand ToggleIsoCommand { get; }
/// <summary>Copy the participant's NDI source FullName to the clipboard. Useful for pasting into Studio Monitor.</summary> /// <summary>Copy the participant's NDI source FullName to the clipboard. Useful for pasting into Studio Monitor.</summary>

View file

@ -0,0 +1,198 @@
<Window x:Class="TeamsISO.App.Views.CommandPaletteWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels"
xmlns:conv="clr-namespace:TeamsISO.App.Converters"
Width="560" Height="360"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
AllowsTransparency="True"
ShowInTaskbar="False"
Background="Transparent"
ResizeMode="NoResize"
Topmost="True"
d:DataContext="{d:DesignInstance Type=vm:CommandPaletteViewModel}"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Window.Resources>
<conv:NullToCollapsedConverter x:Key="NullToCollapsed"/>
</Window.Resources>
<!--
v2 command palette — Ctrl+K / Ctrl+P. The navigation surface for the
Studio Terminal redesign: operators with attention budgets in the
low single digits type two letters, press Enter, action fires.
Chromeless window so the palette feels like a popover, not another
OS-level top-level. The single rounded card inside is the entire
affordance; the host window background is Transparent and the card
sits centered. Esc closes the window from code-behind.
-->
<Border CornerRadius="{StaticResource Radius.L}"
Background="{DynamicResource Wd.SurfaceElevated}"
BorderBrush="{DynamicResource Wd.BorderStrong}"
BorderThickness="1"
SnapsToDevicePixels="True">
<Border.Effect>
<DropShadowEffect BlurRadius="32" ShadowDepth="0" Opacity="0.35" Color="Black"/>
</Border.Effect>
<DockPanel LastChildFill="True">
<!-- Search input — autofocused on open. The placeholder doubles
as a help hint until the operator types. -->
<Border DockPanel.Dock="Top"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="0,0,0,1"
Padding="16,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="⌘"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="14"
Foreground="{DynamicResource Wd.Text.Tertiary}"
VerticalAlignment="Center"
Margin="0,0,12,0"/>
<TextBox Grid.Column="1"
x:Name="FilterBox"
Text="{Binding Filter, UpdateSourceTrigger=PropertyChanged, Delay=20}"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="14"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource Wd.Text.Primary}"
CaretBrush="{DynamicResource Wd.Accent.Cyan}"
VerticalAlignment="Center"
Padding="0">
<TextBox.Style>
<Style TargetType="TextBox">
<Style.Resources>
<VisualBrush x:Key="PlaceholderBrush" TileMode="None" Stretch="None" AlignmentX="Left" AlignmentY="Center">
<VisualBrush.Visual>
<TextBlock Text="Type a command…"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="14"
Foreground="{DynamicResource Wd.Text.Tertiary}"/>
</VisualBrush.Visual>
</VisualBrush>
</Style.Resources>
<Style.Triggers>
<Trigger Property="Text" Value="">
<Setter Property="Background" Value="{StaticResource PlaceholderBrush}"/>
</Trigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
<TextBlock Grid.Column="2"
Text="esc to close"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="10"
Foreground="{DynamicResource Wd.Text.Disabled}"
VerticalAlignment="Center"
Margin="12,0,0,0"/>
</Grid>
</Border>
<!-- Footer hint -->
<Border DockPanel.Dock="Bottom"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="0,1,0,0"
Padding="16,8">
<TextBlock FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="10"
Foreground="{DynamicResource Wd.Text.Tertiary}">
<Run Text="↑ ↓ navigate"/>
<Run Text="·" Foreground="{DynamicResource Wd.Text.Disabled}"/>
<Run Text="enter invoke"/>
<Run Text="·" Foreground="{DynamicResource Wd.Text.Disabled}"/>
<Run Text="esc close"/>
</TextBlock>
</Border>
<!-- Results list — bound to CommandPaletteViewModel.Visible -->
<ListBox x:Name="ResultsList"
ItemsSource="{Binding Visible}"
SelectedItem="{Binding Selected}"
Background="Transparent"
BorderThickness="0"
Padding="6,6,6,6"
Focusable="False"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Auto"
HorizontalContentAlignment="Stretch">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="Margin" Value="0,1"/>
<Setter Property="Focusable" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
CornerRadius="{StaticResource Radius.S}"
Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.Accent.CyanMuted}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.SurfaceHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type vm:PaletteCommand}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding Category}"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="10"
FontWeight="SemiBold"
Foreground="{DynamicResource Wd.Text.Tertiary}"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="1"
Text="{Binding Label}"
FontFamily="{StaticResource Wd.Font.Sans}"
FontSize="13"
Foreground="{DynamicResource Wd.Text.Primary}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
<Border Grid.Column="2"
Background="{DynamicResource Wd.Surface}"
BorderBrush="{DynamicResource Wd.Border}"
BorderThickness="1"
CornerRadius="{StaticResource Radius.S}"
Padding="6,1"
VerticalAlignment="Center"
Margin="12,0,0,0"
Visibility="{Binding Shortcut, Converter={StaticResource NullToCollapsed}}">
<TextBlock Text="{Binding Shortcut}"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="10"
Foreground="{DynamicResource Wd.Text.Secondary}"/>
</Border>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
</Window>

View file

@ -0,0 +1,84 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using TeamsISO.App.ViewModels;
namespace TeamsISO.App.Views;
/// <summary>
/// The Ctrl+K command palette window. Centered over its <see cref="Window.Owner"/>,
/// chromeless, dismissable with Esc or clicking outside the palette card.
///
/// Keyboard contract:
/// <list type="bullet">
/// <item>Type to filter (autofocus on the TextBox at open time)</item>
/// <item>↑ / ↓ — move the selection</item>
/// <item>Enter — invoke the highlighted command, then close</item>
/// <item>Esc — close without invoking</item>
/// </list>
/// </summary>
public partial class CommandPaletteWindow : Window
{
public CommandPaletteWindow()
{
InitializeComponent();
// Autofocus the filter input on open so the operator can start typing
// immediately — the whole point of Ctrl+K is "no mouse required".
Loaded += (_, _) => FilterBox.Focus();
// Closing on click-outside: WindowStyle=None means we don't get the
// standard "deactivated" behavior cleanly, but Deactivated still
// fires when focus leaves the window (e.g., clicking the main shell).
Deactivated += (_, _) => Close();
}
public CommandPaletteWindow(CommandPaletteViewModel vm) : this()
{
DataContext = vm;
}
private CommandPaletteViewModel Vm => (CommandPaletteViewModel)DataContext;
/// <summary>
/// Window-level key handling. We use PreviewKeyDown so the TextBox's
/// internal handling doesn't swallow ↑/↓/Enter before we see them.
/// </summary>
protected override void OnPreviewKeyDown(System.Windows.Input.KeyEventArgs e)
{
switch (e.Key)
{
case Key.Escape:
e.Handled = true;
Close();
break;
case Key.Down:
e.Handled = true;
Vm?.MoveSelection(+1);
ScrollSelectedIntoView();
break;
case Key.Up:
e.Handled = true;
Vm?.MoveSelection(-1);
ScrollSelectedIntoView();
break;
case Key.Enter:
e.Handled = true;
var invoked = Vm?.InvokeSelection() ?? false;
// Close regardless — Enter on an empty palette is "I'm done";
// Enter on a real command means the action fired and the
// operator wants to return to the main shell.
if (invoked) Close();
break;
default:
base.OnPreviewKeyDown(e);
break;
}
}
private void ScrollSelectedIntoView()
{
if (ResultsList is null) return;
var sel = ResultsList.SelectedItem;
if (sel is null) return;
ResultsList.ScrollIntoView(sel);
}
}