teamsiso/src/TeamsISO.App.WinUI/Views/SettingsDrawer.xaml.cs

276 lines
11 KiB
C#
Raw Normal View History

feat(winui3): ThemeManager service + Settings drawer + Help/About/Onboarding Builds out the secondary surfaces of the redesigned WinUI 3 host. ThemeManager (Services/ThemeManager.cs) Single-source-of-truth for the active theme. Holds the user preference (System / Dark / Light), resolves it to ElementTheme at request, and raises a Themed event when it changes so the MainWindow can push the AppWindow title-bar button colors. Uses Windows.UI.ViewManagement UISettings to follow the OS app-mode when preference is System. Persistence to UIPreferences lands in the engine-wiring commit. MainWindow theme wiring Replaces the per-handler theme toggle with a ThemeManager subscription: click the title-bar sun/moon -> Toggle() -> Themed event -> ApplyResolvedTheme on the visual tree + the title-bar buttons. Glyph cue: sun = "current is Light, click to Dark"; moon = "current is Dark, click to Light." Initial state applied at construction so the first frame matches the preference. SettingsDrawer (Views/SettingsDrawer.xaml + .cs) UserControl that slides in from the right over the participants table. 56px header, NavigationView with five tabs (Appearance, Routing, Display, Control, Advanced), footer with Reset-to-defaults + Apply/Close. Appearance tab has the theme tri-state picker (System / Dark / Light radio group) and an "Accent peek" row showing the four brand accents (cyan / coral / live / warn) as swatches so the operator can verify Wild Dragon brand is respected on a light desk. CloseRequested event signals the host to collapse the drawer. HelpDialog (Views/HelpDialog.xaml + .cs) ContentDialog with the keyboard shortcut cheat sheet, grouped by category (Global / Participants / Look / Control surface). 540px max height with scroll, mono-spaced shortcut labels at left, body text at right. Replaces the WPF host's HelpWindow at parity. AboutDialog (Views/AboutDialog.xaml + .cs) ContentDialog with the Wild Dragon mark, version + host + engine + brand info as label/value rows, and three quick action buttons (open logs folder, open recordings, check for updates). Mirrors the WPF host's AboutWindow. OnboardingDialog (Views/OnboardingDialog.xaml + .cs) Three numbered steps (Install NDI Runtime / Enable Teams NDI / Pick transcoder topology), no carousel, operator-tone copy ("Don't show this again" defaults checked). PrimaryButtonText "Get started", SecondaryButtonText "Skip" so the dialog is skippable from the first frame as the PRODUCT.md anti-references demand. Build clean: dotnet build TeamsISO.App.WinUI -c Debug -> 0 / 0. Next: wire the drawer's CloseRequested into MainWindow (so the settings icon actually opens / collapses the drawer), then attack the runtime activation blocker (Phase 3 of the migration plan).
2026-05-13 00:13:58 -04:00
using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using TeamsISO.App.WinUI.Services;
namespace TeamsISO.App.WinUI.Views;
public sealed partial class SettingsDrawer : UserControl
{
/// <summary>
/// Raised when the operator clicks the close button or hits Esc. The host
/// (MainWindow) handles the actual collapse animation since drawer
/// hosting lives at that level — the drawer just signals intent.
/// </summary>
public event EventHandler? CloseRequested;
public SettingsDrawer()
{
InitializeComponent();
BuildTabStrip();
SelectTab("Appearance");
}
private string _currentTab = "Appearance";
/// <summary>Build the tab buttons imperatively so the parse doesn't
/// trigger a SelectionChanged before the code-behind is ready.</summary>
private void BuildTabStrip()
{
foreach (var (label, key) in new[]
{
("Appearance", "Appearance"),
("Routing", "Routing"),
("Display", "Display"),
("Control", "Control"),
("Advanced", "Advanced"),
})
{
var btn = new Button
{
Content = label,
Tag = key,
Style = (Style)Application.Current.Resources["ButtonTertiary"],
};
btn.Click += (s, _) =>
{
if (s is Button b && b.Tag is string k) SelectTab(k);
};
TabStrip.Children.Add(btn);
}
}
private void SelectTab(string key)
{
_currentTab = key;
foreach (var child in TabStrip.Children)
{
if (child is Button b)
{
var isActive = (b.Tag as string) == key;
b.Foreground = (Microsoft.UI.Xaml.Media.SolidColorBrush)Application.Current.Resources[
isActive ? "AccentCyanText" : "FgSecondary"];
}
}
RebuildTabContent(key);
}
private void RebuildTabContent(string key)
{
TabContent.Children.Clear();
switch (key)
{
case "Appearance": BuildAppearanceTab(); break;
case "Routing": BuildRoutingTab(); break;
case "Display": BuildDisplayTab(); break;
case "Control": BuildControlTab(); break;
case "Advanced": BuildAdvancedTab(); break;
}
}
private void BuildRoutingTab()
{
TabContent.Children.Add(SettingHeader("Routing"));
TabContent.Children.Add(SettingRow("Framerate", "30 fps", "Target framerate the engine normalizes incoming NDI feeds to."));
TabContent.Children.Add(SettingRow("Resolution", "1920 x 1080", "Output resolution applied to every routed participant."));
TabContent.Children.Add(SettingRow("Aspect mode", "Letterbox", "How sources whose aspect ratio doesn't match the target are framed."));
TabContent.Children.Add(SettingRow("Audio routing", "Per-participant", "Embed each participant's audio in their own NDI source."));
TabContent.Children.Add(SettingNote("Settings wired to the engine in a follow-up commit."));
}
private void BuildDisplayTab()
{
TabContent.Children.Add(SettingHeader("Display & participants"));
TabContent.Children.Add(SettingRow("Hide local self", "Yes", "Filter the operator's own preview from the participants table."));
TabContent.Children.Add(SettingRow("Auto-disable on departure", "No", "Keep routing alive when a participant goes offline."));
TabContent.Children.Add(SettingRow("Participant sort", "Join order", "Default sort for the participants table."));
TabContent.Children.Add(SettingRow("Minimize to tray", "No", "Keep TeamsISO running in the system tray when the window is minimized."));
TabContent.Children.Add(SettingRow("Launch Teams on startup", "No", "Start Teams in the background when TeamsISO opens."));
TabContent.Children.Add(SettingRow("Auto-hide Teams windows", "No", "Hide every visible Teams window after launch."));
TabContent.Children.Add(SettingRow("Auto-record on call", "No", "Start recording every active ISO when Teams enters a call."));
}
private void BuildControlTab()
{
TabContent.Children.Add(SettingHeader("External control surface"));
TabContent.Children.Add(SettingRow("REST + WebSocket", "127.0.0.1:9755", "HTTP control surface for Companion / Stream Deck."));
TabContent.Children.Add(SettingRow("OSC bridge", "127.0.0.1:9000", "UDP OSC for TouchOSC / hardware surfaces."));
TabContent.Children.Add(SettingRow("LAN reachable", "No", "Bind the REST surface to all interfaces (warning: no auth)."));
TabContent.Children.Add(SettingNote("Control surface protocol unchanged from the WPF host. See docs/CONTROL-SURFACE.md."));
}
private void BuildAdvancedTab()
{
TabContent.Children.Add(SettingHeader("Advanced"));
TabContent.Children.Add(SettingRow("Embed Teams window", "No", "Experimental SetParent reparent of Teams' main window."));
TabContent.Children.Add(SettingRow("Logs", "%LOCALAPPDATA%\\TeamsISO\\Logs", "Where rolling daily Serilog files write."));
TabContent.Children.Add(SettingRow("Recordings", "%USERPROFILE%\\Videos\\TeamsISO", "Default per-show recording directory."));
TabContent.Children.Add(SettingRow("Diagnostic bundle", "Export", "Zip logs + config + presets for a bug report."));
feat(winui3): ThemeManager service + Settings drawer + Help/About/Onboarding Builds out the secondary surfaces of the redesigned WinUI 3 host. ThemeManager (Services/ThemeManager.cs) Single-source-of-truth for the active theme. Holds the user preference (System / Dark / Light), resolves it to ElementTheme at request, and raises a Themed event when it changes so the MainWindow can push the AppWindow title-bar button colors. Uses Windows.UI.ViewManagement UISettings to follow the OS app-mode when preference is System. Persistence to UIPreferences lands in the engine-wiring commit. MainWindow theme wiring Replaces the per-handler theme toggle with a ThemeManager subscription: click the title-bar sun/moon -> Toggle() -> Themed event -> ApplyResolvedTheme on the visual tree + the title-bar buttons. Glyph cue: sun = "current is Light, click to Dark"; moon = "current is Dark, click to Light." Initial state applied at construction so the first frame matches the preference. SettingsDrawer (Views/SettingsDrawer.xaml + .cs) UserControl that slides in from the right over the participants table. 56px header, NavigationView with five tabs (Appearance, Routing, Display, Control, Advanced), footer with Reset-to-defaults + Apply/Close. Appearance tab has the theme tri-state picker (System / Dark / Light radio group) and an "Accent peek" row showing the four brand accents (cyan / coral / live / warn) as swatches so the operator can verify Wild Dragon brand is respected on a light desk. CloseRequested event signals the host to collapse the drawer. HelpDialog (Views/HelpDialog.xaml + .cs) ContentDialog with the keyboard shortcut cheat sheet, grouped by category (Global / Participants / Look / Control surface). 540px max height with scroll, mono-spaced shortcut labels at left, body text at right. Replaces the WPF host's HelpWindow at parity. AboutDialog (Views/AboutDialog.xaml + .cs) ContentDialog with the Wild Dragon mark, version + host + engine + brand info as label/value rows, and three quick action buttons (open logs folder, open recordings, check for updates). Mirrors the WPF host's AboutWindow. OnboardingDialog (Views/OnboardingDialog.xaml + .cs) Three numbered steps (Install NDI Runtime / Enable Teams NDI / Pick transcoder topology), no carousel, operator-tone copy ("Don't show this again" defaults checked). PrimaryButtonText "Get started", SecondaryButtonText "Skip" so the dialog is skippable from the first frame as the PRODUCT.md anti-references demand. Build clean: dotnet build TeamsISO.App.WinUI -c Debug -> 0 / 0. Next: wire the drawer's CloseRequested into MainWindow (so the settings icon actually opens / collapses the drawer), then attack the runtime activation blocker (Phase 3 of the migration plan).
2026-05-13 00:13:58 -04:00
}
private void OnCloseClick(object sender, RoutedEventArgs e) => CloseRequested?.Invoke(this, EventArgs.Empty);
private void OnApplyClick(object sender, RoutedEventArgs e)
{
// Tabs already persist on change; Apply is here for affordance
// consistency with the WPF host. Could surface a toast in the future.
CloseRequested?.Invoke(this, EventArgs.Empty);
}
private void OnResetClick(object sender, RoutedEventArgs e)
{
// Defaults restoration plumbing follows in the view-model wiring
// commit. For now, just clear the dirty hint.
DirtyHint.Text = "Reset queued — apply or close to commit.";
}
/// <summary>
/// Appearance tab — theme picker, accent palette peek. The theme picker
/// is the only setting that affects the UI immediately (others apply on
/// the engine layer once wired). Selection writes to UIPreferences and
/// flips Window.Content.RequestedTheme synchronously.
/// </summary>
private void BuildAppearanceTab()
{
TabContent.Children.Add(SettingHeader("Appearance"));
var themeLabel = new TextBlock
{
Style = (Style)Application.Current.Resources["TextBody"],
Text = "Theme",
Margin = new Thickness(0, 0, 0, 6),
};
TabContent.Children.Add(themeLabel);
var themeRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
foreach (var (label, value) in new[]
{
("System", "System"),
("Dark", "Dark"),
("Light", "Light"),
})
{
var btn = new RadioButton
{
Content = label,
Tag = value,
GroupName = "Theme",
IsChecked = ThemeManager.Current.PreferenceMatches(value),
};
btn.Checked += (s, _) =>
{
if (s is RadioButton rb && rb.Tag is string v)
{
ThemeManager.Current.Set(v);
}
};
themeRow.Children.Add(btn);
}
TabContent.Children.Add(themeRow);
TabContent.Children.Add(SettingNote(
"Dark is the default for the 1:50am operator scene; light is for daytime " +
"production. System follows the Windows app-mode preference."));
TabContent.Children.Add(SettingDivider());
TabContent.Children.Add(SettingHeader("Accent peek"));
var accentRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12 };
accentRow.Children.Add(AccentSwatch("Cyan", "AccentCyanSurface"));
accentRow.Children.Add(AccentSwatch("Coral", "AccentCoral"));
accentRow.Children.Add(AccentSwatch("Live", "StatusLive"));
accentRow.Children.Add(AccentSwatch("Warn", "StatusWarn"));
TabContent.Children.Add(accentRow);
TabContent.Children.Add(SettingNote(
"These accents work in both themes. Cyan stays bright as a surface fill " +
"(text on top is near-black regardless of theme). For inline text use, the " +
"light palette substitutes a darker cyan automatically."));
}
// ──────────────────── helpers ──────────────────────────────────────────
private static TextBlock SettingHeader(string text) => new()
{
Style = (Style)Application.Current.Resources["TextHeading"],
Text = text,
Margin = new Thickness(0, 0, 0, 8),
};
private static Grid SettingRow(string label, string value, string? tooltip = null)
{
var g = new Grid { Margin = new Thickness(0, 0, 0, 6) };
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(180) });
g.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var l = new TextBlock
{
Style = (Style)Application.Current.Resources["TextBody"],
Text = label,
VerticalAlignment = VerticalAlignment.Center,
};
var v = new TextBlock
{
Style = (Style)Application.Current.Resources["TextSubtle"],
Text = value,
VerticalAlignment = VerticalAlignment.Center,
};
if (tooltip is not null)
{
ToolTipService.SetToolTip(l, tooltip);
}
Grid.SetColumn(l, 0);
Grid.SetColumn(v, 1);
g.Children.Add(l);
g.Children.Add(v);
return g;
}
private static TextBlock SettingNote(string text) => new()
{
Style = (Style)Application.Current.Resources["TextCaption"],
Text = text,
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 4, 0, 12),
};
private static Border SettingDivider() => new()
{
Height = 1,
Background = (SolidColorBrush)Application.Current.Resources["BorderSubtle"],
Margin = new Thickness(0, 12, 0, 12),
};
private static Border AccentSwatch(string label, string brushKey)
{
var inner = new StackPanel { Orientation = Orientation.Vertical, Spacing = 4 };
var swatch = new Border
{
Width = 80,
Height = 32,
CornerRadius = new Microsoft.UI.Xaml.CornerRadius(6),
Background = (SolidColorBrush)Application.Current.Resources[brushKey],
};
var caption = new TextBlock
{
Style = (Style)Application.Current.Resources["TextCaption"],
Text = label,
HorizontalAlignment = HorizontalAlignment.Center,
};
inner.Children.Add(swatch);
inner.Children.Add(caption);
return new Border { Child = inner };
}
}