From a2362d1e3e05b7a99b2f1da663c2ff538b11c916 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Fri, 2 Oct 2026 14:40:50 +0200 Subject: [PATCH] Desktop: decide a titlebar double-click on the press, before the drag can swallow it --- .../Views/CaptionPresses.cs | 54 +++++++++++++++++++ .../Views/SettingsTitleBar.axaml | 2 +- .../Views/SettingsTitleBar.axaml.cs | 12 +++++ src/DodoSSH.Client.App/Views/TitleBar.axaml | 2 +- .../Views/TitleBar.axaml.cs | 14 ++++- .../TitleBarTests.cs | 46 ++++++++++++++++ 6 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 src/DodoSSH.Client.App/Views/CaptionPresses.cs 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() {