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"/>
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
<conv:LevelThresholdConverter x:Key="LevelGate"/>
<conv:CountToVisibilityConverter x:Key="CountToVis"/>
</Window.Resources>
<Window.InputBindings>
@ -138,7 +139,7 @@
Command="{Binding ToggleThemeCommand}"
Padding="6,4"
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"
Stroke="{DynamicResource Wd.Text.Secondary}"
StrokeThickness="1.4"
@ -410,7 +411,7 @@
<!--
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
when LIVE; filled coral on ERROR;
filled amber on NO SIGNAL / STARTING;
@ -422,12 +423,22 @@
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
4. Output name 130px — JetBrains Mono 12 — the NDI source
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
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
bg.active-speaker tint set by the global DataGridRow style
(avoids the impeccable side-stripe-border ban).
@ -438,6 +449,7 @@
BorderThickness="1"
CornerRadius="{StaticResource Radius.M}"
Background="{DynamicResource Wd.Surface}">
<Grid>
<DataGrid x:Name="ParticipantsGrid"
ItemsSource="{Binding ParticipantsView}"
AutoGenerateColumns="False"
@ -451,7 +463,8 @@
CanUserResizeRows="False"
SelectionMode="Single"
SelectionUnit="FullRow"
RowHeight="52">
RowHeight="52"
Visibility="{Binding ParticipantCount, Converter={StaticResource CountToVis}}">
<DataGrid.Columns>
<!-- Col 1 — State LED. 8×8 hard-edged Rectangle.
Default fill is hollow (transparent with stroke). DataTriggers
@ -601,7 +614,7 @@
<!-- Col 4 — Output name (mono). The NDI source name TeamsISO
will broadcast this participant as. -->
<DataGridTemplateColumn Header="Output" Width="150" IsReadOnly="True">
<DataGridTemplateColumn Header="Output" Width="130" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding OutputName}"
@ -639,7 +652,7 @@
<!-- 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 Header="ISO" Width="100">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Command="{Binding ToggleIsoCommand}"
@ -665,6 +678,30 @@
</DataGridTemplateColumn>
</DataGrid.Columns>
</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>
</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>
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>
@ -87,8 +92,8 @@ public partial class MainWindow : Window
if (!TeamsLauncher.IsRunning())
{
MessageBox.Show(
"Microsoft Teams isn't running. Click the camera icon above to launch it first.",
"TeamsISO — Hide / show Teams",
Properties.Strings.HideShowTeams_NotRunning_Message,
Properties.Strings.HideShowTeams_Title,
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
@ -125,8 +130,8 @@ public partial class MainWindow : Window
if (!TeamsLauncher.TryLaunch(out var error))
{
MessageBox.Show(
$"Could not launch Microsoft Teams.\n\n{error}",
"TeamsISO — Launch Teams",
string.Format(Properties.Strings.LaunchTeams_Failed_MessageFormat, error),
Properties.Strings.LaunchTeams_Title,
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
@ -159,15 +164,19 @@ public partial class MainWindow : Window
/// <summary>
/// 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
/// "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>
private void OnLaunchTeamsRightClick(object sender, MouseButtonEventArgs e)
{
if (!TeamsLauncher.IsRunning()) return;
var confirm = MessageBox.Show(
"Microsoft Teams is currently running.\n\nClose all Teams windows now?",
"TeamsISO — Stop Teams",
Properties.Strings.StopTeams_Confirm_Message,
Properties.Strings.StopTeams_Title,
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes) return;
@ -177,9 +186,9 @@ public partial class MainWindow : Window
{
MessageBox.Show(
asked == 0
? "No Teams windows responded to close."
: $"Sent close to {asked} Teams window(s); some may still be exiting.",
"TeamsISO — Stop Teams",
? Properties.Strings.StopTeams_NoneResponded
: string.Format(Properties.Strings.StopTeams_AskedFormat, asked),
Properties.Strings.StopTeams_Title,
MessageBoxButton.OK,
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>
</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. -->
<ItemGroup>
<Resource Include="Assets\dragon-mark.png" />