feat(ui): chromeless title bar with custom caption controls
All checks were successful
CI / build-and-test (push) Successful in 37s

MainWindow drops the standard Windows title bar (WindowStyle=None + WindowChrome with CaptionHeight=44, ResizeBorderThickness=6, UseAeroCaptionButtons=False) and draws its own minimize / maximize-restore / close buttons inline in the existing header strip. The custom buttons opt into shell:WindowChrome.IsHitTestVisibleInChrome=True so clicks fire on them rather than starting a window drag.

Result: the entire top of the window is now ours, matching the Microsoft Teams desktop client's flush header look. The 'TeamsISO + by Wild Dragon' branding sits at the same baseline as the engine-status pill and the caption controls, and dragging anywhere not occupied by an interactive widget moves the window.

Caption-button styles in the theme: 46x32 hover-tinted, with the close button turning the Windows 11 #C42B1C red on hover. Maximize-button glyph swaps between the single-rectangle and overlapping-rectangles variants on StateChanged.

Drive-by: ParticipantViewModel.{FramesIn,FramesOut,IncomingResolution} setters dropped from private to public so {Run Text=...} bindings (which default to TwoWay on Run) can attach without WPF throwing 'cannot work on read-only property'.
This commit is contained in:
Zac Gaetano 2026-05-08 00:55:57 -04:00
parent 9c231118de
commit bab29b02ab
4 changed files with 162 additions and 18 deletions

View file

@ -3,14 +3,33 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels" xmlns:vm="clr-namespace:TeamsISO.App.ViewModels"
xmlns:conv="clr-namespace:TeamsISO.App.Converters" xmlns:conv="clr-namespace:TeamsISO.App.Converters"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="TeamsISO" Title="TeamsISO"
Height="780" Width="1280" Height="780" Width="1280"
MinHeight="640" MinWidth="1080" MinHeight="640" MinWidth="1080"
Background="{DynamicResource Wd.Canvas}" Background="{DynamicResource Wd.Canvas}"
WindowStyle="None"
ResizeMode="CanResize"
UseLayoutRounding="True" UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal" TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="ClearType"> TextOptions.TextRenderingMode="ClearType">
<!--
Chromeless window chrome — removes the standard Windows title bar so we can
draw our own header that matches the Teams flush look. CaptionHeight=44 makes
the top 44px of the window act as a drag region. ResizeBorderThickness=6
keeps edge-resize working. UseAeroCaptionButtons=False disables the OS-drawn
min/max/close (we draw our own).
-->
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome
CaptionHeight="44"
ResizeBorderThickness="6"
CornerRadius="0"
GlassFrameThickness="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<Window.Resources> <Window.Resources>
<conv:BoolToVisibilityConverter x:Key="BoolToVis"/> <conv:BoolToVisibilityConverter x:Key="BoolToVis"/>
<conv:EnumDescriptionConverter x:Key="EnumDesc"/> <conv:EnumDescriptionConverter x:Key="EnumDesc"/>
@ -168,21 +187,66 @@
</Border> </Border>
</StackPanel> </StackPanel>
<Border Grid.Column="1" <StackPanel Grid.Column="1"
Style="{StaticResource Wd.Pill}" Orientation="Horizontal"
Background="{DynamicResource Wd.Status.LiveBg}" VerticalAlignment="Center">
VerticalAlignment="Center"> <Border Style="{StaticResource Wd.Pill}"
<StackPanel Orientation="Horizontal"> Background="{DynamicResource Wd.Status.LiveBg}"
<Ellipse Width="7" Height="7" VerticalAlignment="Center"
Fill="{DynamicResource Wd.Status.Live}" Margin="0,0,16,0">
VerticalAlignment="Center"/> <StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding StatusText}" <Ellipse Width="7" Height="7"
Style="{StaticResource Wd.Text.Body}" Fill="{DynamicResource Wd.Status.Live}"
FontSize="12" VerticalAlignment="Center"/>
Margin="8,0,0,0" <TextBlock Text="{Binding StatusText}"
VerticalAlignment="Center"/> Style="{StaticResource Wd.Text.Body}"
</StackPanel> FontSize="12"
</Border> Margin="8,0,0,0"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!--
Custom window control buttons. WindowChrome.IsHitTestVisibleInChrome
tells the chrome that clicks here are real clicks, not drag-region
clicks, so the buttons fire instead of starting a window drag.
-->
<Button x:Name="MinimizeButton"
Style="{StaticResource Wd.Button.Caption}"
Click="OnMinimize"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
ToolTip="Minimize">
<Path Data="M 0,5 L 10,5"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Width="10" Height="10"
Stretch="None"/>
</Button>
<Button x:Name="MaximizeButton"
Style="{StaticResource Wd.Button.Caption}"
Click="OnMaximizeRestore"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
ToolTip="Maximize">
<Path x:Name="MaximizeIcon"
Data="M 0,0 L 10,0 L 10,10 L 0,10 Z"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Fill="Transparent"
Width="10" Height="10"
Stretch="None"/>
</Button>
<Button x:Name="CloseButton"
Style="{StaticResource Wd.Button.CaptionClose}"
Click="OnClose"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
ToolTip="Close">
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Width="10" Height="10"
Stretch="None"/>
</Button>
</StackPanel>
</Grid> </Grid>
</Border> </Border>

View file

@ -1,4 +1,5 @@
using System.Windows; using System.Windows;
using System.Windows.Shapes;
using TeamsISO.App.ViewModels; using TeamsISO.App.ViewModels;
namespace TeamsISO.App; namespace TeamsISO.App;
@ -8,10 +9,37 @@ public partial class MainWindow : Window
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
StateChanged += OnWindowStateChanged;
} }
public MainWindow(MainViewModel viewModel) : this() public MainWindow(MainViewModel viewModel) : this()
{ {
DataContext = viewModel; DataContext = viewModel;
} }
/// <summary>Custom min button — chrome'd window has no system caption buttons.</summary>
private void OnMinimize(object sender, RoutedEventArgs e) =>
WindowState = WindowState.Minimized;
/// <summary>Toggles maximize/restore. Bound to the maximize button + double-click on the drag region.</summary>
private void OnMaximizeRestore(object sender, RoutedEventArgs e) =>
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
/// <summary>Custom close button.</summary>
private void OnClose(object sender, RoutedEventArgs e) => Close();
/// <summary>
/// Swap the maximize-button glyph between the "single rectangle" (when normal) and the
/// "two-overlapping-rectangles" (when maximized) variants, matching the Windows 11
/// caption-button conventions.
/// </summary>
private void OnWindowStateChanged(object? sender, EventArgs e)
{
if (FindName("MaximizeIcon") is not Path icon) return;
icon.Data = WindowState == WindowState.Maximized
// Two-rectangle "restore" glyph
? System.Windows.Media.Geometry.Parse("M 2,0 L 10,0 L 10,8 M 0,2 L 8,2 L 8,10 L 0,10 Z")
// Single-rectangle "maximize" glyph
: System.Windows.Media.Geometry.Parse("M 0,0 L 10,0 L 10,10 L 0,10 Z");
}
} }

View file

@ -186,6 +186,58 @@
</Setter> </Setter>
</Style> </Style>
<!--
Window-caption buttons: minimize / maximize / close. Match the Windows 11
Teams look — slim 46x32, hover-tinted, Close turns red on hover.
-->
<Style x:Key="Wd.Button.Caption" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Width" Value="46"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.SurfaceHover}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Wd.SurfaceActive}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Wd.Button.CaptionClose" TargetType="Button" BasedOn="{StaticResource Wd.Button.Caption}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#C42B1C"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#A52A1F"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Rail-icon button: square 48x48, used on the Teams-style left rail --> <!-- Rail-icon button: square 48x48, used on the Teams-style left rail -->
<Style x:Key="Wd.Button.RailIcon" TargetType="Button"> <Style x:Key="Wd.Button.RailIcon" TargetType="Button">
<Setter Property="Background" Value="Transparent"/> <Setter Property="Background" Value="Transparent"/>

View file

@ -41,13 +41,13 @@ public sealed class ParticipantViewModel : ObservableObject
private string _incomingResolution = "—"; private string _incomingResolution = "—";
/// <summary>Number of frames the receiver has captured so far.</summary> /// <summary>Number of frames the receiver has captured so far.</summary>
public long FramesIn { get => _framesIn; private set => SetField(ref _framesIn, value); } public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); }
/// <summary>Number of frames the sender has emitted so far.</summary> /// <summary>Number of frames the sender has emitted so far.</summary>
public long FramesOut { get => _framesOut; private set => SetField(ref _framesOut, value); } public long FramesOut { get => _framesOut; set => SetField(ref _framesOut, value); }
/// <summary>Source resolution as "WxH", or em-dash when no frames have been seen yet.</summary> /// <summary>Source resolution as "WxH", or em-dash when no frames have been seen yet.</summary>
public string IncomingResolution { get => _incomingResolution; private set => SetField(ref _incomingResolution, value); } public string IncomingResolution { get => _incomingResolution; set => SetField(ref _incomingResolution, value); }
/// <summary>Updates the live stats display from a controller-side snapshot.</summary> /// <summary>Updates the live stats display from a controller-side snapshot.</summary>
public void UpdateStats(IsoHealthStats stats) public void UpdateStats(IsoHealthStats stats)