Desktop: decide a titlebar double-click on the press, before the drag can swallow it
ci / build and test (pull_request) Successful in 2m48s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 13s
ci / android head (pull_request) Successful in 3m41s

This commit is contained in:
jaap-jan committed 2026-10-02 14:40:50 +02:00
1 parent 079a273d46
commit a2362d1e3e
6 files changed
+127 -3

No files matched your search

@@ -0,0 +1,54 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Tells a drawn titlebar's double-click from a press that starts a drag.
/// </summary>
/// <remarks>
/// <para>
/// Both titlebars used to answer a double-click through <c>DoubleTapped</c> and start a drag on every press.
/// The two do not mix: <c>BeginMoveDrag</c> 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 <em>and</em> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal sealed class CaptionPresses
{
private ulong lastAt;
private Point lastWhere;
private bool waitingForASecond;
/// <summary>Whether this press is the second of a double-click on <paramref name="bar"/>.</summary>
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;
}
}
@@ -26,7 +26,7 @@
-->
<Border Height="53" Background="{StaticResource DeepChrome}"
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
PointerPressed="OnDrag">
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
@@ -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;
/// <summary>Left button only, and only on a press nothing inside the bar has already handled.</summary>
@@ -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
}
/// <summary>Both the button and a double-click on the bar arrive here, as Windows convention expects.</summary>
/// <remarks>The double-click by way of <see cref="OnDrag"/>; see <see cref="CaptionPresses"/>.</remarks>
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
{
if (Host is not { } window)
+1 -1
View File
@@ -32,7 +32,7 @@
-->
<Border Height="53" Background="{StaticResource DeepChrome}"
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
PointerPressed="OnDrag">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0">
+13 -1
View File
@@ -41,6 +41,8 @@ internal sealed partial class TitleBar : UserControl
}
}
private readonly CaptionPresses presses = new();
private Window? Host => TopLevel.GetTopLevel(this) as Window;
/// <remarks>
@@ -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
/// <remarks>
/// 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 <see cref="OnDrag"/>
/// rather than <c>DoubleTapped</c>; see <see cref="CaptionPresses"/>.
/// </remarks>
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
{
@@ -66,6 +66,52 @@ public sealed class TitleBarTests : IAsyncLifetime
caches.Dispose();
}
/// <remarks>
/// A double-click on the bar maximises, and a second one restores. It was a <c>DoubleTapped</c> 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 <c>CaptionPresses</c>. A single press must still be a drag and nothing else.
/// </remarks>
[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()
{