Public Access
Desktop: decide a titlebar double-click on the press, before the drag can swallow it
This commit is contained in:
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}"
|
<Border Height="53" Background="{StaticResource DeepChrome}"
|
||||||
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
|
PointerPressed="OnDrag">
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
|
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ internal sealed partial class SettingsTitleBar : UserControl
|
|||||||
{
|
{
|
||||||
public SettingsTitleBar() => InitializeComponent();
|
public SettingsTitleBar() => InitializeComponent();
|
||||||
|
|
||||||
|
private readonly CaptionPresses presses = new();
|
||||||
|
|
||||||
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
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>
|
/// <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;
|
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);
|
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>
|
/// <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)
|
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (Host is not { } window)
|
if (Host is not { } window)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<Border Height="53" Background="{StaticResource DeepChrome}"
|
<Border Height="53" Background="{StaticResource DeepChrome}"
|
||||||
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
|
PointerPressed="OnDrag">
|
||||||
|
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0">
|
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0">
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ internal sealed partial class TitleBar : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly CaptionPresses presses = new();
|
||||||
|
|
||||||
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -55,6 +57,15 @@ internal sealed partial class TitleBar : UserControl
|
|||||||
return;
|
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);
|
Host?.BeginMoveDrag(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +79,8 @@ internal sealed partial class TitleBar : UserControl
|
|||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Both the button and a double-click on the bar arrive here, which is the convention Windows sets and
|
/// 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>
|
/// </remarks>
|
||||||
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
|
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,6 +66,52 @@ public sealed class TitleBarTests : IAsyncLifetime
|
|||||||
caches.Dispose();
|
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]
|
[Fact]
|
||||||
public async Task ClickingTheSearchPill_OpensTheQuickConnectPalette()
|
public async Task ClickingTheSearchPill_OpensTheQuickConnectPalette()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user