feat(wpf): v2 task 39+40 - studio table redesign + Ctrl+K command palette
Some checks failed
CI / build-and-test (push) Failing after 30s
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:
parent
c27130302f
commit
d282e1b0f8
11 changed files with 784 additions and 33 deletions
|
|
@ -286,6 +286,13 @@ public partial class App : Application
|
|||
}
|
||||
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(
|
||||
"TeamsISO failed to start.\n\nDetails: " + ex,
|
||||
"TeamsISO — startup error",
|
||||
|
|
|
|||
42
src/TeamsISO.App/Converters/LevelThresholdConverter.cs
Normal file
42
src/TeamsISO.App/Converters/LevelThresholdConverter.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace TeamsISO.App.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Maps an audio level (0.0–1.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;
|
||||
}
|
||||
24
src/TeamsISO.App/Converters/NullToCollapsedConverter.cs
Normal file
24
src/TeamsISO.App/Converters/NullToCollapsedConverter.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@
|
|||
TrueValue="Collapsed"
|
||||
FalseValue="Visible"/>
|
||||
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
|
||||
<conv:LevelThresholdConverter x:Key="LevelGate"/>
|
||||
</Window.Resources>
|
||||
|
||||
<Window.InputBindings>
|
||||
|
|
@ -45,10 +46,11 @@
|
|||
<KeyBinding Key="S" Modifiers="Ctrl+Shift" Command="{Binding StopAllIsosCommand}"/>
|
||||
<KeyBinding Key="R" Modifiers="Ctrl" Command="{Binding RefreshDiscoveryCommand}"/>
|
||||
<KeyBinding Key="T" Modifiers="Ctrl" Command="{Binding ToggleThemeCommand}"/>
|
||||
<!-- Ctrl+K — command palette (next session). For now opens the same
|
||||
settings drawer surface; replaced with the palette window once
|
||||
it lands. -->
|
||||
<KeyBinding Key="K" Modifiers="Ctrl" Command="{Binding ShowHelpCommand}"/>
|
||||
<!-- Ctrl+K (and Ctrl+P alias) — open the v2 command palette. The
|
||||
KeyBinding fires on the window; the handler is wired via the
|
||||
palette button's Click in the header. -->
|
||||
<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="NumPad2" Command="{Binding ToggleByIndexCommand}" CommandParameter="2"/>
|
||||
<KeyBinding Key="NumPad3" Command="{Binding ToggleByIndexCommand}" CommandParameter="3"/>
|
||||
|
|
@ -405,10 +407,31 @@
|
|||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Participants table — DataGrid placeholder pending task 39's redesign.
|
||||
For v2's first cut the existing column structure is kept so the
|
||||
shell rebuild doesn't also change row semantics. The 5-column
|
||||
"Studio Terminal" table comes in the next commit. -->
|
||||
<!--
|
||||
Participants table — v2 "Studio Terminal" layout.
|
||||
|
||||
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"
|
||||
Margin="20,0,20,12"
|
||||
BorderBrush="{DynamicResource Wd.Border}"
|
||||
|
|
@ -428,22 +451,143 @@
|
|||
CanUserResizeRows="False"
|
||||
SelectionMode="Single"
|
||||
SelectionUnit="FullRow"
|
||||
RowHeight="56">
|
||||
RowHeight="52">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Name"
|
||||
Binding="{Binding DisplayName}"
|
||||
Width="*"
|
||||
IsReadOnly="True"/>
|
||||
<DataGridTextColumn Header="Source"
|
||||
Binding="{Binding SourceFullName}"
|
||||
Width="*"
|
||||
IsReadOnly="True"/>
|
||||
<DataGridTemplateColumn Header="ISO" Width="120">
|
||||
<!-- Col 1 — State LED. 8×8 hard-edged Rectangle.
|
||||
Default fill is hollow (transparent with stroke). DataTriggers
|
||||
swap the fill based on StateLabel: LIVE → cyan filled,
|
||||
NO SIGNAL / STARTING → amber filled, ERROR → coral filled.
|
||||
No rounding — broadcast vocabulary uses sharp LEDs. -->
|
||||
<DataGridTemplateColumn Header="" Width="24" IsReadOnly="True">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<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>
|
||||
<DataTemplate>
|
||||
<Button Command="{Binding ToggleIsoCommand}"
|
||||
Margin="0,0,12,0"
|
||||
Padding="14,6">
|
||||
Padding="14,6"
|
||||
VerticalAlignment="Center">
|
||||
<Button.Style>
|
||||
<Style TargetType="Button" BasedOn="{StaticResource Wd.Button.IsoToggle}">
|
||||
<Setter Property="Content" Value="Enable"/>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ public partial class MainWindow : Window
|
|||
public MainWindow(MainViewModel viewModel) : this()
|
||||
{
|
||||
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>
|
||||
|
|
@ -218,16 +222,18 @@ public partial class MainWindow : Window
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command palette placeholder. Task 40 builds the real Ctrl+K floating
|
||||
/// window; for the v2 shell commit, the header button opens the same
|
||||
/// help dialog that F1 does so the affordance isn't dead.
|
||||
/// Open the v2 Ctrl+K command palette. Bound to the header ⌘K button and
|
||||
/// to the Ctrl+K keyboard binding. The palette is a chromeless floating
|
||||
/// 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>
|
||||
private void OnCommandPaletteClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is MainViewModel vm && vm.ShowHelpCommand.CanExecute(null))
|
||||
{
|
||||
vm.ShowHelpCommand.Execute(null);
|
||||
}
|
||||
if (DataContext is not MainViewModel vm) return;
|
||||
var paletteVm = new ViewModels.CommandPaletteViewModel(vm, Dispatcher);
|
||||
var palette = new Views.CommandPaletteWindow(paletteVm) { Owner = this };
|
||||
palette.ShowDialog();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -738,13 +738,12 @@
|
|||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter Property="Background" Value="{DynamicResource Wd.SurfaceElevated}"/>
|
||||
</Trigger>
|
||||
<!-- Active speaker highlight: set by MainViewModel at the 1Hz
|
||||
stats tick based on DisplayedAudioLevel. Cyan left border
|
||||
(3px) makes whoever's talking pop without changing the
|
||||
background color (which IsMouseOver / IsSelected own). -->
|
||||
<!-- Active speaker highlight (v2): full-row cyan-muted background
|
||||
tint. NOT a left-edge stripe — that would trip the impeccable
|
||||
side-stripe-border ban and reads as AI-design vocabulary.
|
||||
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">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Wd.Accent.Cyan}"/>
|
||||
<Setter Property="BorderThickness" Value="3,0,0,1"/>
|
||||
<Setter Property="Background" Value="{DynamicResource Wd.Accent.CyanMuted}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
|
|
|
|||
210
src/TeamsISO.App/ViewModels/CommandPaletteViewModel.cs
Normal file
210
src/TeamsISO.App/ViewModels/CommandPaletteViewModel.cs
Normal 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);
|
||||
|
|
@ -166,6 +166,23 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||
/// </summary>
|
||||
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>
|
||||
public RelayCommand ShowNotesCommand { get; }
|
||||
|
||||
|
|
@ -368,6 +385,8 @@ public sealed class MainViewModel : ObservableObject, IDisposable
|
|||
Services.ThemeManager.Current.Toggle();
|
||||
});
|
||||
|
||||
OpenCommandPaletteCommand = new RelayCommand(() => _openCommandPalette?.Invoke());
|
||||
|
||||
ShowHelpCommand = new RelayCommand(() =>
|
||||
{
|
||||
// Showing a Window from a VM violates strict MVVM, but TeamsISO doesn't
|
||||
|
|
|
|||
|
|
@ -419,8 +419,26 @@ public sealed class ParticipantViewModel : ObservableObject
|
|||
public string 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; }
|
||||
|
||||
|
|
|
|||
198
src/TeamsISO.App/Views/CommandPaletteWindow.xaml
Normal file
198
src/TeamsISO.App/Views/CommandPaletteWindow.xaml
Normal 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>
|
||||
84
src/TeamsISO.App/Views/CommandPaletteWindow.xaml.cs
Normal file
84
src/TeamsISO.App/Views/CommandPaletteWindow.xaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue