feat(ui): chromeless title bar with custom caption controls
All checks were successful
CI / build-and-test (push) Successful in 37s
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:
parent
9c231118de
commit
bab29b02ab
4 changed files with 162 additions and 18 deletions
|
|
@ -3,14 +3,33 @@
|
|||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:TeamsISO.App.ViewModels"
|
||||
xmlns:conv="clr-namespace:TeamsISO.App.Converters"
|
||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||
Title="TeamsISO"
|
||||
Height="780" Width="1280"
|
||||
MinHeight="640" MinWidth="1080"
|
||||
Background="{DynamicResource Wd.Canvas}"
|
||||
WindowStyle="None"
|
||||
ResizeMode="CanResize"
|
||||
UseLayoutRounding="True"
|
||||
TextOptions.TextFormattingMode="Ideal"
|
||||
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>
|
||||
<conv:BoolToVisibilityConverter x:Key="BoolToVis"/>
|
||||
<conv:EnumDescriptionConverter x:Key="EnumDesc"/>
|
||||
|
|
@ -168,21 +187,66 @@
|
|||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Column="1"
|
||||
Style="{StaticResource Wd.Pill}"
|
||||
Background="{DynamicResource Wd.Status.LiveBg}"
|
||||
VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="7" Height="7"
|
||||
Fill="{DynamicResource Wd.Status.Live}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding StatusText}"
|
||||
Style="{StaticResource Wd.Text.Body}"
|
||||
FontSize="12"
|
||||
Margin="8,0,0,0"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
VerticalAlignment="Center">
|
||||
<Border Style="{StaticResource Wd.Pill}"
|
||||
Background="{DynamicResource Wd.Status.LiveBg}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,16,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Ellipse Width="7" Height="7"
|
||||
Fill="{DynamicResource Wd.Status.Live}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding StatusText}"
|
||||
Style="{StaticResource Wd.Text.Body}"
|
||||
FontSize="12"
|
||||
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>
|
||||
</Border>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Shapes;
|
||||
using TeamsISO.App.ViewModels;
|
||||
|
||||
namespace TeamsISO.App;
|
||||
|
|
@ -8,10 +9,37 @@ public partial class MainWindow : Window
|
|||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
StateChanged += OnWindowStateChanged;
|
||||
}
|
||||
|
||||
public MainWindow(MainViewModel viewModel) : this()
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,6 +186,58 @@
|
|||
</Setter>
|
||||
</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 -->
|
||||
<Style x:Key="Wd.Button.RailIcon" TargetType="Button">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
|
|
|
|||
|
|
@ -41,13 +41,13 @@ public sealed class ParticipantViewModel : ObservableObject
|
|||
private string _incomingResolution = "—";
|
||||
|
||||
/// <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>
|
||||
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>
|
||||
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>
|
||||
public void UpdateStats(IsoHealthStats stats)
|
||||
|
|
|
|||
Loading…
Reference in a new issue