diff --git a/src/DodoSSH.Client.App/Views/CaptionPresses.cs b/src/DodoSSH.Client.App/Views/CaptionPresses.cs
new file mode 100644
index 0000000..63ccb7a
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/CaptionPresses.cs
@@ -0,0 +1,54 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+
+namespace DodoSSH.Client.App.Views;
+
+///
+/// Tells a drawn titlebar's double-click from a press that starts a drag.
+///
+///
+///
+/// Both titlebars used to answer a double-click through DoubleTapped and start a drag on every press.
+/// The two do not mix: BeginMoveDrag hands the press to the operating system's own move loop, which
+/// eats the release — and a double tap is a gesture Avalonia assembles from presses and releases,
+/// so the second click often never completed one and the window did not maximise. The convention it was
+/// imitating is one nobody thinks about until it does not work.
+///
+///
+/// So the press decides, before the drag begins: a second press inside the platform's double-click time and
+/// distance is the maximise, and does not start a drag at all. The click count is honoured where the platform
+/// reports one, and the time and distance are measured here for where the move loop kept it from counting.
+///
+///
+internal sealed class CaptionPresses
+{
+ private ulong lastAt;
+
+ private Point lastWhere;
+
+ private bool waitingForASecond;
+
+ /// Whether this press is the second of a double-click on .
+ internal bool IsSecond(PointerPressedEventArgs e, Visual bar)
+ {
+ var settings = Application.Current?.PlatformSettings;
+ var time = settings?.GetDoubleTapTime(e.Pointer.Type) ?? TimeSpan.FromMilliseconds(500);
+ var size = settings?.GetDoubleTapSize(e.Pointer.Type) ?? new Size(4, 4);
+ var where = e.GetPosition(bar);
+
+ var second = e.ClickCount == 2
+ || (waitingForASecond
+ && e.Timestamp - lastAt <= (ulong)time.TotalMilliseconds
+ && Math.Abs(where.X - lastWhere.X) <= size.Width
+ && Math.Abs(where.Y - lastWhere.Y) <= size.Height);
+
+ // A double-click spends both presses: a third that follows quickly starts again rather than toggling
+ // the window straight back.
+ waitingForASecond = !second;
+ lastAt = e.Timestamp;
+ lastWhere = where;
+
+ return second;
+ }
+}
diff --git a/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml b/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml
index 05bba60..f8b3228 100644
--- a/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml
+++ b/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml
@@ -26,7 +26,7 @@
-->
+ PointerPressed="OnDrag">
diff --git a/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml.cs b/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml.cs
index d48ec3e..cb0765c 100644
--- a/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml.cs
@@ -9,6 +9,8 @@ internal sealed partial class SettingsTitleBar : UserControl
{
public SettingsTitleBar() => InitializeComponent();
+ private readonly CaptionPresses presses = new();
+
private Window? Host => TopLevel.GetTopLevel(this) as Window;
/// Left button only, and only on a press nothing inside the bar has already handled.
@@ -19,6 +21,15 @@ internal sealed partial class SettingsTitleBar : UserControl
return;
}
+ // Decided on the press, before the drag would take it: see CaptionPresses.
+ if (presses.IsSecond(e, this))
+ {
+ OnToggleMaximised(sender, e);
+ e.Handled = true;
+
+ return;
+ }
+
Host?.BeginMoveDrag(e);
}
@@ -31,6 +42,7 @@ internal sealed partial class SettingsTitleBar : UserControl
}
/// Both the button and a double-click on the bar arrive here, as Windows convention expects.
+ /// The double-click by way of ; see .
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
{
if (Host is not { } window)
diff --git a/src/DodoSSH.Client.App/Views/TitleBar.axaml b/src/DodoSSH.Client.App/Views/TitleBar.axaml
index 6dbf537..b01154c 100644
--- a/src/DodoSSH.Client.App/Views/TitleBar.axaml
+++ b/src/DodoSSH.Client.App/Views/TitleBar.axaml
@@ -32,7 +32,7 @@
-->
+ PointerPressed="OnDrag">
diff --git a/src/DodoSSH.Client.App/Views/TitleBar.axaml.cs b/src/DodoSSH.Client.App/Views/TitleBar.axaml.cs
index 7452211..5276ef1 100644
--- a/src/DodoSSH.Client.App/Views/TitleBar.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/TitleBar.axaml.cs
@@ -41,6 +41,8 @@ internal sealed partial class TitleBar : UserControl
}
}
+ private readonly CaptionPresses presses = new();
+
private Window? Host => TopLevel.GetTopLevel(this) as Window;
///
@@ -55,6 +57,15 @@ internal sealed partial class TitleBar : UserControl
return;
}
+ // Decided on the press, before the drag would take it: see CaptionPresses.
+ if (presses.IsSecond(e, this))
+ {
+ OnToggleMaximised(sender, e);
+ e.Handled = true;
+
+ return;
+ }
+
Host?.BeginMoveDrag(e);
}
@@ -68,7 +79,8 @@ internal sealed partial class TitleBar : UserControl
///
/// Both the button and a double-click on the bar arrive here, which is the convention Windows sets and
- /// the one people reach for without thinking about it.
+ /// the one people reach for without thinking about it. The double-click comes from
+ /// rather than DoubleTapped; see .
///
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
{
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/TitleBarTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/TitleBarTests.cs
index f250fbe..18cc411 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/TitleBarTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/TitleBarTests.cs
@@ -66,6 +66,52 @@ public sealed class TitleBarTests : IAsyncLifetime
caches.Dispose();
}
+ ///
+ /// A double-click on the bar maximises, and a second one restores. It was a DoubleTapped handler
+ /// beside a press that started a drag, and the drag handed the press to the operating system's move loop,
+ /// which ate the release a double tap is assembled from — so on Windows the click often did nothing. The
+ /// press decides now; see CaptionPresses. A single press must still be a drag and nothing else.
+ ///
+ [Fact]
+ public async Task DoubleClickingTheBar_MaximisesAndRestores_AndASinglePressDoesNot()
+ {
+ await LayoutHarness.OnTheUiThreadAsync(
+ () =>
+ {
+ var bar = new TitleBar { DataContext = shell };
+ var window = LayoutHarness.HostAtMinimumSize(
+ bar, LayoutHarness.MinimumWidth, LayoutHarness.TitleBarHeight);
+
+ try
+ {
+ // The top edge, past everything the bar draws: the drag surface and nothing else.
+ var empty = new Point(bar.Bounds.Width * 0.6, 2);
+
+ window.MouseDown(empty, MouseButton.Left);
+ window.MouseUp(empty, MouseButton.Left);
+
+ window.WindowState.ShouldBe(WindowState.Normal, "one press is a drag");
+
+ window.MouseDown(empty, MouseButton.Left);
+ window.MouseUp(empty, MouseButton.Left);
+
+ window.WindowState.ShouldBe(WindowState.Maximized);
+
+ window.MouseDown(empty, MouseButton.Left);
+ window.MouseUp(empty, MouseButton.Left);
+ window.MouseDown(empty, MouseButton.Left);
+ window.MouseUp(empty, MouseButton.Left);
+
+ window.WindowState.ShouldBe(WindowState.Normal);
+ }
+ finally
+ {
+ window.Close();
+ }
+ },
+ Token);
+ }
+
[Fact]
public async Task ClickingTheSearchPill_OpensTheQuickConnectPalette()
{