diff --git a/App/Controls/ExpandContent.xaml b/App/Controls/ExpandContent.xaml
index d36170d..2cc0eb4 100644
--- a/App/Controls/ExpandContent.xaml
+++ b/App/Controls/ExpandContent.xaml
@@ -9,42 +9,43 @@
xmlns:toolkit="using:CommunityToolkit.WinUI"
mc:Ignorable="d">
-
+
-
+
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
+
diff --git a/App/Controls/ExpandContent.xaml.cs b/App/Controls/ExpandContent.xaml.cs
index 1cd5d2f..926af9a 100644
--- a/App/Controls/ExpandContent.xaml.cs
+++ b/App/Controls/ExpandContent.xaml.cs
@@ -2,38 +2,60 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Markup;
+using System;
+using System.Threading.Tasks;
namespace Coder.Desktop.App.Controls;
+
[ContentProperty(Name = nameof(Children))]
[DependencyProperty("IsOpen", DefaultValue = false)]
public sealed partial class ExpandContent : UserControl
{
public UIElementCollection Children => CollapsiblePanel.Children;
+ private readonly string _expandedState = "ExpandedState";
+ private readonly string _collapsedState = "CollapsedState";
+
public ExpandContent()
{
InitializeComponent();
- }
+ Loaded += (_, __) =>
+ {
+ // When we load the control for the first time (after panel swapping)
+ // we need to set the initial state based on IsOpen.
+ VisualStateManager.GoToState(
+ this,
+ IsOpen ? _expandedState : _collapsedState,
+ useTransitions: false); // NO animation yet
- public void CollapseAnimation_Completed(object? sender, object args)
- {
- // Hide the panel completely when the collapse animation is done. This
- // cannot be done with keyframes for some reason.
- //
- // Without this, the space will still be reserved for the panel.
- CollapsiblePanel.Visibility = Visibility.Collapsed;
+ // If IsOpen was already true we must also show the panel
+ if (IsOpen)
+ {
+ CollapsiblePanel.Visibility = Visibility.Visible;
+ // This makes the panel expand to its full height
+ CollapsiblePanel.ClearValue(FrameworkElement.MaxHeightProperty);
+ }
+ };
}
partial void OnIsOpenChanged(bool oldValue, bool newValue)
{
- var newState = newValue ? "ExpandedState" : "CollapsedState";
-
- // The animation can't set visibility when starting or ending the
- // animation.
+ var newState = newValue ? _expandedState : _collapsedState;
if (newValue)
+ {
CollapsiblePanel.Visibility = Visibility.Visible;
+ // We use BeginTime to ensure other panels are collapsed first.
+ // If the user clicks the expand button quickly, we want to avoid
+ // the panel expanding to its full height before the collapse animation completes.
+ CollapseSb.SkipToFill();
+ }
VisualStateManager.GoToState(this, newState, true);
}
+
+ private void CollapseStoryboard_Completed(object sender, object e)
+ {
+ CollapsiblePanel.Visibility = Visibility.Collapsed;
+ }
}
diff --git a/App/Services/RpcController.cs b/App/Services/RpcController.cs
index 70dfe9f..7beff66 100644
--- a/App/Services/RpcController.cs
+++ b/App/Services/RpcController.cs
@@ -234,6 +234,8 @@ public async Task StopVpn(CancellationToken ct = default)
MutateState(state => { state.VpnLifecycle = VpnLifecycle.Unknown; });
throw new VpnLifecycleException($"Failed to stop VPN. Service reported failure: {reply.Stop.ErrorMessage}");
}
+
+ MutateState(state => { state.VpnLifecycle = VpnLifecycle.Stopped; });
}
public async ValueTask DisposeAsync()
diff --git a/App/ViewModels/AgentViewModel.cs b/App/ViewModels/AgentViewModel.cs
index 34b01d7..cd5907b 100644
--- a/App/ViewModels/AgentViewModel.cs
+++ b/App/ViewModels/AgentViewModel.cs
@@ -237,12 +237,20 @@ public AgentViewModel(ILogger logger, ICoderApiClientFactory cod
Id = id;
- PropertyChanged += (_, args) =>
+ PropertyChanging += (x, args) =>
{
if (args.PropertyName == nameof(IsExpanded))
{
- _expanderHost.HandleAgentExpanded(Id, IsExpanded);
+ var value = !IsExpanded;
+ if (value)
+ _expanderHost.HandleAgentExpanded(Id, value);
+ }
+ };
+ PropertyChanged += (x, args) =>
+ {
+ if (args.PropertyName == nameof(IsExpanded))
+ {
// Every time the drawer is expanded, re-fetch all apps.
if (IsExpanded && !FetchingApps)
FetchApps();
diff --git a/App/ViewModels/TrayWindowLoginRequiredViewModel.cs b/App/ViewModels/TrayWindowLoginRequiredViewModel.cs
index 628be72..abc1257 100644
--- a/App/ViewModels/TrayWindowLoginRequiredViewModel.cs
+++ b/App/ViewModels/TrayWindowLoginRequiredViewModel.cs
@@ -2,6 +2,7 @@
using Coder.Desktop.App.Views;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.UI.Xaml;
namespace Coder.Desktop.App.ViewModels;
@@ -31,4 +32,10 @@ public void Login()
_signInWindow.Closed += (_, _) => _signInWindow = null;
_signInWindow.Activate();
}
+
+ [RelayCommand]
+ public void Exit()
+ {
+ _ = ((App)Application.Current).ExitApplication();
+ }
}
diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs
index cfa5163..24b1b99 100644
--- a/App/ViewModels/TrayWindowViewModel.cs
+++ b/App/ViewModels/TrayWindowViewModel.cs
@@ -126,7 +126,7 @@ public void HandleAgentExpanded(Uuid id, bool expanded)
if (!expanded) return;
_hasExpandedAgent = true;
// Collapse every other agent.
- foreach (var otherAgent in Agents.Where(a => a.Id != id))
+ foreach (var otherAgent in Agents.Where(a => a.Id != id && a.IsExpanded == true))
otherAgent.SetExpanded(false);
}
@@ -360,11 +360,10 @@ private void ShowFileSyncListWindow()
}
[RelayCommand]
- private void SignOut()
+ private async Task SignOut()
{
- if (VpnLifecycle is not VpnLifecycle.Stopped)
- return;
- _credentialManager.ClearCredentials();
+ await _rpcController.StopVpn();
+ await _credentialManager.ClearCredentials();
}
[RelayCommand]
diff --git a/App/Views/Pages/TrayWindowLoginRequiredPage.xaml b/App/Views/Pages/TrayWindowLoginRequiredPage.xaml
index ce161e3..c1d69aa 100644
--- a/App/Views/Pages/TrayWindowLoginRequiredPage.xaml
+++ b/App/Views/Pages/TrayWindowLoginRequiredPage.xaml
@@ -34,5 +34,14 @@
+
+
+
+
+
diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml
index f3549c2..283867d 100644
--- a/App/Views/Pages/TrayWindowMainPage.xaml
+++ b/App/Views/Pages/TrayWindowMainPage.xaml
@@ -333,7 +333,6 @@
diff --git a/App/Views/TrayWindow.xaml b/App/Views/TrayWindow.xaml
index 0d87874..cfc4214 100644
--- a/App/Views/TrayWindow.xaml
+++ b/App/Views/TrayWindow.xaml
@@ -20,5 +20,12 @@
+
+
+
diff --git a/App/Views/TrayWindow.xaml.cs b/App/Views/TrayWindow.xaml.cs
index 5d1755c..ef55095 100644
--- a/App/Views/TrayWindow.xaml.cs
+++ b/App/Views/TrayWindow.xaml.cs
@@ -1,8 +1,3 @@
-using System;
-using System.Runtime.InteropServices;
-using Windows.Graphics;
-using Windows.System;
-using Windows.UI.Core;
using Coder.Desktop.App.Controls;
using Coder.Desktop.App.Models;
using Coder.Desktop.App.Services;
@@ -15,6 +10,13 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using System;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using Windows.Graphics;
+using Windows.System;
+using Windows.UI.Core;
using WinRT.Interop;
using WindowActivatedEventArgs = Microsoft.UI.Xaml.WindowActivatedEventArgs;
@@ -24,8 +26,15 @@ public sealed partial class TrayWindow : Window
{
private const int WIDTH = 300;
+ private readonly AppWindow _aw;
+
+ public double ProxyHeight { get; private set; }
+
+ // This is used to know the "start point of the animation"
+ private int _lastWindowHeight;
+ private Storyboard? _currentSb;
+
private NativeApi.POINT? _lastActivatePosition;
- private int _maxHeightSinceLastActivation;
private readonly IRpcController _rpcController;
private readonly ICredentialManager _credentialManager;
@@ -82,8 +91,34 @@ public TrayWindow(IRpcController rpcController, ICredentialManager credentialMan
var value = 2;
// Best effort. This does not work on Windows 10.
_ = NativeApi.DwmSetWindowAttribute(windowHandle, 33, ref value, Marshal.SizeOf());
+
+ _aw = AppWindow.GetFromWindowId(
+ Win32Interop.GetWindowIdFromWindow(
+ WindowNative.GetWindowHandle(this)));
+ SizeProxy.SizeChanged += (_, e) =>
+ {
+ if (_currentSb is null) return; // nothing running
+
+ int newHeight = (int)Math.Round(
+ e.NewSize.Height * DisplayScale.WindowScale(this));
+
+ int delta = newHeight - _lastWindowHeight;
+ if (delta == 0) return;
+
+ var pos = _aw.Position;
+ var size = _aw.Size;
+
+ pos.Y -= delta; // grow upward
+ size.Height = newHeight;
+
+ _aw.MoveAndResize(
+ new RectInt32(pos.X, pos.Y, size.Width, size.Height));
+
+ _lastWindowHeight = newHeight;
+ };
}
+
private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel,
SyncSessionControllerStateModel syncSessionModel)
{
@@ -140,22 +175,62 @@ public void SetRootFrame(Page page)
private void RootFrame_SizeChanged(object sender, SizedFrameEventArgs e)
{
- MoveAndResize(e.NewSize.Height);
+ AnimateWindowHeight(e.NewSize.Height);
}
- private void MoveAndResize(double height)
+ // We need to animate the height change in code-behind, because XAML
+ // storyboard animation timeline is immutable - it cannot be changed
+ // mid-run to accomodate a new height.
+ private void AnimateWindowHeight(double targetHeight)
{
- var size = CalculateWindowSize(height);
- var pos = CalculateWindowPosition(size);
- var rect = new RectInt32(pos.X, pos.Y, size.Width, size.Height);
- AppWindow.MoveAndResize(rect);
+ // If another animation is already running we need to fast forward it.
+ if (_currentSb is { } oldSb)
+ {
+ oldSb.Completed -= OnStoryboardCompleted;
+ // We need to use SkipToFill, because Stop actually sets Height to 0, which
+ // makes the window go haywire.
+ oldSb.SkipToFill();
+ }
+
+ _lastWindowHeight = AppWindow.Size.Height;
+
+ var anim = new DoubleAnimation
+ {
+ To = targetHeight,
+ Duration = TimeSpan.FromMilliseconds(200),
+ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
+ EnableDependentAnimation = true
+ };
+
+ Storyboard.SetTarget(anim, SizeProxy);
+ Storyboard.SetTargetProperty(anim, "Height");
+
+ var sb = new Storyboard { Children = { anim } };
+ sb.Completed += OnStoryboardCompleted;
+ sb.Begin();
+
+ _currentSb = sb;
+ }
+
+ private void OnStoryboardCompleted(object? sender, object e)
+ {
+ // We need to remove the event handler after the storyboard completes,
+ // to avoid memory leaks and multiple calls.
+ if (sender is Storyboard sb)
+ sb.Completed -= OnStoryboardCompleted;
+
+ // SizeChanged handler will stop forwarding resize ticks
+ // until we start the next storyboard.
+ _currentSb = null;
}
private void MoveResizeAndActivate()
{
SaveCursorPos();
- _maxHeightSinceLastActivation = 0;
- MoveAndResize(RootFrame.GetContentSize().Height);
+ var size = CalculateWindowSize(RootFrame.GetContentSize().Height);
+ var pos = CalculateWindowPosition(size);
+ var rect = new RectInt32(pos.X, pos.Y, size.Width, size.Height);
+ AppWindow.MoveAndResize(rect);
AppWindow.Show();
NativeApi.SetForegroundWindow(WindowNative.GetWindowHandle(this));
}
@@ -179,9 +254,6 @@ private SizeInt32 CalculateWindowSize(double height)
var scale = DisplayScale.WindowScale(this);
var newWidth = (int)(WIDTH * scale);
var newHeight = (int)(height * scale);
- // Store the maximum height we've seen for positioning purposes.
- if (newHeight > _maxHeightSinceLastActivation)
- _maxHeightSinceLastActivation = newHeight;
return new SizeInt32(newWidth, newHeight);
}
@@ -190,14 +262,6 @@ private PointInt32 CalculateWindowPosition(SizeInt32 size)
{
var width = size.Width;
var height = size.Height;
- // For positioning purposes, pretend the window is the maximum size it
- // has been since it was last activated. This has the affect of
- // allowing the window to move up to accomodate more content, but
- // prevents it from moving back down when the window shrinks again.
- //
- // Prevents a lot of jittery behavior with app drawers.
- if (height < _maxHeightSinceLastActivation)
- height = _maxHeightSinceLastActivation;
var cursorPosition = _lastActivatePosition;
if (cursorPosition is null)