From 37d639fb92a3330e48c91306b253f119b1346f6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:24:35 +0000 Subject: [PATCH 1/3] Initial plan From 0e191ead5da7e21d0c5acbdd429a3f19bbecf9f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:30:58 +0000 Subject: [PATCH 2/3] Pop out manual station into its own small window Agent-Logs-Url: https://github.com/TheJoeFin/Trdo/sessions/069d6b25-7e88-4cf0-85ed-b1ea8971abf7 Co-authored-by: TheJoeFin <7809853+TheJoeFin@users.noreply.github.com> --- Trdo/Controls/ManualStationWindow.xaml | 116 ++++++++++++++++++++++ Trdo/Controls/ManualStationWindow.xaml.cs | 58 +++++++++++ Trdo/Pages/PlayingPage.xaml.cs | 7 +- Trdo/Pages/SearchStation.xaml.cs | 7 +- Trdo/Trdo.csproj | 6 ++ 5 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 Trdo/Controls/ManualStationWindow.xaml create mode 100644 Trdo/Controls/ManualStationWindow.xaml.cs diff --git a/Trdo/Controls/ManualStationWindow.xaml b/Trdo/Controls/ManualStationWindow.xaml new file mode 100644 index 0000000..978374d --- /dev/null +++ b/Trdo/Controls/ManualStationWindow.xaml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Trdo/Controls/ManualStationWindow.xaml.cs b/Trdo/Controls/ManualStationWindow.xaml.cs new file mode 100644 index 0000000..e28e7db --- /dev/null +++ b/Trdo/Controls/ManualStationWindow.xaml.cs @@ -0,0 +1,58 @@ +using Trdo.Models; +using Trdo.ViewModels; +using WinUIEx; + +namespace Trdo.Controls; + +/// +/// A small standalone window for manually adding or editing a radio station. +/// Opens as a pop-out window so that closing the tray flyout does not clear the form fields. +/// +public sealed partial class ManualStationWindow : WindowEx +{ + public AddStationViewModel ViewModel { get; } + + public ManualStationWindow() + { + InitializeComponent(); + + ViewModel = new AddStationViewModel(); + ViewModel.SetPlayerViewModel(PlayerViewModel.Shared); + + ExtendsContentIntoTitleBar = true; + SetTitleBar(ModernTitlebar); + + Activated += ManualStationWindow_Activated; + } + + /// + /// Opens the window pre-filled with the given station's data for editing. + /// + public void LoadStationForEdit(RadioStation station) + { + ViewModel.LoadStationForEdit(station); + } + + private void ManualStationWindow_Activated(object sender, Microsoft.UI.Xaml.WindowActivatedEventArgs args) + { + // Focus the station name field once the window is ready + if (args.WindowActivationState != Microsoft.UI.Xaml.WindowActivationState.Deactivated) + { + StationNameTextBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + Activated -= ManualStationWindow_Activated; + } + } + + private void SaveButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + if (ViewModel.Save()) + { + Close(); + } + } + + private void CancelButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + Close(); + } +} diff --git a/Trdo/Pages/PlayingPage.xaml.cs b/Trdo/Pages/PlayingPage.xaml.cs index 4da53e9..2daa1e1 100644 --- a/Trdo/Pages/PlayingPage.xaml.cs +++ b/Trdo/Pages/PlayingPage.xaml.cs @@ -356,8 +356,11 @@ private void EditStation_Click(object sender, RoutedEventArgs e) if (sender is MenuFlyoutItem menuItem && menuItem.Tag is RadioStation station) { Debug.WriteLine($"[PlayingPage] Edit station clicked: {station.Name}"); - // Navigate to AddStation page in edit mode with the station data - _shellViewModel?.NavigateToAddStationPage(station); + // Open a pop-out window for editing so the flyout closing doesn't clear the fields + ManualStationWindow editWindow = new(); + WindowHelper.Track(editWindow); + editWindow.LoadStationForEdit(station); + editWindow.Activate(); } } diff --git a/Trdo/Pages/SearchStation.xaml.cs b/Trdo/Pages/SearchStation.xaml.cs index ecc3ad6..d89b766 100644 --- a/Trdo/Pages/SearchStation.xaml.cs +++ b/Trdo/Pages/SearchStation.xaml.cs @@ -2,6 +2,7 @@ using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; using System; +using Trdo.Controls; using Trdo.Models; using Trdo.Services; using Trdo.ViewModels; @@ -139,8 +140,10 @@ private void AddStationButton_Click(object sender, RoutedEventArgs e) private void ManualEntryButton_Click(object sender, RoutedEventArgs e) { StopPreview(); - // Navigate to manual entry page - _shellViewModel?.NavigateToAddStationPage(); + // Open a pop-out window for manual station entry so the flyout closing doesn't clear the fields + ManualStationWindow addWindow = new(); + WindowHelper.Track(addWindow); + addWindow.Activate(); } private void CancelButton_Click(object sender, RoutedEventArgs e) diff --git a/Trdo/Trdo.csproj b/Trdo/Trdo.csproj index a5f24ad..cb3749a 100644 --- a/Trdo/Trdo.csproj +++ b/Trdo/Trdo.csproj @@ -20,6 +20,7 @@ + @@ -119,6 +120,11 @@ MSBuild:Compile + + + MSBuild:Compile + + MSBuild:Compile From fadef170c437ce19425a6be9621227b15072eafe Mon Sep 17 00:00:00 2001 From: Joe Finney Date: Tue, 31 Mar 2026 19:31:02 -0500 Subject: [PATCH 3/3] Improve window placement UX with pointer anchor service Added WindowPlacementService to capture pointer position and position windows (ManualStationWindow, TutorialWindow) near the anchor. Updated event handlers to use the service before opening flyouts or windows. Increased ManualStationWindow height to 500 and reformatted XAML bindings for readability. Added necessary using directives for the new service. --- Trdo/App.xaml.cs | 6 +- Trdo/Controls/ManualStationWindow.xaml | 25 +++-- Trdo/Controls/ManualStationWindow.xaml.cs | 2 + Trdo/Controls/TutorialWindow.xaml.cs | 10 ++ Trdo/Pages/PlayingPage.xaml.cs | 12 +-- Trdo/Pages/SearchStation.xaml.cs | 2 +- Trdo/Services/WindowPlacementService.cs | 114 ++++++++++++++++++++++ 7 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 Trdo/Services/WindowPlacementService.cs diff --git a/Trdo/App.xaml.cs b/Trdo/App.xaml.cs index 65d57c5..a085ee6 100644 --- a/Trdo/App.xaml.cs +++ b/Trdo/App.xaml.cs @@ -11,7 +11,6 @@ using Trdo.Pages; using Trdo.Services; using Trdo.ViewModels; -using Windows.UI; using Windows.UI.ViewManagement; using WinUIEx; @@ -157,6 +156,7 @@ private void InitializeTrayIcon() private void TrayIcon_ContextMenu(TrayIcon sender, TrayIconEventArgs args) { + WindowPlacementService.CapturePointerAnchor(); args.Flyout = CreateFlyout(); } @@ -166,6 +166,7 @@ private void TrayIcon_Selected(TrayIcon sender, TrayIconEventArgs args) if (!_playerVm.CanPlay) { // No stations available, show the flyout to encourage user to add a station + WindowPlacementService.CapturePointerAnchor(); args.Flyout = CreateFlyout(); return; } @@ -190,6 +191,7 @@ private Flyout CreateFlyout() flyout.Opened += (s, e) => { + WindowPlacementService.CapturePointerAnchor(); // Clear the back stack when flyout opens to prevent accumulation Services.NavigationService.Instance.ClearBackStack(); }; @@ -272,7 +274,7 @@ private void UpdatePlayPauseCommandText() } else if (_playerVm.IsPlaying) { - + // Include now playing info if available if (_playerVm.HasNowPlaying) { diff --git a/Trdo/Controls/ManualStationWindow.xaml b/Trdo/Controls/ManualStationWindow.xaml index 978374d..b222264 100644 --- a/Trdo/Controls/ManualStationWindow.xaml +++ b/Trdo/Controls/ManualStationWindow.xaml @@ -9,7 +9,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" Title="Trdo - Add Station" Width="400" - Height="380" + Height="500" IsMaximizable="False" IsMinimizable="False" mc:Ignorable="d"> @@ -20,9 +20,7 @@ - + @@ -38,7 +36,9 @@ + Text="{x:Bind ViewModel.StationName, + Mode=TwoWay, + UpdateSourceTrigger=PropertyChanged}" /> @@ -47,7 +47,9 @@ @@ -57,7 +59,9 @@ @@ -67,7 +71,9 @@ @@ -101,7 +107,8 @@ Grid.Column="1" HorizontalAlignment="Stretch" Click="SaveButton_Click" - IsEnabled="{x:Bind ViewModel.CanSave, Mode=OneWay}" + IsEnabled="{x:Bind ViewModel.CanSave, + Mode=OneWay}" Style="{StaticResource AccentButtonStyle}"> diff --git a/Trdo/Controls/ManualStationWindow.xaml.cs b/Trdo/Controls/ManualStationWindow.xaml.cs index e28e7db..2c7cc49 100644 --- a/Trdo/Controls/ManualStationWindow.xaml.cs +++ b/Trdo/Controls/ManualStationWindow.xaml.cs @@ -1,4 +1,5 @@ using Trdo.Models; +using Trdo.Services; using Trdo.ViewModels; using WinUIEx; @@ -38,6 +39,7 @@ private void ManualStationWindow_Activated(object sender, Microsoft.UI.Xaml.Wind // Focus the station name field once the window is ready if (args.WindowActivationState != Microsoft.UI.Xaml.WindowActivationState.Deactivated) { + WindowPlacementService.PositionWindowNearAnchor(this, 400, 500); StationNameTextBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); Activated -= ManualStationWindow_Activated; } diff --git a/Trdo/Controls/TutorialWindow.xaml.cs b/Trdo/Controls/TutorialWindow.xaml.cs index 0f8327f..758c27c 100644 --- a/Trdo/Controls/TutorialWindow.xaml.cs +++ b/Trdo/Controls/TutorialWindow.xaml.cs @@ -13,6 +13,16 @@ public TutorialWindow() ExtendsContentIntoTitleBar = true; SetTitleBar(ModernTitlebar); + Activated += TutorialWindow_Activated; + } + + private void TutorialWindow_Activated(object sender, Microsoft.UI.Xaml.WindowActivatedEventArgs args) + { + if (args.WindowActivationState == Microsoft.UI.Xaml.WindowActivationState.Deactivated) + return; + + WindowPlacementService.PositionWindowNearAnchor(this, 400, 600); + Activated -= TutorialWindow_Activated; } private void Button_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) diff --git a/Trdo/Pages/PlayingPage.xaml.cs b/Trdo/Pages/PlayingPage.xaml.cs index 2daa1e1..e7998b4 100644 --- a/Trdo/Pages/PlayingPage.xaml.cs +++ b/Trdo/Pages/PlayingPage.xaml.cs @@ -9,7 +9,6 @@ using Trdo.Models; using Trdo.Services; using Trdo.ViewModels; -using WinUIEx; // To learn more about WinUI, the WinUI project structure, // and more about our project templates, see: http://aka.ms/winui-project-info. @@ -23,9 +22,9 @@ public sealed partial class PlayingPage : Page private const int MinIndexForScrolling = 3; private const string FilledStar = "\uE735"; private const string OutlineStar = "\uE734"; - + private readonly FavoritesService _favoritesService = FavoritesService.Instance; - + public PlayerViewModel ViewModel { get; } private ShellViewModel? _shellViewModel; @@ -117,7 +116,7 @@ private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.Pro UpdatePlayButtonState(); UpdateStationSelection(); - if (e.PropertyName == nameof(PlayerViewModel.CurrentMetadata) || + if (e.PropertyName == nameof(PlayerViewModel.CurrentMetadata) || e.PropertyName == nameof(PlayerViewModel.HasNowPlaying)) { UpdateFavoriteButtonState(); @@ -321,7 +320,7 @@ private void NowPlayingInfo_Click(object sender, RoutedEventArgs e) private void FavoriteButton_Click(object sender, RoutedEventArgs e) { Debug.WriteLine("[PlayingPage] Favorite button clicked"); - + if (ViewModel.CurrentMetadata?.HasMetadata != true) { Debug.WriteLine("[PlayingPage] No metadata to favorite"); @@ -331,7 +330,7 @@ private void FavoriteButton_Click(object sender, RoutedEventArgs e) string stationName = ViewModel.SelectedStation?.Name ?? "Unknown Station"; bool isFavorited = _favoritesService.ToggleFavorite(ViewModel.CurrentMetadata, stationName); Debug.WriteLine($"[PlayingPage] Track favorite toggled. IsFavorited: {isFavorited}"); - + // UpdateFavoriteButtonState will be called via the FavoritesChanged event } @@ -357,6 +356,7 @@ private void EditStation_Click(object sender, RoutedEventArgs e) { Debug.WriteLine($"[PlayingPage] Edit station clicked: {station.Name}"); // Open a pop-out window for editing so the flyout closing doesn't clear the fields + WindowPlacementService.CapturePointerAnchor(); ManualStationWindow editWindow = new(); WindowHelper.Track(editWindow); editWindow.LoadStationForEdit(station); diff --git a/Trdo/Pages/SearchStation.xaml.cs b/Trdo/Pages/SearchStation.xaml.cs index d89b766..070ae68 100644 --- a/Trdo/Pages/SearchStation.xaml.cs +++ b/Trdo/Pages/SearchStation.xaml.cs @@ -1,7 +1,6 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; -using System; using Trdo.Controls; using Trdo.Models; using Trdo.Services; @@ -141,6 +140,7 @@ private void ManualEntryButton_Click(object sender, RoutedEventArgs e) { StopPreview(); // Open a pop-out window for manual station entry so the flyout closing doesn't clear the fields + WindowPlacementService.CapturePointerAnchor(); ManualStationWindow addWindow = new(); WindowHelper.Track(addWindow); addWindow.Activate(); diff --git a/Trdo/Services/WindowPlacementService.cs b/Trdo/Services/WindowPlacementService.cs new file mode 100644 index 0000000..2c47938 --- /dev/null +++ b/Trdo/Services/WindowPlacementService.cs @@ -0,0 +1,114 @@ +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using System.Runtime.InteropServices; +using Windows.Graphics; +using WinUIEx; + +namespace Trdo.Services; + +internal static partial class WindowPlacementService +{ + private const int WindowMargin = 12; + private static PointInt32? _lastAnchorPoint; + + public static void CapturePointerAnchor() + { + if (GetCursorPos(out POINT point)) + _lastAnchorPoint = new PointInt32(point.X, point.Y); + } + + public static void PositionWindowNearAnchor(Window window, int width, int height) + { + PointInt32 anchor = GetAnchorPoint(); + DisplayArea? displayArea = DisplayArea.GetFromPoint(anchor, DisplayAreaFallback.Nearest); + RectInt32 workArea = displayArea?.WorkArea ?? DisplayArea.Primary.WorkArea; + + bool placeLeft = anchor.X >= workArea.X + (workArea.Width / 2); + bool placeAbove = anchor.Y >= workArea.Y + (workArea.Height / 2); + + int x = placeLeft ? anchor.X - width - WindowMargin : anchor.X + WindowMargin; + int y = placeAbove ? anchor.Y - height - WindowMargin : anchor.Y + WindowMargin; + + x = System.Math.Clamp(x, workArea.X, workArea.X + workArea.Width - width); + y = System.Math.Clamp(y, workArea.Y, workArea.Y + workArea.Height - height); + + window.MoveAndResize(x, y, width, height); + } + + private static PointInt32 GetAnchorPoint() + { + if (_lastAnchorPoint is PointInt32 anchor) + return anchor; + + if (TryGetTaskbarAnchorPoint(out anchor)) + return anchor; + + RectInt32 workArea = DisplayArea.Primary.WorkArea; + return new PointInt32(workArea.X + workArea.Width - WindowMargin, workArea.Y + workArea.Height - WindowMargin); + } + + private static bool TryGetTaskbarAnchorPoint(out PointInt32 anchor) + { + APPBARDATA appBarData = new() + { + cbSize = Marshal.SizeOf() + }; + + if (SHAppBarMessage(ABM_GETTASKBARPOS, ref appBarData) == 0) + { + anchor = default; + return false; + } + + anchor = appBarData.uEdge switch + { + ABE_BOTTOM => new PointInt32(appBarData.rc.Right - WindowMargin, appBarData.rc.Top + ((appBarData.rc.Bottom - appBarData.rc.Top) / 2)), + ABE_TOP => new PointInt32(appBarData.rc.Right - WindowMargin, appBarData.rc.Bottom - WindowMargin), + ABE_LEFT => new PointInt32(appBarData.rc.Right - WindowMargin, appBarData.rc.Bottom - WindowMargin), + ABE_RIGHT => new PointInt32(appBarData.rc.Left + WindowMargin, appBarData.rc.Bottom - WindowMargin), + _ => new PointInt32(appBarData.rc.Right - WindowMargin, appBarData.rc.Bottom - WindowMargin) + }; + + return true; + } + + private const uint ABM_GETTASKBARPOS = 0x00000005; + private const uint ABE_LEFT = 0; + private const uint ABE_TOP = 1; + private const uint ABE_RIGHT = 2; + private const uint ABE_BOTTOM = 3; + + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + private struct APPBARDATA + { + public int cbSize; + public nint hWnd; + public uint uCallbackMessage; + public uint uEdge; + public RECT rc; + public nint lParam; + } + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetCursorPos(out POINT point); + + [LibraryImport("shell32.dll")] + private static partial uint SHAppBarMessage(uint dwMessage, ref APPBARDATA pData); +}