From 96d1766a935c613ce413d9eb0c63b82d6676cbb2 Mon Sep 17 00:00:00 2001 From: bcbetterninja <327058824+bcbetterninja@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:22:08 +0000 Subject: [PATCH] Fix Linux compose focus and keep recipient searches responsive --- .github/workflows/checks.yml | 6 +- src/BetterMail.App/LinuxWebViewFocus.cs | 60 ++++++++++ src/BetterMail.App/MainWindow.axaml.cs | 8 ++ .../MainWindowViewModel.Sync.cs | 2 +- src/BetterMail.App/MainWindowViewModel.cs | 74 ++++++------ src/BetterMail.App/RecipientDirectoryCache.cs | 74 ++++++++++++ src/BetterMail.App/RichHtmlEditor.axaml.cs | 24 +++- .../RecipientDirectoryCacheTests.cs | 88 +++++++++++++++ .../Program.ComposeInput.cs | 106 ++++++++++++++++++ tools/BetterMail.UiPreview/Program.cs | 2 +- 10 files changed, 407 insertions(+), 37 deletions(-) create mode 100644 src/BetterMail.App/LinuxWebViewFocus.cs create mode 100644 src/BetterMail.App/RecipientDirectoryCache.cs create mode 100644 tests/BetterMail.Tests/RecipientDirectoryCacheTests.cs create mode 100644 tools/BetterMail.UiPreview/Program.ComposeInput.cs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index ffba171..ceb3f75 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -49,7 +49,11 @@ jobs: run: dotnet publish src/BetterMail.App --configuration Release --no-restore --output artifacts/smoke -p:UseAppHost=false - name: Install Linux desktop libraries if: runner.os == 'Linux' - run: sudo apt-get update -qq && sudo apt-get install -y xvfb libice6 libsm6 libfontconfig1 libsecret-1-0 libwebkit2gtk-4.1-0 + run: sudo apt-get update -qq && sudo apt-get install -y xvfb libxtst6 libice6 libsm6 libfontconfig1 libsecret-1-0 libwebkit2gtk-4.1-0 + - name: Check Linux compose keyboard input + if: runner.os == 'Linux' + timeout-minutes: 2 + run: xvfb-run -a dotnet run --project tools/BetterMail.UiPreview --configuration Release --no-build -- artifacts/compose-input --compose-input - name: Smoke test published Linux app if: runner.os == 'Linux' run: xvfb-run -a python3 scripts/smoke-startup.py artifacts/smoke/BetterMail.dll diff --git a/src/BetterMail.App/LinuxWebViewFocus.cs b/src/BetterMail.App/LinuxWebViewFocus.cs new file mode 100644 index 0000000..abc92b0 --- /dev/null +++ b/src/BetterMail.App/LinuxWebViewFocus.cs @@ -0,0 +1,60 @@ +using System.Runtime.InteropServices; +using Avalonia.Platform; + +namespace BetterMail.App; + +internal static class LinuxWebViewFocus +{ + // GTK's grab_focus selects its widget, but doesn't move X11 keyboard focus into + // the reparented WebView window. This also applies to GTK running under XWayland. + // Keep native key/IME handling rather than synthesizing text through JavaScript. + public static void Focus(IPlatformHandle? handle, IPlatformHandle? owner) + { + if (!OperatingSystem.IsLinux() || handle is not IGtkWebViewPlatformHandle || + handle.HandleDescriptor != "XID" || owner?.HandleDescriptor != "XID" || + handle.Handle == IntPtr.Zero || owner.Handle == IntPtr.Zero) return; + var display = XOpenDisplay(IntPtr.Zero); + if (display == IntPtr.Zero) return; + try + { + XGetInputFocus(display, out var focused, out _); + // Ignore delayed GTK notifications after the user has moved to another window. + if (focused == owner.Handle || focused == new IntPtr(1) /* PointerRoot */) + SetFocus(display, handle.Handle); + } + finally { XCloseDisplay(display); } + } + + public static void RestoreOwner(IPlatformHandle? editor, IPlatformHandle? owner) + { + if (!OperatingSystem.IsLinux() || editor is not IGtkWebViewPlatformHandle || + editor.HandleDescriptor != "XID" || owner?.HandleDescriptor != "XID" || + editor.Handle == IntPtr.Zero || owner.Handle == IntPtr.Zero) return; + var display = XOpenDisplay(IntPtr.Zero); + if (display == IntPtr.Zero) return; + try + { + XGetInputFocus(display, out var focused, out _); + // Hand input back to To/Subject/the toolbar, but never reclaim focus from another app. + if (focused == editor.Handle) SetFocus(display, owner.Handle); + } + finally { XCloseDisplay(display); } + } + + private static void SetFocus(IntPtr display, IntPtr window) + { + XSetInputFocus(display, window, 2 /* RevertToParent */, IntPtr.Zero /* CurrentTime */); + XFlush(display); + } + + [DllImport("libX11.so.6")] + private static extern IntPtr XOpenDisplay(IntPtr name); + [DllImport("libX11.so.6")] + private static extern int XSetInputFocus(IntPtr display, IntPtr window, int revertTo, IntPtr time); + [DllImport("libX11.so.6")] + private static extern int XGetInputFocus(IntPtr display, out IntPtr window, out int revertTo); + [DllImport("libX11.so.6")] + private static extern int XFlush(IntPtr display); + [DllImport("libX11.so.6")] + private static extern int XCloseDisplay(IntPtr display); +} diff --git a/src/BetterMail.App/MainWindow.axaml.cs b/src/BetterMail.App/MainWindow.axaml.cs index aca548f..9ad22cc 100644 --- a/src/BetterMail.App/MainWindow.axaml.cs +++ b/src/BetterMail.App/MainWindow.axaml.cs @@ -1119,8 +1119,16 @@ private void OpenCompose(ComposeRequest request) return; } _composeWindows[session] = window; + var recipientOwner = _viewModel; + void RefreshRecipients() + { + if (window.FocusManager?.GetFocusedElement() is TextBox { DataContext: ComposeRecipientField field } && + !string.IsNullOrWhiteSpace(field.Query)) field.RefreshSearch(); + } + recipientOwner.RecipientDirectoryChanged += RefreshRecipients; window.Closed += (_, _) => { + recipientOwner.RecipientDirectoryChanged -= RefreshRecipients; _composeWindows.Remove(session); if (!_isClosing) { diff --git a/src/BetterMail.App/MainWindowViewModel.Sync.cs b/src/BetterMail.App/MainWindowViewModel.Sync.cs index ac94ddf..e8b4ffa 100644 --- a/src/BetterMail.App/MainWindowViewModel.Sync.cs +++ b/src/BetterMail.App/MainWindowViewModel.Sync.cs @@ -231,7 +231,7 @@ private async Task RunStorageMaintenanceAsync() StorageSyncStep.Detail = $"{++batches} cleanup batches complete"; await Task.Delay(50); } - _recipientDirectoryTask = null; + _ = RecipientDirectory.RefreshAsync(); if (WindowsSessionLock.IsLocked()) { using var vacuumCancellation = new CancellationTokenSource(); diff --git a/src/BetterMail.App/MainWindowViewModel.cs b/src/BetterMail.App/MainWindowViewModel.cs index b9c21f1..c6894c5 100644 --- a/src/BetterMail.App/MainWindowViewModel.cs +++ b/src/BetterMail.App/MainWindowViewModel.cs @@ -161,7 +161,22 @@ public bool MailSenderImagesEnabled private MailPageCursor? _messagePageCursor; private IReadOnlyList _messagePageFolders = []; private bool _isLoadingMoreMessages; - private Task>? _recipientDirectoryTask; + private RecipientDirectoryCache? _recipientDirectory; + private string[] _recipientDirectoryScope = []; + internal event Action? RecipientDirectoryChanged; + private RecipientDirectoryCache RecipientDirectory + { + get + { + if (_recipientDirectory is not null) return _recipientDirectory; + var directory = new RecipientDirectoryCache(LoadRecipientContactsAsync, LoadRecipientHistoryAsync); + directory.Changed += () => + { + if (ReferenceEquals(directory, _recipientDirectory)) RecipientDirectoryChanged?.Invoke(); + }; + return _recipientDirectory = directory; + } + } private CalendarEventSource? _nextCalendarEvent; internal string DataDirectory => _dataDirectory; @@ -1521,7 +1536,7 @@ await RunBusyAsync("Loading mailbox…", async () => await RefreshNextCalendarEventAsync(); StartAutoSync(); _ = ReconcileAllDraftsInBackgroundAsync(); - _recipientDirectoryTask = LoadRecipientDirectoryAsync(); + _ = RecipientDirectory.RefreshAsync(); } private async Task ConnectAsync() @@ -3799,46 +3814,32 @@ await _store.UpsertWorkspaceItemsAsync( .Select(result => $"{result.Owner.DisplayName}: {result.Error} Open Settings > Accounts and re-authenticate if access expired.")); RaisePropertyChanged(nameof(IsWorkspaceEmpty)); - _recipientDirectoryTask = null; + _ = RecipientDirectory.RefreshAsync(); } - internal async Task> SearchRecipientSuggestionsAsync( - string query, - CancellationToken cancellationToken) - { - var directory = await (_recipientDirectoryTask ??= LoadRecipientDirectoryAsync()) - .WaitAsync(cancellationToken); - return directory - .Where(suggestion => Contains(suggestion.DisplayName, query) || Contains(suggestion.Address, query)) - .OrderByDescending(suggestion => suggestion.Address.StartsWith(query, StringComparison.OrdinalIgnoreCase)) - .ThenByDescending(suggestion => suggestion.DisplayName.StartsWith(query, StringComparison.OrdinalIgnoreCase)) - .ThenBy(static suggestion => suggestion.DisplayName, StringComparer.OrdinalIgnoreCase) - .Take(20) - .ToArray(); - } + internal Task> SearchRecipientSuggestionsAsync( + string query, CancellationToken cancellationToken) => + RecipientDirectory.SearchAsync(query.Trim(), cancellationToken); - private async Task> LoadRecipientDirectoryAsync() + private async Task> LoadRecipientContactsAsync() { - if (_store is null) - { - return []; - } + if (_store is null) return []; var suggestions = new List(); - foreach (var owner in ContactOwners) + foreach (var owner in ContactOwners.ToArray()) { var contacts = await _store.GetWorkspaceItemsAsync( "contact", owner.CacheId, "all"); suggestions.AddRange(contacts.SelectMany(contact => contact.EmailAddresses.Select(address => new RecipientSuggestion(contact.DisplayName, address, owner.DisplayName)))); } - if (_store.HasIndexedCorrespondents) - { - suggestions.AddRange((await _store.GetDiscoveredPeopleAsync("", 5000)) - .Select(person => new RecipientSuggestion(person.DisplayName, person.EmailAddress, "Mail history"))); - } - return suggestions - .Where(static suggestion => !string.IsNullOrWhiteSpace(suggestion.Address)) - .DistinctBy(static suggestion => suggestion.Address, StringComparer.OrdinalIgnoreCase) + return suggestions; + } + + private async Task> LoadRecipientHistoryAsync() + { + if (_store is null || !_store.HasIndexedCorrespondents) return []; + return (await _store.GetDiscoveredPeopleAsync("", 5000)) + .Select(person => new RecipientSuggestion(person.DisplayName, person.EmailAddress, "Mail history")) .ToArray(); } private Task EditContactAsync(PersonEntry person) @@ -4839,7 +4840,6 @@ join account in Accounts on mailbox.AccountId equals account.AccountId private void RebuildContactOwners() { - _recipientDirectoryTask = null; var selectedMailboxId = SelectedContactOwner?.Mailbox.Id; Replace(ContactOwners, from mailbox in Mailboxes.Concat(Accounts @@ -4853,6 +4853,16 @@ where account.Capabilities.HasFlag(ProviderCapabilities.Contacts) && SelectedContactOwner = ContactOwners.FirstOrDefault(owner => owner.Mailbox.Id == selectedMailboxId) ?? ContactOwners.FirstOrDefault(); RefreshContactCommands(); + var scope = ContactOwners.Select(owner => $"contact:{owner.CacheId}:{owner.DisplayName}") + .Concat(Mailboxes.Select(mailbox => $"mail:{mailbox.Id}")) + .Order(StringComparer.Ordinal).ToArray(); + if (!_recipientDirectoryScope.SequenceEqual(scope)) + { + // Drop removed accounts immediately, but don't discard a warm cache on ordinary sync. + _recipientDirectoryScope = scope; + _recipientDirectory = null; + if (_initializationComplete) _ = RecipientDirectory.RefreshAsync(); + } } private void OnSenderSignatureChanged(SenderSettingsItem item) diff --git a/src/BetterMail.App/RecipientDirectoryCache.cs b/src/BetterMail.App/RecipientDirectoryCache.cs new file mode 100644 index 0000000..674a007 --- /dev/null +++ b/src/BetterMail.App/RecipientDirectoryCache.cs @@ -0,0 +1,74 @@ +namespace BetterMail.App; + +// Owned by the UI thread. Keep the last usable snapshot while local storage refreshes. +internal sealed class RecipientDirectoryCache( + Func>> loadContacts, + Func>> loadHistory) +{ + private IReadOnlyList? _snapshot; + private IReadOnlyList _history = []; + private Task? _refresh; + private bool _refreshAgain; + private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); + public event Action? Changed; + + public async Task> SearchAsync(string query, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + if (_snapshot is null) + { + if (_refresh is null) _ = RefreshAsync(); + await _ready.Task.WaitAsync(token); + } + return (_snapshot ?? []) + .Where(item => item.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) || + item.Address.Contains(query, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(item => item.Address.StartsWith(query, StringComparison.OrdinalIgnoreCase)) + .ThenByDescending(item => item.DisplayName.StartsWith(query, StringComparison.OrdinalIgnoreCase)) + .ThenBy(static item => item.DisplayName, StringComparer.OrdinalIgnoreCase) + .Take(20).ToArray(); + } + + public Task RefreshAsync() + { + if (_refresh is { IsCompleted: false }) + { + _refreshAgain = true; + return _refresh; + } + return _refresh = RefreshCoreAsync(); + } + + private async Task RefreshCoreAsync() + { + do + { + _refreshAgain = false; + try + { + var contacts = await loadContacts(); + Publish(contacts); + // Publish saved contacts first: autocomplete must not wait for a mail-history aggregate. + _history = await loadHistory(); + Publish(contacts); + } + catch (Exception exception) + { + // A failed refresh must neither discard known recipients nor fault every keystroke. + System.Diagnostics.Trace.TraceWarning("Local recipient directory refresh failed: {0}", exception.GetType().Name); + _snapshot ??= []; + _ready.TrySetResult(); + } + } while (_refreshAgain); + } + + private void Publish(IReadOnlyList contacts) + { + _snapshot = contacts.Concat(_history) + .Where(static item => !string.IsNullOrWhiteSpace(item.Address)) + .DistinctBy(static item => item.Address, StringComparer.OrdinalIgnoreCase) + .ToArray(); + _ready.TrySetResult(); + Changed?.Invoke(); + } +} diff --git a/src/BetterMail.App/RichHtmlEditor.axaml.cs b/src/BetterMail.App/RichHtmlEditor.axaml.cs index d26489d..46fa035 100644 --- a/src/BetterMail.App/RichHtmlEditor.axaml.cs +++ b/src/BetterMail.App/RichHtmlEditor.axaml.cs @@ -15,6 +15,7 @@ public sealed partial class RichHtmlEditor : UserControl private bool _updatingFromEditor; private bool _initialized; private const string AttachmentMessagePrefix = "bettermail:attachment:"; + private const string FocusMessage = "bettermail:editor-focus"; public event Action? AttachmentDropped; public static readonly StyledProperty HtmlProperty = AvaloniaProperty.Register( @@ -29,6 +30,13 @@ public sealed partial class RichHtmlEditor : UserControl public RichHtmlEditor() { InitializeComponent(); + Editor.GotFocus += (_, _) => + { + if (_ready && Editor.IsEffectivelyVisible) + LinuxWebViewFocus.Focus(Editor.TryGetPlatformHandle(), TopLevel.GetTopLevel(this)?.TryGetPlatformHandle()); + }; + Editor.LostFocus += (_, _) => LinuxWebViewFocus.RestoreOwner( + Editor.TryGetPlatformHandle(), TopLevel.GetTopLevel(this)?.TryGetPlatformHandle()); AttachedToVisualTree += InitializeEditor; } @@ -59,7 +67,9 @@ protected override async void OnPropertyChanged(AvaloniaPropertyChangedEventArgs } if (change.Property == HtmlProperty) { - await Editor.InvokeScript($"editor.innerHTML={JsonSerializer.Serialize(Html)}"); + // Two-way bindings can echo an input event back after its handler returns. + // Replacing identical HTML destroys the browser's selection and undo history. + await Editor.InvokeScript($"if(editor.innerHTML!=={JsonSerializer.Serialize(Html)}) editor.innerHTML={JsonSerializer.Serialize(Html)}"); } else if (change.Property == IsReadOnlyProperty) { @@ -98,6 +108,7 @@ private void InitializeEditor(object? sender, VisualTreeAttachmentEventArgs e) contenteditable="{{(!IsReadOnly).ToString().ToLowerInvariant()}}">