polish(mainwindow): empty state, table widths, strings, theme tooltip

Walks the v2 polish punch list against MainWindow.

- Theme button tooltip is now "Theme (System / Dark / Light)" per the
  v2 shape brief, replacing the previous "Toggle theme (Ctrl+T)".
- Participants table column widths match spec: Output 130px (was 150),
  ISO pill 100px (was 110). The 24px state LED, 110px audio meter, and
  52px row height already matched. The 106px Preview thumbnail column
  and 32px gear-button column are intentional deviations (live thumbs
  were restored at 4944de5; per-ISO override gear added at the same
  time) and are now called out in the column-spec comment so a future
  reader doesn't try to "fix" them.
- Empty-state placeholder finally renders when ParticipantCount == 0:
  mono sentence "no ndi sources yet — open teams and start a meeting"
  + a tertiary Refresh discovery button — exactly the copy specified
  by the shape brief's empty-states section. CountToVisibilityConverter
  is now declared in MainWindow.Resources (it shipped as a class but
  was never registered).
- OnClosing wraps WindowStateStore.Save in a try/catch so a serialization
  or filesystem fault on shutdown can never block the window from
  closing. Save itself already swallows its own IO errors; this is
  defense-in-depth for anything that escapes.
- MessageBox copy in MainWindow.xaml.cs (Hide/show Teams, Launch Teams,
  Stop Teams) moves to Properties/Strings.resx + a hand-written
  Properties/Strings.Designer.cs accessor. ResourceManager reads it by
  basename "TeamsISO.App.Properties.Strings"; LogicalName is set on the
  EmbeddedResource so the manifest name is predictable regardless of
  how MSBuild would otherwise compute it. Future-localization seam.
  OnLaunchTeamsRightClick's confirmation dialog is intentional — it
  guards a destructive mid-show action — and the code-behind comment
  now says so; the palette also offers Stop Teams as the keyboard
  surface, so the right-click affordance isn't the only one.

Build clean (0 warnings, 0 errors); 160 tests still pass (56 App +
104 Engine, Category!=ndi&requires!=ndi filter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-15 19:26:23 -04:00
parent 37390026b3
commit 33fca8e955
5 changed files with 186 additions and 18 deletions

View file

@ -39,6 +39,7 @@
FalseValue="Visible"/> FalseValue="Visible"/>
<conv:EnumDescriptionConverter x:Key="EnumDesc"/> <conv:EnumDescriptionConverter x:Key="EnumDesc"/>
<conv:LevelThresholdConverter x:Key="LevelGate"/> <conv:LevelThresholdConverter x:Key="LevelGate"/>
<conv:CountToVisibilityConverter x:Key="CountToVis"/>
</Window.Resources> </Window.Resources>
<Window.InputBindings> <Window.InputBindings>
@ -138,7 +139,7 @@
Command="{Binding ToggleThemeCommand}" Command="{Binding ToggleThemeCommand}"
Padding="6,4" Padding="6,4"
Margin="0,0,2,0" Margin="0,0,2,0"
ToolTip="Toggle theme (Ctrl+T)"> ToolTip="Theme (System / Dark / Light)">
<Path Data="M 8,2 C 8,2 4,4 4,8 C 4,12 8,14 8,14 C 5,14 2,11 2,8 C 2,5 5,2 8,2 Z" <Path Data="M 8,2 C 8,2 4,4 4,8 C 4,12 8,14 8,14 C 5,14 2,11 2,8 C 2,5 5,2 8,2 Z"
Stroke="{DynamicResource Wd.Text.Secondary}" Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.4" StrokeThickness="1.4"
@ -410,7 +411,7 @@
<!-- <!--
Participants table — v2 "Studio Terminal" layout. Participants table — v2 "Studio Terminal" layout.
Five columns: Spec columns (from docs/shapes/2026-05-13-…studio-terminal.md):
1. State LED 24px — 8×8 hard-edged square. Filled cyan 1. State LED 24px — 8×8 hard-edged square. Filled cyan
when LIVE; filled coral on ERROR; when LIVE; filled coral on ERROR;
filled amber on NO SIGNAL / STARTING; filled amber on NO SIGNAL / STARTING;
@ -422,12 +423,22 @@
each lit when DisplayedAudioLevel each lit when DisplayedAudioLevel
crosses its threshold (0.2, 0.4, crosses its threshold (0.2, 0.4,
0.6, 0.8, 1.0). No averaging. 0.6, 0.8, 1.0). No averaging.
4. Output name 150px — JetBrains Mono 12 — the NDI source 4. Output name 130px — JetBrains Mono 12 — the NDI source
name TeamsISO broadcasts as. name TeamsISO broadcasts as.
5. ISO toggle pill 110px — LIVE = cyan-muted fill with cyan 5. ISO toggle pill 100px — LIVE = cyan-muted fill with cyan
text; OFF = hollow neutral; ERROR text; OFF = hollow neutral; ERROR
gets the existing trigger swap. gets the existing trigger swap.
Deliberate deviations from the spec (operator preference, see
4944de5 — "restore live thumbnail preview column"):
• A 106px live thumbnail column sits between State LED and
Name. Replaces the table's previous role as the only place
to see what the operator is broadcasting; the pop-out
preview window is the secondary view.
• A 32px ghost-button cell on the right edge of Name opens
the per-ISO override dialog (framerate / resolution /
aspect / audio). Hidden on hover-out.
Row height 52 (down from 56). Active speaker = full-row Row height 52 (down from 56). Active speaker = full-row
bg.active-speaker tint set by the global DataGridRow style bg.active-speaker tint set by the global DataGridRow style
(avoids the impeccable side-stripe-border ban). (avoids the impeccable side-stripe-border ban).
@ -438,6 +449,7 @@
BorderThickness="1" BorderThickness="1"
CornerRadius="{StaticResource Radius.M}" CornerRadius="{StaticResource Radius.M}"
Background="{DynamicResource Wd.Surface}"> Background="{DynamicResource Wd.Surface}">
<Grid>
<DataGrid x:Name="ParticipantsGrid" <DataGrid x:Name="ParticipantsGrid"
ItemsSource="{Binding ParticipantsView}" ItemsSource="{Binding ParticipantsView}"
AutoGenerateColumns="False" AutoGenerateColumns="False"
@ -451,7 +463,8 @@
CanUserResizeRows="False" CanUserResizeRows="False"
SelectionMode="Single" SelectionMode="Single"
SelectionUnit="FullRow" SelectionUnit="FullRow"
RowHeight="52"> RowHeight="52"
Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}}">
<DataGrid.Columns> <DataGrid.Columns>
<!-- Col 1 — State LED. 8×8 hard-edged Rectangle. <!-- Col 1 — State LED. 8×8 hard-edged Rectangle.
Default fill is hollow (transparent with stroke). DataTriggers Default fill is hollow (transparent with stroke). DataTriggers
@ -601,7 +614,7 @@
<!-- Col 4 — Output name (mono). The NDI source name TeamsISO <!-- Col 4 — Output name (mono). The NDI source name TeamsISO
will broadcast this participant as. --> will broadcast this participant as. -->
<DataGridTemplateColumn Header="Output" Width="150" IsReadOnly="True"> <DataGridTemplateColumn Header="Output" Width="130" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate> <DataTemplate>
<TextBlock Text="{Binding OutputName}" <TextBlock Text="{Binding OutputName}"
@ -639,7 +652,7 @@
<!-- Col 5 — ISO toggle pill. LIVE = cyan-muted fill + cyan border + cyan text. <!-- Col 5 — ISO toggle pill. LIVE = cyan-muted fill + cyan border + cyan text.
OFF = hollow neutral. Error states use the existing IsoToggle style. --> OFF = hollow neutral. Error states use the existing IsoToggle style. -->
<DataGridTemplateColumn Header="ISO" Width="110"> <DataGridTemplateColumn Header="ISO" Width="100">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate> <DataTemplate>
<Button Command="{Binding ToggleIsoCommand}" <Button Command="{Binding ToggleIsoCommand}"
@ -665,6 +678,30 @@
</DataGridTemplateColumn> </DataGridTemplateColumn>
</DataGrid.Columns> </DataGrid.Columns>
</DataGrid> </DataGrid>
<!-- Empty-state placeholder. Renders when no NDI participants
have been discovered yet. Mono sentence + one tertiary
Refresh button — no illustration, no mascot, per the v2
shape brief's empty-states section. -->
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}, ConverterParameter=empty}">
<TextBlock Text="no ndi sources yet — open teams and start a meeting"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="12"
Foreground="{DynamicResource Wd.Text.Tertiary}"
HorizontalAlignment="Center"/>
<Button Style="{StaticResource Wd.Button.Ghost}"
Command="{Binding RefreshDiscoveryCommand}"
Content="Refresh discovery (Ctrl+R)"
Padding="14,7"
Margin="0,14,0,0"
HorizontalAlignment="Center"
FontFamily="{StaticResource Wd.Font.Mono}"
FontSize="11"
ToolTip="Rebuild the NDI finder"/>
</StackPanel>
</Grid>
</Border> </Border>
</Grid> </Grid>

View file

@ -42,7 +42,12 @@ public partial class MainWindow : Window
/// <summary>Persist the placement on close so next launch lands in the same spot.</summary> /// <summary>Persist the placement on close so next launch lands in the same spot.</summary>
private void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e) private void OnClosing(object? sender, System.ComponentModel.CancelEventArgs e)
{ {
WindowStateStore.Save(this); // A failure persisting window state must NEVER block the window from
// closing — operator's shutdown comes first. WindowStateStore.Save
// already swallows its own IO errors; this is defense-in-depth for
// anything that escapes (NRE, future regression, etc.).
try { WindowStateStore.Save(this); }
catch { /* best-effort: forgo placement memory for one launch */ }
} }
/// <summary>Opens the About dialog — version, NDI runtime, build SHA.</summary> /// <summary>Opens the About dialog — version, NDI runtime, build SHA.</summary>
@ -87,8 +92,8 @@ public partial class MainWindow : Window
if (!TeamsLauncher.IsRunning()) if (!TeamsLauncher.IsRunning())
{ {
MessageBox.Show( MessageBox.Show(
"Microsoft Teams isn't running. Click the camera icon above to launch it first.", Properties.Strings.HideShowTeams_NotRunning_Message,
"TeamsISO — Hide / show Teams", Properties.Strings.HideShowTeams_Title,
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Information); MessageBoxImage.Information);
return; return;
@ -125,8 +130,8 @@ public partial class MainWindow : Window
if (!TeamsLauncher.TryLaunch(out var error)) if (!TeamsLauncher.TryLaunch(out var error))
{ {
MessageBox.Show( MessageBox.Show(
$"Could not launch Microsoft Teams.\n\n{error}", string.Format(Properties.Strings.LaunchTeams_Failed_MessageFormat, error),
"TeamsISO — Launch Teams", Properties.Strings.LaunchTeams_Title,
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Warning); MessageBoxImage.Warning);
} }
@ -159,15 +164,19 @@ public partial class MainWindow : Window
/// <summary> /// <summary>
/// Right-click on the Launch button asks to stop Teams. Split out from the /// Right-click on the Launch button asks to stop Teams. Split out from the
/// left-click so a normal click is "open / surface" rather than the previous /// left-click so a normal click is "open / surface" rather than the previous
/// "open OR ambush you with a stop dialog". /// "open OR ambush you with a stop dialog". The confirmation dialog here is
/// intentional — Stop Teams is a destructive mid-show action; explicit
/// confirmation is the right pattern, not the "ambush" anti-pattern that
/// was fixed for left-click. The palette also offers Stop Teams for
/// keyboard-first operators.
/// </summary> /// </summary>
private void OnLaunchTeamsRightClick(object sender, MouseButtonEventArgs e) private void OnLaunchTeamsRightClick(object sender, MouseButtonEventArgs e)
{ {
if (!TeamsLauncher.IsRunning()) return; if (!TeamsLauncher.IsRunning()) return;
var confirm = MessageBox.Show( var confirm = MessageBox.Show(
"Microsoft Teams is currently running.\n\nClose all Teams windows now?", Properties.Strings.StopTeams_Confirm_Message,
"TeamsISO — Stop Teams", Properties.Strings.StopTeams_Title,
MessageBoxButton.YesNo, MessageBoxButton.YesNo,
MessageBoxImage.Question); MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes) return; if (confirm != MessageBoxResult.Yes) return;
@ -177,9 +186,9 @@ public partial class MainWindow : Window
{ {
MessageBox.Show( MessageBox.Show(
asked == 0 asked == 0
? "No Teams windows responded to close." ? Properties.Strings.StopTeams_NoneResponded
: $"Sent close to {asked} Teams window(s); some may still be exiting.", : string.Format(Properties.Strings.StopTeams_AskedFormat, asked),
"TeamsISO — Stop Teams", Properties.Strings.StopTeams_Title,
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Information); MessageBoxImage.Information);
} }

View file

@ -0,0 +1,36 @@
// Hand-written strongly-typed accessor for Properties/Strings.resx. Kept
// out of Visual Studio's "ResXFileCodeGenerator" auto-regeneration loop
// so the .csproj stays simple and the file doesn't churn on every save.
// If you add a key in Strings.resx, add a matching property here.
// The compiler treats `*.Designer.cs` as auto-generated and refuses
// nullable annotations without an explicit directive — opt in.
#nullable enable
using System.Globalization;
using System.Resources;
namespace TeamsISO.App.Properties;
internal static class Strings
{
private static readonly ResourceManager ResourceManager = new(
baseName: "TeamsISO.App.Properties.Strings",
assembly: typeof(Strings).Assembly);
public static CultureInfo? Culture { get; set; }
private static string Get(string key) =>
ResourceManager.GetString(key, Culture) ?? string.Empty;
public static string HideShowTeams_Title => Get(nameof(HideShowTeams_Title));
public static string HideShowTeams_NotRunning_Message => Get(nameof(HideShowTeams_NotRunning_Message));
public static string LaunchTeams_Title => Get(nameof(LaunchTeams_Title));
public static string LaunchTeams_Failed_MessageFormat => Get(nameof(LaunchTeams_Failed_MessageFormat));
public static string StopTeams_Title => Get(nameof(StopTeams_Title));
public static string StopTeams_Confirm_Message => Get(nameof(StopTeams_Confirm_Message));
public static string StopTeams_NoneResponded => Get(nameof(StopTeams_NoneResponded));
public static string StopTeams_AskedFormat => Get(nameof(StopTeams_AskedFormat));
}

View file

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
User-facing English strings shown by MainWindow's MessageBox prompts.
Pulled out of code-behind so a future localizer has a single seam to
translate. Strings.Designer.cs is a hand-rolled accessor backed by
ResourceManager — no Visual-Studio auto-regeneration needed.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>
<resheader name="version"><value>2.0</value></resheader>
<resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
<resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
<data name="HideShowTeams_Title" xml:space="preserve">
<value>TeamsISO — Hide / show Teams</value>
</data>
<data name="HideShowTeams_NotRunning_Message" xml:space="preserve">
<value>Microsoft Teams isn't running. Click the camera icon above to launch it first.</value>
</data>
<data name="LaunchTeams_Title" xml:space="preserve">
<value>TeamsISO — Launch Teams</value>
</data>
<data name="LaunchTeams_Failed_MessageFormat" xml:space="preserve">
<value>Could not launch Microsoft Teams.
{0}</value>
<comment>{0} = error string from TeamsLauncher.TryLaunch.</comment>
</data>
<data name="StopTeams_Title" xml:space="preserve">
<value>TeamsISO — Stop Teams</value>
</data>
<data name="StopTeams_Confirm_Message" xml:space="preserve">
<value>Microsoft Teams is currently running.
Close all Teams windows now?</value>
</data>
<data name="StopTeams_NoneResponded" xml:space="preserve">
<value>No Teams windows responded to close.</value>
</data>
<data name="StopTeams_AskedFormat" xml:space="preserve">
<value>Sent close to {0} Teams window(s); some may still be exiting.</value>
<comment>{0} = number of windows the launcher asked to close.</comment>
</data>
</root>

View file

@ -39,6 +39,17 @@
</AssemblyAttribute> </AssemblyAttribute>
</ItemGroup> </ItemGroup>
<!--
Strings.resx — user-facing English MessageBox copy. Embedded as a
.NET resource so ResourceManager can resolve TeamsISO.App.Properties.Strings
by basename. Strings.Designer.cs is hand-written (see file comment).
-->
<ItemGroup>
<EmbeddedResource Update="Properties\Strings.resx">
<LogicalName>TeamsISO.App.Properties.Strings.resources</LogicalName>
</EmbeddedResource>
</ItemGroup>
<!-- Wild Dragon brand assets — embedded as resources so the published binary is self-contained. --> <!-- Wild Dragon brand assets — embedded as resources so the published binary is self-contained. -->
<ItemGroup> <ItemGroup>
<Resource Include="Assets\dragon-mark.png" /> <Resource Include="Assets\dragon-mark.png" />