From 15400d40967a01594142a835bc14f8a489d05294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sun, 8 Mar 2026 13:21:42 +0100 Subject: [PATCH] Improved keyboad shortcuts. --- .../CalendarAppShellViewModel.cs | 13 ++ .../CalendarPageViewModel.cs | 36 +++- .../EventDetailsPageViewModel.cs | 10 + .../Entities/Shared/KeyboardShortcut.cs | 13 +- .../Enums/KeyboardShortcutAction.cs | 16 ++ .../Interfaces/IKeyboardShortcutService.cs | 12 +- .../Models/KeyboardShortcutDialogResult.cs | 16 +- .../Models/KeyboardShortcutTriggerDetails.cs | 16 ++ .../Translations/en_US/resources.json | 10 + Wino.Core.ViewModels/CoreBaseViewModel.cs | 3 + .../Data/BreadcrumbNavigationItemViewModel.cs | 5 +- .../Data/KeyboardShortcutActionViewModel.cs | 33 +++ .../Data/KeyboardShortcutViewModel.cs | 44 ++-- .../KeyboardShortcutsPageViewModel.cs | 10 +- Wino.Mail.ViewModels/ComposePageViewModel.cs | 13 ++ Wino.Mail.ViewModels/MailAppShellViewModel.cs | 13 ++ Wino.Mail.ViewModels/MailListPageViewModel.cs | 85 ++++++++ Wino.Mail.WinUI/BasePage.cs | 3 + .../Dialogs/KeyboardShortcutDialog.xaml | 55 +++-- .../Dialogs/KeyboardShortcutDialog.xaml.cs | 202 ++++++++++++------ .../Helpers/BreadcrumbNavigationHelper.cs | 115 ++++++++++ Wino.Mail.WinUI/MailAppShell.xaml.cs | 97 +++++++++ .../Views/Calendar/CalendarAppShell.xaml.cs | 111 +++++++++- .../Calendar/CalendarEventComposePage.xaml | 22 +- .../Calendar/CalendarEventComposePage.xaml.cs | 10 + Wino.Mail.WinUI/Views/Mail/ComposePage.xaml | 15 +- .../Views/Mail/ComposePage.xaml.cs | 10 + .../Views/Mail/MailListPage.xaml.cs | 15 +- .../Views/ManageAccountsPage.xaml.cs | 50 +---- Wino.Mail.WinUI/Views/SettingOptionsPage.xaml | 11 + .../Views/Settings/KeyboardShortcutsPage.xaml | 15 +- Wino.Mail.WinUI/Views/SettingsPage.xaml.cs | 50 +---- Wino.Mail.WinUI/Views/WelcomeHostPage.xaml.cs | 39 +--- Wino.Services/DatabaseService.cs | 41 ++++ Wino.Services/KeyboardShortcutService.cs | 106 ++++----- 35 files changed, 979 insertions(+), 336 deletions(-) create mode 100644 Wino.Core.Domain/Enums/KeyboardShortcutAction.cs create mode 100644 Wino.Core.Domain/Models/KeyboardShortcutTriggerDetails.cs create mode 100644 Wino.Core.ViewModels/Data/KeyboardShortcutActionViewModel.cs create mode 100644 Wino.Mail.WinUI/Helpers/BreadcrumbNavigationHelper.cs diff --git a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs index 6cafeb36..8930f74b 100644 --- a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs @@ -17,6 +17,7 @@ using Wino.Core.Domain.Enums; using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Calendar; +using Wino.Core.Domain.Models; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Synchronization; using Wino.Core.ViewModels; @@ -393,6 +394,18 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel, }); } + public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args) + { + if (args.Handled || args.Mode != WinoApplicationMode.Calendar) + return; + + if (args.Action == KeyboardShortcutAction.NewEvent) + { + await NewEventAsync(); + args.Handled = true; + } + } + [RelayCommand] diff --git a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs index 28f69fbb..5b9d7c59 100644 --- a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs +++ b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs @@ -14,6 +14,7 @@ using Serilog; using Wino.Calendar.ViewModels.Data; using Wino.Calendar.ViewModels.Interfaces; using Wino.Calendar.ViewModels.Messages; +using Wino.Core.Domain; using Wino.Core.Domain.Collections; using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Enums; @@ -21,6 +22,7 @@ using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Calendar; using Wino.Core.Domain.Models.Calendar.CalendarTypeStrategies; +using Wino.Core.Domain.Models; using Wino.Core.Domain.Models.Navigation; using Wino.Core.ViewModels; using Wino.Messaging.Client.Calendar; @@ -135,6 +137,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, private readonly INativeAppService _nativeAppService; private readonly IPreferencesService _preferencesService; private readonly IWinoRequestDelegator _winoRequestDelegator; + private readonly IMailDialogService _dialogService; // Store latest rendered options. private CalendarDisplayType _currentDisplayType; @@ -156,7 +159,8 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, INativeAppService nativeAppService, IAccountCalendarStateService accountCalendarStateService, IPreferencesService preferencesService, - IWinoRequestDelegator winoRequestDelegator) + IWinoRequestDelegator winoRequestDelegator, + IMailDialogService dialogService) { StatePersistanceService = statePersistanceService; AccountCalendarStateService = accountCalendarStateService; @@ -167,6 +171,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, _nativeAppService = nativeAppService; _preferencesService = preferencesService; _winoRequestDelegator = winoRequestDelegator; + _dialogService = dialogService; AccountCalendarStateService.AccountCalendarSelectionStateChanged += UpdateAccountCalendarRequested; AccountCalendarStateService.CollectiveAccountGroupSelectionStateChanged += AccountCalendarStateCollectivelyChanged; @@ -175,6 +180,35 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel, RegisterRecipients(); } + public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args) + { + if (args.Handled || args.Mode != WinoApplicationMode.Calendar || args.Action != KeyboardShortcutAction.Delete) + return; + + if (DisplayDetailsCalendarItemViewModel?.CalendarItem == null) + return; + + if (DisplayDetailsCalendarItemViewModel.CalendarItem.IsRecurringParent) + { + var confirmed = await _dialogService.ShowConfirmationDialogAsync( + Translator.DialogMessage_DeleteRecurringSeriesMessage, + Translator.DialogMessage_DeleteRecurringSeriesTitle, + Translator.Buttons_Delete); + + if (!confirmed) + return; + } + + var preparationRequest = new CalendarOperationPreparationRequest( + CalendarSynchronizerOperation.DeleteEvent, + DisplayDetailsCalendarItemViewModel.CalendarItem, + null); + + await _winoRequestDelegator.ExecuteAsync(preparationRequest); + DisplayDetailsCalendarItemViewModel = null; + args.Handled = true; + } + protected override void RegisterRecipients() { base.RegisterRecipients(); diff --git a/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs b/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs index f2064610..5840a669 100644 --- a/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs +++ b/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs @@ -15,6 +15,7 @@ using Wino.Core.Domain.Entities.Calendar; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Calendar; +using Wino.Core.Domain.Models; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Services; using Wino.Core.ViewModels; @@ -498,6 +499,15 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel } } + public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args) + { + if (args.Handled || args.Mode != WinoApplicationMode.Calendar || args.Action != KeyboardShortcutAction.Delete) + return; + + await DeleteAsync(); + args.Handled = true; + } + [RelayCommand] private Task JoinOnlineAsync() { diff --git a/Wino.Core.Domain/Entities/Shared/KeyboardShortcut.cs b/Wino.Core.Domain/Entities/Shared/KeyboardShortcut.cs index d89c4952..8b066ad5 100644 --- a/Wino.Core.Domain/Entities/Shared/KeyboardShortcut.cs +++ b/Wino.Core.Domain/Entities/Shared/KeyboardShortcut.cs @@ -12,6 +12,11 @@ public class KeyboardShortcut [PrimaryKey] public Guid Id { get; set; } + /// + /// The application mode this shortcut applies to. + /// + public WinoApplicationMode Mode { get; set; } = WinoApplicationMode.Mail; + /// /// The key combination string (e.g., "D", "Delete", "F1"). /// @@ -23,9 +28,9 @@ public class KeyboardShortcut public ModifierKeys ModifierKeys { get; set; } /// - /// The mail operation this shortcut triggers. + /// The shortcut action this shortcut triggers. /// - public MailOperation MailOperation { get; set; } + public KeyboardShortcutAction Action { get; set; } /// /// Whether this shortcut is enabled. @@ -55,6 +60,6 @@ public class KeyboardShortcut modifierText += "Win+"; return modifierText + Key; - } + } } -} \ No newline at end of file +} diff --git a/Wino.Core.Domain/Enums/KeyboardShortcutAction.cs b/Wino.Core.Domain/Enums/KeyboardShortcutAction.cs new file mode 100644 index 00000000..f48f5cf4 --- /dev/null +++ b/Wino.Core.Domain/Enums/KeyboardShortcutAction.cs @@ -0,0 +1,16 @@ +namespace Wino.Core.Domain.Enums; + +public enum KeyboardShortcutAction +{ + None, + NewMail, + ToggleReadUnread, + ToggleFlag, + ToggleArchive, + Delete, + Move, + Reply, + ReplyAll, + Send, + NewEvent +} diff --git a/Wino.Core.Domain/Interfaces/IKeyboardShortcutService.cs b/Wino.Core.Domain/Interfaces/IKeyboardShortcutService.cs index 1e7098ec..eb242d84 100644 --- a/Wino.Core.Domain/Interfaces/IKeyboardShortcutService.cs +++ b/Wino.Core.Domain/Interfaces/IKeyboardShortcutService.cs @@ -37,21 +37,23 @@ public interface IKeyboardShortcutService Task DeleteKeyboardShortcutAsync(Guid shortcutId); /// - /// Gets the mail operation for the given key combination. + /// Gets the keyboard shortcut for the given key combination in a specific mode. /// + /// The application mode to search within. /// The pressed key. /// The modifier keys pressed. - /// The mail operation if found, otherwise null. - Task GetMailOperationForKeyAsync(string key, ModifierKeys modifierKeys); + /// The matching shortcut if found, otherwise null. + Task GetShortcutForKeyAsync(WinoApplicationMode mode, string key, ModifierKeys modifierKeys); /// /// Checks if a key combination is already assigned to another shortcut. /// + /// The application mode to check within. /// The key to check. /// The modifier keys to check. /// Optional ID to exclude from the check (for updates). /// True if the combination is already used, false otherwise. - Task IsKeyCombinationInUseAsync(string key, ModifierKeys modifierKeys, Guid? excludeShortcutId = null); + Task IsKeyCombinationInUseAsync(WinoApplicationMode mode, string key, ModifierKeys modifierKeys, Guid? excludeShortcutId = null); /// /// Creates default keyboard shortcuts for common mail operations. @@ -62,4 +64,4 @@ public interface IKeyboardShortcutService /// Resets all shortcuts to defaults. /// Task ResetToDefaultShortcutsAsync(); -} \ No newline at end of file +} diff --git a/Wino.Core.Domain/Models/KeyboardShortcutDialogResult.cs b/Wino.Core.Domain/Models/KeyboardShortcutDialogResult.cs index 4c4cab97..d8079ce5 100644 --- a/Wino.Core.Domain/Models/KeyboardShortcutDialogResult.cs +++ b/Wino.Core.Domain/Models/KeyboardShortcutDialogResult.cs @@ -12,6 +12,11 @@ public class KeyboardShortcutDialogResult /// public bool IsSuccess { get; set; } + /// + /// The application mode selected by the user. + /// + public WinoApplicationMode Mode { get; set; } = WinoApplicationMode.Mail; + /// /// The key combination entered by the user. /// @@ -23,21 +28,22 @@ public class KeyboardShortcutDialogResult public ModifierKeys ModifierKeys { get; set; } /// - /// The mail operation selected by the user. + /// The shortcut action selected by the user. /// - public MailOperation MailOperation { get; set; } + public KeyboardShortcutAction Action { get; set; } /// /// Creates a successful result. /// - public static KeyboardShortcutDialogResult Success(string key, ModifierKeys modifierKeys, MailOperation mailOperation) + public static KeyboardShortcutDialogResult Success(WinoApplicationMode mode, string key, ModifierKeys modifierKeys, KeyboardShortcutAction action) { return new KeyboardShortcutDialogResult { IsSuccess = true, + Mode = mode, Key = key, ModifierKeys = modifierKeys, - MailOperation = mailOperation + Action = action }; } @@ -51,4 +57,4 @@ public class KeyboardShortcutDialogResult IsSuccess = false }; } -} \ No newline at end of file +} diff --git a/Wino.Core.Domain/Models/KeyboardShortcutTriggerDetails.cs b/Wino.Core.Domain/Models/KeyboardShortcutTriggerDetails.cs new file mode 100644 index 00000000..40ac92b2 --- /dev/null +++ b/Wino.Core.Domain/Models/KeyboardShortcutTriggerDetails.cs @@ -0,0 +1,16 @@ +using System; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.Domain.Models; + +public class KeyboardShortcutTriggerDetails +{ + public Guid ShortcutId { get; init; } + public WinoApplicationMode Mode { get; init; } + public KeyboardShortcutAction Action { get; init; } + public string Key { get; init; } = string.Empty; + public ModifierKeys ModifierKeys { get; init; } + public bool Handled { get; set; } + public object Sender { get; init; } + public object Origin { get; init; } +} diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 58821952..51b0f6d5 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -362,19 +362,29 @@ "KeyboardShortcuts_FailedToReset": "Failed to reset keyboard shortcuts.", "KeyboardShortcuts_FailedToUpdate": "Failed to update keyboard shortcuts", "KeyboardShortcuts_MailoperationAction": "Action", + "KeyboardShortcuts_Action": "Action", "KeyboardShortcuts_FailedToLoad": "Failed to load keyboard shortcuts.", "KeyboardShortcuts_EnterKeyForShortcut": "Please enter a key for the shortcut.", "KeyboardShortcuts_SelectOperationForShortcut": "Please an action to perform for the shortcut.", + "KeyboardShortcuts_EnterKey": "Please enter a key for the shortcut.", + "KeyboardShortcuts_SelectOperation": "Please select an action for the shortcut.", "KeyboardShortcuts_ShortcutInUse": "This shortcut is already in use by another s hortcut.", "KeyboardShortcuts_FailedToSave": "Failed to save the shortcut.", "KeyboardShortcuts_FailedToDelete": "Failed to delete the shortcut.", "KeyboardShortcuts_PageDescription": "Set up keyboard shortcuts for quick mail operations. Press keys while focused on the key input field to capture shortcuts.", "KeyboardShortcuts_Add": "Add shortcut", + "KeyboardShortcuts_EditTitle": "Edit Keyboard Shortcut", "KeyboardShortcuts_ResetToDefaults": "Reset to Defaults", "KeyboardShortcuts_PressKeysHere": "Press keys here...", "KeyboardShortcuts_KeyCombination": "Key Combination", "KeyboardShortcuts_FocusArea": "Focus the field above and press the desired key combination", "KeyboardShortcuts_Modifiers": "Modifier Keys", + "KeyboardShortcuts_Mode": "App Mode", + "KeyboardShortcuts_ModeMail": "Mail", + "KeyboardShortcuts_ModeCalendar": "Calendar", + "KeyboardShortcuts_ActionToggleReadUnread": "Toggle read/unread", + "KeyboardShortcuts_ActionToggleFlag": "Toggle flag", + "KeyboardShortcuts_ActionToggleArchive": "Toggle archive/unarchive", "ImageRenderingDisabled": "Image rendering is disabled for this message.", "ImapAdvancedSetupDialog_AuthenticationMethod": "Authentication method", "ImapAdvancedSetupDialog_ConnectionSecurity": "Connection security", diff --git a/Wino.Core.ViewModels/CoreBaseViewModel.cs b/Wino.Core.ViewModels/CoreBaseViewModel.cs index bf604998..17abbf45 100644 --- a/Wino.Core.ViewModels/CoreBaseViewModel.cs +++ b/Wino.Core.ViewModels/CoreBaseViewModel.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models; using Wino.Core.Domain.Models.Navigation; namespace Wino.Core.ViewModels; @@ -40,6 +41,8 @@ public class CoreBaseViewModel : ObservableRecipient, INavigationAware public virtual void OnPageLoaded() { } + public virtual Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args) => Task.CompletedTask; + public Task ExecuteUIThread(Action action) { if (action == null) return Task.CompletedTask; diff --git a/Wino.Core.ViewModels/Data/BreadcrumbNavigationItemViewModel.cs b/Wino.Core.ViewModels/Data/BreadcrumbNavigationItemViewModel.cs index 55941039..4d39385b 100644 --- a/Wino.Core.ViewModels/Data/BreadcrumbNavigationItemViewModel.cs +++ b/Wino.Core.ViewModels/Data/BreadcrumbNavigationItemViewModel.cs @@ -13,13 +13,16 @@ public partial class BreadcrumbNavigationItemViewModel : ObservableObject public int StepNumber { get; set; } + public int BackStackDepth { get; set; } + public BreadcrumbNavigationRequested Request { get; set; } - public BreadcrumbNavigationItemViewModel(BreadcrumbNavigationRequested request, bool isActive, int stepNumber = 0) + public BreadcrumbNavigationItemViewModel(BreadcrumbNavigationRequested request, bool isActive, int stepNumber = 0, int backStackDepth = 0) { Request = request; Title = request.PageTitle; IsActive = isActive; StepNumber = stepNumber; + BackStackDepth = backStackDepth; } } diff --git a/Wino.Core.ViewModels/Data/KeyboardShortcutActionViewModel.cs b/Wino.Core.ViewModels/Data/KeyboardShortcutActionViewModel.cs new file mode 100644 index 00000000..85a2c624 --- /dev/null +++ b/Wino.Core.ViewModels/Data/KeyboardShortcutActionViewModel.cs @@ -0,0 +1,33 @@ +using Wino.Core.Domain; +using Wino.Core.Domain.Enums; + +namespace Wino.Core.ViewModels.Data; + +public class KeyboardShortcutActionViewModel +{ + public WinoApplicationMode Mode { get; } + public KeyboardShortcutAction Action { get; } + + public string DisplayName => Action switch + { + KeyboardShortcutAction.NewMail => Translator.MenuNewMail, + KeyboardShortcutAction.ToggleReadUnread => Translator.KeyboardShortcuts_ActionToggleReadUnread, + KeyboardShortcutAction.ToggleFlag => Translator.KeyboardShortcuts_ActionToggleFlag, + KeyboardShortcutAction.ToggleArchive => Translator.KeyboardShortcuts_ActionToggleArchive, + KeyboardShortcutAction.Delete => Translator.Buttons_Delete, + KeyboardShortcutAction.Move => Translator.MailOperation_Move, + KeyboardShortcutAction.Reply => Translator.MailOperation_Reply, + KeyboardShortcutAction.ReplyAll => Translator.MailOperation_ReplyAll, + KeyboardShortcutAction.Send => Translator.Buttons_Send, + KeyboardShortcutAction.NewEvent => Translator.CalendarEventCompose_NewEventButton, + _ => Action.ToString() + }; + + public KeyboardShortcutActionViewModel(WinoApplicationMode mode, KeyboardShortcutAction action) + { + Mode = mode; + Action = action; + } + + public override string ToString() => DisplayName; +} diff --git a/Wino.Core.ViewModels/Data/KeyboardShortcutViewModel.cs b/Wino.Core.ViewModels/Data/KeyboardShortcutViewModel.cs index 36c7b190..6ee4e1cb 100644 --- a/Wino.Core.ViewModels/Data/KeyboardShortcutViewModel.cs +++ b/Wino.Core.ViewModels/Data/KeyboardShortcutViewModel.cs @@ -15,9 +15,10 @@ public partial class KeyboardShortcutViewModel : ObservableObject public partial bool IsEnabled { get; set; } public Guid Id { get; } + public WinoApplicationMode Mode { get; } public string Key { get; } public ModifierKeys ModifierKeys { get; } - public MailOperation MailOperation { get; } + public KeyboardShortcutAction Action { get; } public DateTime CreatedAt { get; } public string DisplayName @@ -38,25 +39,30 @@ public partial class KeyboardShortcutViewModel : ObservableObject } } - public string MailOperationDisplayName + public string ModeDisplayName => Mode switch + { + WinoApplicationMode.Mail => Translator.KeyboardShortcuts_ModeMail, + WinoApplicationMode.Calendar => Translator.KeyboardShortcuts_ModeCalendar, + _ => Mode.ToString() + }; + + public string ActionDisplayName { get { - return MailOperation switch + return Action switch { - MailOperation.Archive => Translator.MailOperation_Archive, - MailOperation.UnArchive => Translator.MailOperation_Unarchive, - MailOperation.SoftDelete => Translator.MailOperation_Delete, - MailOperation.Move => Translator.MailOperation_Move, - MailOperation.MoveToJunk => Translator.MailOperation_MoveJunk, - MailOperation.SetFlag => Translator.MailOperation_SetFlag, - MailOperation.ClearFlag => Translator.MailOperation_ClearFlag, - MailOperation.MarkAsRead => Translator.MailOperation_MarkAsRead, - MailOperation.MarkAsUnread => Translator.MailOperation_MarkAsUnread, - MailOperation.Reply => Translator.MailOperation_Reply, - MailOperation.ReplyAll => Translator.MailOperation_ReplyAll, - MailOperation.Forward => Translator.MailOperation_Forward, - _ => MailOperation.ToString() + KeyboardShortcutAction.NewMail => Translator.MenuNewMail, + KeyboardShortcutAction.ToggleReadUnread => Translator.KeyboardShortcuts_ActionToggleReadUnread, + KeyboardShortcutAction.ToggleFlag => Translator.KeyboardShortcuts_ActionToggleFlag, + KeyboardShortcutAction.ToggleArchive => Translator.KeyboardShortcuts_ActionToggleArchive, + KeyboardShortcutAction.Delete => Translator.Buttons_Delete, + KeyboardShortcutAction.Move => Translator.MailOperation_Move, + KeyboardShortcutAction.Reply => Translator.MailOperation_Reply, + KeyboardShortcutAction.ReplyAll => Translator.MailOperation_ReplyAll, + KeyboardShortcutAction.Send => Translator.Buttons_Send, + KeyboardShortcutAction.NewEvent => Translator.CalendarEventCompose_NewEventButton, + _ => Action.ToString() }; } } @@ -64,9 +70,10 @@ public partial class KeyboardShortcutViewModel : ObservableObject public KeyboardShortcutViewModel(KeyboardShortcut shortcut) { Id = shortcut.Id; + Mode = shortcut.Mode; Key = shortcut.Key; ModifierKeys = shortcut.ModifierKeys; - MailOperation = shortcut.MailOperation; + Action = shortcut.Action; CreatedAt = shortcut.CreatedAt; IsEnabled = shortcut.IsEnabled; } @@ -76,9 +83,10 @@ public partial class KeyboardShortcutViewModel : ObservableObject return new KeyboardShortcut { Id = Id, + Mode = Mode, Key = Key, ModifierKeys = ModifierKeys, - MailOperation = MailOperation, + Action = Action, CreatedAt = CreatedAt, IsEnabled = IsEnabled }; diff --git a/Wino.Core.ViewModels/KeyboardShortcutsPageViewModel.cs b/Wino.Core.ViewModels/KeyboardShortcutsPageViewModel.cs index e190c0c5..a4c27da2 100644 --- a/Wino.Core.ViewModels/KeyboardShortcutsPageViewModel.cs +++ b/Wino.Core.ViewModels/KeyboardShortcutsPageViewModel.cs @@ -70,7 +70,7 @@ public partial class KeyboardShortcutsPageViewModel : CoreBaseViewModel try { // Check if key combination is already in use - var isInUse = await _keyboardShortcutService.IsKeyCombinationInUseAsync(result.Key, result.ModifierKeys, null); + var isInUse = await _keyboardShortcutService.IsKeyCombinationInUseAsync(result.Mode, result.Key, result.ModifierKeys, null); if (isInUse) { await _dialogService.ShowMessageAsync(Translator.KeyboardShortcuts_ShortcutInUse, Translator.GeneralTitle_Error, WinoCustomMessageDialogIcon.Error); @@ -80,9 +80,10 @@ public partial class KeyboardShortcutsPageViewModel : CoreBaseViewModel // Create new shortcut var shortcut = new KeyboardShortcut { + Mode = result.Mode, Key = result.Key, ModifierKeys = result.ModifierKeys, - MailOperation = result.MailOperation, + Action = result.Action, IsEnabled = true }; @@ -116,7 +117,7 @@ public partial class KeyboardShortcutsPageViewModel : CoreBaseViewModel try { // Check if key combination is already in use (excluding current shortcut) - var isInUse = await _keyboardShortcutService.IsKeyCombinationInUseAsync(result.Key, result.ModifierKeys, shortcut.Id); + var isInUse = await _keyboardShortcutService.IsKeyCombinationInUseAsync(result.Mode, result.Key, result.ModifierKeys, shortcut.Id); if (isInUse) { await _dialogService.ShowMessageAsync(Translator.KeyboardShortcuts_ShortcutInUse, Translator.GeneralTitle_Error, WinoCustomMessageDialogIcon.Error); @@ -125,9 +126,10 @@ public partial class KeyboardShortcutsPageViewModel : CoreBaseViewModel // Update existing shortcut var updatedShortcut = shortcut.ToEntity(); + updatedShortcut.Mode = result.Mode; updatedShortcut.Key = result.Key; updatedShortcut.ModifierKeys = result.ModifierKeys; - updatedShortcut.MailOperation = result.MailOperation; + updatedShortcut.Action = result.Action; await _keyboardShortcutService.SaveKeyboardShortcutAsync(updatedShortcut); await LoadShortcutsAsync(); diff --git a/Wino.Mail.ViewModels/ComposePageViewModel.cs b/Wino.Mail.ViewModels/ComposePageViewModel.cs index fea6a408..be5b3e31 100644 --- a/Wino.Mail.ViewModels/ComposePageViewModel.cs +++ b/Wino.Mail.ViewModels/ComposePageViewModel.cs @@ -18,6 +18,7 @@ using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.MailItem; +using Wino.Core.Domain.Models; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Extensions; using Wino.Core.Services; @@ -38,6 +39,18 @@ public partial class ComposePageViewModel : MailBaseViewModel, public Func> GetHTMLBodyFunction; + public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args) + { + if (args.Handled || args.Mode != WinoApplicationMode.Mail) + return; + + if (args.Action == KeyboardShortcutAction.Send) + { + await SendAsync(); + args.Handled = true; + } + } + // When we send the message or discard it, we need to block the mime update // Update is triggered when we leave the page. private bool isUpdatingMimeBlocked = false; diff --git a/Wino.Mail.ViewModels/MailAppShellViewModel.cs b/Wino.Mail.ViewModels/MailAppShellViewModel.cs index 5965ba47..b4a4ca3f 100644 --- a/Wino.Mail.ViewModels/MailAppShellViewModel.cs +++ b/Wino.Mail.ViewModels/MailAppShellViewModel.cs @@ -16,6 +16,7 @@ using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.MenuItems; using Wino.Core.Domain.Models.Folders; +using Wino.Core.Domain.Models; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Synchronization; @@ -935,6 +936,18 @@ public partial class MailAppShellViewModel : MailBaseViewModel, await _winoRequestDelegator.ExecuteAsync(draftPreparationRequest); } + public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args) + { + if (args.Handled || args.Mode != WinoApplicationMode.Mail) + return; + + if (args.Action == KeyboardShortcutAction.NewMail) + { + await HandleCreateNewMailAsync(); + args.Handled = true; + } + } + // TODO: Handle by messaging. diff --git a/Wino.Mail.ViewModels/MailListPageViewModel.cs b/Wino.Mail.ViewModels/MailListPageViewModel.cs index 7ae1f4bd..50e56849 100644 --- a/Wino.Mail.ViewModels/MailListPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailListPageViewModel.cs @@ -18,6 +18,7 @@ using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Folders; +using Wino.Core.Domain.Models; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Menus; using Wino.Core.Domain.Models.Navigation; @@ -72,6 +73,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, private readonly IAccountService _accountService; private readonly IMailDialogService _mailDialogService; private readonly IMailService _mailService; + private readonly IMimeFileService _mimeFileService; private readonly INotificationBuilder _notificationBuilder; private readonly IFolderService _folderService; private readonly IContextMenuItemService _contextMenuItemService; @@ -165,6 +167,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, IAccountService accountService, IMailDialogService mailDialogService, IMailService mailService, + IMimeFileService mimeFileService, IStatePersistanceService statePersistenceService, INotificationBuilder notificationBuilder, IFolderService folderService, @@ -179,6 +182,7 @@ public partial class MailListPageViewModel : MailBaseViewModel, _accountService = accountService; _mailDialogService = mailDialogService; _mailService = mailService; + _mimeFileService = mimeFileService; _folderService = folderService; _contextMenuItemService = contextMenuItemService; _winoRequestDelegator = winoRequestDelegator; @@ -610,6 +614,87 @@ public partial class MailListPageViewModel : MailBaseViewModel, public Task ExecuteMailOperationAsync(MailOperationPreperationRequest package) => _winoRequestDelegator.ExecuteAsync(package); + public override async Task KeyboardShortcutHook(KeyboardShortcutTriggerDetails args) + { + if (args.Handled || args.Mode != WinoApplicationMode.Mail) + return; + + var targetItems = GetShortcutTargetItems().ToList(); + + switch (args.Action) + { + case KeyboardShortcutAction.ToggleReadUnread: + if (!targetItems.Any()) return; + await ExecuteMailOperationAsync(new MailOperationPreperationRequest(MailOperation.MarkAsRead, targetItems.Select(x => x.MailCopy), true)); + args.Handled = true; + break; + case KeyboardShortcutAction.ToggleFlag: + if (!targetItems.Any()) return; + await ExecuteMailOperationAsync(new MailOperationPreperationRequest(MailOperation.SetFlag, targetItems.Select(x => x.MailCopy), true)); + args.Handled = true; + break; + case KeyboardShortcutAction.ToggleArchive: + if (!targetItems.Any()) return; + await ExecuteMailOperationAsync(new MailOperationPreperationRequest(MailOperation.Archive, targetItems.Select(x => x.MailCopy), true)); + args.Handled = true; + break; + case KeyboardShortcutAction.Delete: + if (!targetItems.Any()) return; + await ExecuteMailOperationAsync(new MailOperationPreperationRequest(MailOperation.SoftDelete, targetItems.Select(x => x.MailCopy))); + args.Handled = true; + break; + case KeyboardShortcutAction.Move: + if (!targetItems.Any()) return; + await ExecuteMailOperationAsync(new MailOperationPreperationRequest(MailOperation.Move, targetItems.Select(x => x.MailCopy))); + args.Handled = true; + break; + case KeyboardShortcutAction.Reply: + await CreateReplyDraftAsync(DraftCreationReason.Reply); + args.Handled = true; + break; + case KeyboardShortcutAction.ReplyAll: + await CreateReplyDraftAsync(DraftCreationReason.ReplyAll); + args.Handled = true; + break; + } + } + + private IEnumerable GetShortcutTargetItems() + { + if (MailCollection.SelectedItemsCount > 0) + return MailCollection.SelectedItems.OfType(); + + if (_activeMailItem != null) + return [_activeMailItem]; + + return []; + } + + private async Task CreateReplyDraftAsync(DraftCreationReason reason) + { + var targetMail = GetShortcutTargetItems().FirstOrDefault(); + if (targetMail?.MailCopy == null || targetMail.MailCopy.FileId == Guid.Empty) + return; + + var mimeInformation = await _mimeFileService.GetMimeMessageInformationAsync(targetMail.MailCopy.FileId, targetMail.MailCopy.AssignedAccount.Id); + if (mimeInformation?.MimeMessage == null) + return; + + var draftOptions = new DraftCreationOptions + { + Reason = reason, + ReferencedMessage = new ReferencedMessage + { + MimeMessage = mimeInformation.MimeMessage, + MailCopy = targetMail.MailCopy + } + }; + + var (draftMailCopy, draftBase64MimeMessage) = await _mailService.CreateDraftAsync(targetMail.MailCopy.AssignedAccount.Id, draftOptions).ConfigureAwait(false); + var draftPreparationRequest = new DraftPreparationRequest(targetMail.MailCopy.AssignedAccount, draftMailCopy, draftBase64MimeMessage, draftOptions.Reason, targetMail.MailCopy); + await _winoRequestDelegator.ExecuteAsync(draftPreparationRequest); + } + public IEnumerable GetAvailableMailActions(IEnumerable contextMailItems) => _contextMenuItemService.GetMailItemContextMenuActions(contextMailItems.Select(a => a.MailCopy)); diff --git a/Wino.Mail.WinUI/BasePage.cs b/Wino.Mail.WinUI/BasePage.cs index 02229c36..1a6944c2 100644 --- a/Wino.Mail.WinUI/BasePage.cs +++ b/Wino.Mail.WinUI/BasePage.cs @@ -37,11 +37,14 @@ public partial class BasePage : Page, IRecipient /// Unregister message recipients for this page. Override to unregister specific message types. /// protected virtual void UnregisterRecipients() { } + + public virtual CoreBaseViewModel? AssociatedViewModel => null; } public abstract class BasePage : BasePage where T : CoreBaseViewModel { public T ViewModel { get; } = WinoApplication.Current.Services.GetService() ?? throw new ArgumentException($"Can't resolve '{typeof(T)}' as view model."); + public override CoreBaseViewModel AssociatedViewModel => ViewModel; protected BasePage() { diff --git a/Wino.Mail.WinUI/Dialogs/KeyboardShortcutDialog.xaml b/Wino.Mail.WinUI/Dialogs/KeyboardShortcutDialog.xaml index 0d1ee4d4..6dc8f5e3 100644 --- a/Wino.Mail.WinUI/Dialogs/KeyboardShortcutDialog.xaml +++ b/Wino.Mail.WinUI/Dialogs/KeyboardShortcutDialog.xaml @@ -21,22 +21,40 @@ + - + + + + + + + + + + Text="{x:Bind domain:Translator.KeyboardShortcuts_Action}" /> + ItemsSource="{x:Bind AvailableActions}" + SelectedItem="{x:Bind SelectedAction, Mode=TwoWay}"> - + @@ -44,7 +62,7 @@ - + - - - - - - - - - - - - - AvailableMailOperations { get; } + public List AvailableActions { get; private set; } = []; - public MailOperationViewModel SelectedMailOperation { get; set; } - public bool IsControlPressed { get; set; } - public bool IsAltPressed { get; set; } - public bool IsShiftPressed { get; set; } - public bool IsWindowsPressed { get; set; } + public KeyboardShortcutActionViewModel SelectedAction { get; set; } = null!; + public WinoApplicationMode SelectedMode { get; set; } = WinoApplicationMode.Mail; + public bool IsMailModeSelected + { + get => SelectedMode == WinoApplicationMode.Mail; + set + { + if (!value || SelectedMode == WinoApplicationMode.Mail) return; + SelectedMode = WinoApplicationMode.Mail; + RefreshAvailableActions(); + } + } + + public bool IsCalendarModeSelected + { + get => SelectedMode == WinoApplicationMode.Calendar; + set + { + if (!value || SelectedMode == WinoApplicationMode.Calendar) return; + SelectedMode = WinoApplicationMode.Calendar; + RefreshAvailableActions(); + } + } + + private ModifierKeys _modifierKeys; + private string _key = string.Empty; public KeyboardShortcutDialog() { InitializeComponent(); - AvailableMailOperations = GetAvailableMailOperations(); - SelectedMailOperation = AvailableMailOperations.FirstOrDefault()!; + RefreshAvailableActions(); } public KeyboardShortcutDialog(KeyboardShortcut existingShortcut) : this() { if (existingShortcut != null) { - KeyInputTextBox.Text = existingShortcut.Key; - SelectedMailOperation = AvailableMailOperations.FirstOrDefault(x => x.Operation == existingShortcut.MailOperation)!; - - var modifiers = existingShortcut.ModifierKeys; - IsControlPressed = modifiers.HasFlag(ModifierKeys.Control); - IsAltPressed = modifiers.HasFlag(ModifierKeys.Alt); - IsShiftPressed = modifiers.HasFlag(ModifierKeys.Shift); - IsWindowsPressed = modifiers.HasFlag(ModifierKeys.Windows); - - Title = "Edit Keyboard Shortcut"; + SelectedMode = existingShortcut.Mode; + _modifierKeys = existingShortcut.ModifierKeys; + _key = existingShortcut.Key; + RefreshAvailableActions(existingShortcut.Action); + KeyInputTextBox.Text = BuildDisplayString(_key, _modifierKeys); + Title = Translator.KeyboardShortcuts_EditTitle; } } @@ -51,61 +68,47 @@ public sealed partial class KeyboardShortcutDialog : ContentDialog ErrorBorder.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; // Validate input - if (string.IsNullOrWhiteSpace(KeyInputTextBox.Text)) + if (string.IsNullOrWhiteSpace(_key)) { - ShowError("Please enter a key for the shortcut."); + ShowError(Translator.KeyboardShortcuts_EnterKey); args.Cancel = true; return; } - if (SelectedMailOperation == null || SelectedMailOperation.Operation == MailOperation.None) + if (SelectedAction == null || SelectedAction.Action == KeyboardShortcutAction.None) { - ShowError("Please select a mail operation for the shortcut."); + ShowError(Translator.KeyboardShortcuts_SelectOperation); args.Cancel = true; return; } - // Get modifier keys - var modifierKeys = GetSelectedModifierKeys(); - - // Create successful result - Result = KeyboardShortcutDialogResult.Success(KeyInputTextBox.Text, modifierKeys, SelectedMailOperation.Operation); + Result = KeyboardShortcutDialogResult.Success(SelectedMode, _key, _modifierKeys, SelectedAction.Action); } private void KeyInputTextBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) { - // Clear error when user starts typing ErrorBorder.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; - var key = e.Key.ToString(); + _modifierKeys = GetCurrentModifierKeys(); + var key = NormalizeKey(e.Key); - // Update modifier states based on current key press - IsControlPressed = Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Control).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down); - IsAltPressed = Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Menu).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down); - IsShiftPressed = Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Shift).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down); - IsWindowsPressed = Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.LeftWindows).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down) || - Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.RightWindows).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down); - - // Set the key (ignore modifier keys themselves) - if (key != "Control" && key != "Menu" && key != "Shift" && key != "LeftWindows" && key != "RightWindows") + if (!string.IsNullOrEmpty(key)) { - KeyInputTextBox.Text = key; + _key = key; } - // Prevent the key from being processed further - // e.Handled = true; + KeyInputTextBox.Text = string.IsNullOrEmpty(_key) + ? BuildDisplayString(string.Empty, _modifierKeys) + : BuildDisplayString(_key, _modifierKeys); + + e.Handled = true; } - private ModifierKeys GetSelectedModifierKeys() + private void RefreshAvailableActions(KeyboardShortcutAction selectedAction = KeyboardShortcutAction.None) { - var modifiers = ModifierKeys.None; - - if (IsControlPressed) modifiers |= ModifierKeys.Control; - if (IsAltPressed) modifiers |= ModifierKeys.Alt; - if (IsShiftPressed) modifiers |= ModifierKeys.Shift; - if (IsWindowsPressed) modifiers |= ModifierKeys.Windows; - - return modifiers; + AvailableActions = GetAvailableActions(SelectedMode); + SelectedAction = AvailableActions.FirstOrDefault(x => x.Action == selectedAction) ?? AvailableActions.FirstOrDefault()!; + Bindings.Update(); } private void ShowError(string message) @@ -114,32 +117,91 @@ public sealed partial class KeyboardShortcutDialog : ContentDialog ErrorBorder.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } - private static List GetAvailableMailOperations() + private static List GetAvailableActions(WinoApplicationMode mode) { - var operations = new List(); - - // Add commonly used mail operations that make sense for keyboard shortcuts - var validOperations = new[] + KeyboardShortcutAction[] actions = mode switch { - MailOperation.Archive, - MailOperation.UnArchive, - MailOperation.SoftDelete, - MailOperation.Move, - MailOperation.MoveToJunk, - MailOperation.SetFlag, - MailOperation.ClearFlag, - MailOperation.MarkAsRead, - MailOperation.MarkAsUnread, - MailOperation.Reply, - MailOperation.ReplyAll, - MailOperation.Forward + WinoApplicationMode.Mail => + [ + KeyboardShortcutAction.NewMail, + KeyboardShortcutAction.ToggleReadUnread, + KeyboardShortcutAction.ToggleFlag, + KeyboardShortcutAction.ToggleArchive, + KeyboardShortcutAction.Delete, + KeyboardShortcutAction.Move, + KeyboardShortcutAction.Reply, + KeyboardShortcutAction.ReplyAll, + KeyboardShortcutAction.Send + ], + WinoApplicationMode.Calendar => + [ + KeyboardShortcutAction.NewEvent, + KeyboardShortcutAction.Delete + ], + _ => [] }; - foreach (var operation in validOperations) + return actions + .Select(action => new KeyboardShortcutActionViewModel(mode, action)) + .ToList(); + } + + private static ModifierKeys GetCurrentModifierKeys() + { + var modifiers = ModifierKeys.None; + + if (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Control).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down)) + modifiers |= ModifierKeys.Control; + + if (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Menu).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down)) + modifiers |= ModifierKeys.Alt; + + if (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Shift).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down)) + modifiers |= ModifierKeys.Shift; + + if (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.LeftWindows).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down) || + Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.RightWindows).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down)) { - operations.Add(new MailOperationViewModel(operation)); + modifiers |= ModifierKeys.Windows; } - return operations.OrderBy(x => x.DisplayName).ToList(); + return modifiers; + } + + private static string NormalizeKey(Windows.System.VirtualKey key) + { + return key switch + { + Windows.System.VirtualKey.Control or + Windows.System.VirtualKey.LeftControl or + Windows.System.VirtualKey.RightControl or + Windows.System.VirtualKey.Menu or + Windows.System.VirtualKey.LeftMenu or + Windows.System.VirtualKey.RightMenu or + Windows.System.VirtualKey.Shift or + Windows.System.VirtualKey.LeftShift or + Windows.System.VirtualKey.RightShift or + Windows.System.VirtualKey.LeftWindows or + Windows.System.VirtualKey.RightWindows => string.Empty, + _ => key.ToString() + }; + } + + private static string BuildDisplayString(string key, ModifierKeys modifierKeys) + { + var parts = new List(); + + if (modifierKeys.HasFlag(ModifierKeys.Control)) + parts.Add("Ctrl"); + if (modifierKeys.HasFlag(ModifierKeys.Alt)) + parts.Add("Alt"); + if (modifierKeys.HasFlag(ModifierKeys.Shift)) + parts.Add("Shift"); + if (modifierKeys.HasFlag(ModifierKeys.Windows)) + parts.Add("Win"); + if (!string.IsNullOrEmpty(key)) + parts.Add(key); + + return string.Join("+", parts); } } diff --git a/Wino.Mail.WinUI/Helpers/BreadcrumbNavigationHelper.cs b/Wino.Mail.WinUI/Helpers/BreadcrumbNavigationHelper.cs new file mode 100644 index 00000000..6e50c8c9 --- /dev/null +++ b/Wino.Mail.WinUI/Helpers/BreadcrumbNavigationHelper.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.ObjectModel; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Animation; +using Wino.Core.Domain.Enums; +using Wino.Mail.ViewModels.Data; +using Wino.Messaging.Client.Navigation; + +namespace Wino.Helpers; + +public static class BreadcrumbNavigationHelper +{ + public static bool Navigate( + Frame frame, + ObservableCollection pageHistory, + BreadcrumbNavigationRequested message, + Func getPageType) + { + var pageType = getPageType(message.PageType); + + if (pageType == null) + return false; + + frame.Navigate(pageType, message.Parameter, new SlideNavigationTransitionInfo + { + Effect = SlideNavigationTransitionEffect.FromRight + }); + + SetActiveItem(pageHistory, null); + pageHistory.Add(new BreadcrumbNavigationItemViewModel( + message, + isActive: true, + stepNumber: pageHistory.Count + 1, + backStackDepth: frame.BackStack.Count + 1)); + + return true; + } + + public static bool GoBack( + Frame frame, + ObservableCollection pageHistory, + NavigationTransitionEffect slideEffect) + { + if (!frame.CanGoBack || pageHistory.Count == 0) + return false; + + pageHistory.RemoveAt(pageHistory.Count - 1); + frame.GoBack(new SlideNavigationTransitionInfo + { + Effect = slideEffect == NavigationTransitionEffect.FromLeft + ? SlideNavigationTransitionEffect.FromLeft + : SlideNavigationTransitionEffect.FromRight + }); + + SetActiveItem(pageHistory, pageHistory.Count > 0 ? pageHistory[^1] : null); + return true; + } + + public static bool NavigateTo( + Frame frame, + ObservableCollection pageHistory, + int targetIndex) + { + if (targetIndex < 0 || targetIndex >= pageHistory.Count) + return false; + + var activeIndex = GetActiveIndex(pageHistory); + if (activeIndex <= 0 || targetIndex >= activeIndex) + return false; + + var targetItem = pageHistory[targetIndex]; + + while (frame.BackStack.Count > targetItem.BackStackDepth) + { + frame.BackStack.RemoveAt(frame.BackStack.Count - 1); + } + + if (!frame.CanGoBack) + return false; + + frame.GoBack(new SlideNavigationTransitionInfo + { + Effect = SlideNavigationTransitionEffect.FromLeft + }); + + while (pageHistory.Count > targetIndex + 1) + { + pageHistory.RemoveAt(pageHistory.Count - 1); + } + + SetActiveItem(pageHistory, targetItem); + return true; + } + + private static int GetActiveIndex(ObservableCollection pageHistory) + { + for (var i = 0; i < pageHistory.Count; i++) + { + if (pageHistory[i].IsActive) + return i; + } + + return -1; + } + + private static void SetActiveItem( + ObservableCollection pageHistory, + BreadcrumbNavigationItemViewModel? activeItem) + { + foreach (var item in pageHistory) + { + item.IsActive = ReferenceEquals(item, activeItem); + } + } +} diff --git a/Wino.Mail.WinUI/MailAppShell.xaml.cs b/Wino.Mail.WinUI/MailAppShell.xaml.cs index 13fe2ce7..aa295da5 100644 --- a/Wino.Mail.WinUI/MailAppShell.xaml.cs +++ b/Wino.Mail.WinUI/MailAppShell.xaml.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.WinUI; +using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; @@ -14,6 +15,7 @@ using Wino.Core.Domain; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models; using Wino.Core.Domain.Models.Folders; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Navigation; @@ -43,6 +45,7 @@ public sealed partial class MailAppShell : MailAppShellAbstract, public MailAppShell() : base() { InitializeComponent(); + PreviewKeyDown += OnPreviewKeyDown; } protected override void OnNavigatedFrom(NavigationEventArgs e) @@ -315,6 +318,100 @@ public sealed partial class MailAppShell : MailAppShellAbstract, } } + private async void OnPreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.KeyStatus.RepeatCount > 1 || ShouldIgnoreShortcut()) + return; + + var key = NormalizeKey(e.Key); + if (string.IsNullOrEmpty(key)) + return; + + var shortcutService = WinoApplication.Current.Services.GetRequiredService(); + var shortcut = await shortcutService.GetShortcutForKeyAsync(WinoApplicationMode.Mail, key, GetCurrentModifierKeys()); + + if (shortcut == null) + return; + + var details = new KeyboardShortcutTriggerDetails + { + ShortcutId = shortcut.Id, + Mode = shortcut.Mode, + Action = shortcut.Action, + Key = shortcut.Key, + ModifierKeys = shortcut.ModifierKeys, + Sender = sender, + Origin = FocusManager.GetFocusedElement(XamlRoot) + }; + + await ViewModel.KeyboardShortcutHook(details); + + if (InnerShellFrame.Content is BasePage activePage && activePage.AssociatedViewModel != null) + { + await activePage.AssociatedViewModel.KeyboardShortcutHook(details); + } + + if (details.Handled) + { + e.Handled = true; + } + } + + private bool ShouldIgnoreShortcut() + { + var focusedElement = FocusManager.GetFocusedElement(XamlRoot); + + if (focusedElement is TextBox or AutoSuggestBox or PasswordBox or RichEditBox or ComboBox) + return true; + + if (focusedElement is FrameworkElement frameworkElement) + { + var typeName = frameworkElement.GetType().Name; + if (typeName.Contains("WebView", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + private static ModifierKeys GetCurrentModifierKeys() + { + var modifiers = ModifierKeys.None; + + if (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Control).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down)) + modifiers |= ModifierKeys.Control; + if (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Menu).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down)) + modifiers |= ModifierKeys.Alt; + if (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Shift).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down)) + modifiers |= ModifierKeys.Shift; + if (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.LeftWindows).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down) || + Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.RightWindows).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down)) + { + modifiers |= ModifierKeys.Windows; + } + + return modifiers; + } + + private static string NormalizeKey(Windows.System.VirtualKey key) + { + return key switch + { + Windows.System.VirtualKey.Control or + Windows.System.VirtualKey.LeftControl or + Windows.System.VirtualKey.RightControl or + Windows.System.VirtualKey.Menu or + Windows.System.VirtualKey.LeftMenu or + Windows.System.VirtualKey.RightMenu or + Windows.System.VirtualKey.Shift or + Windows.System.VirtualKey.LeftShift or + Windows.System.VirtualKey.RightShift or + Windows.System.VirtualKey.LeftWindows or + Windows.System.VirtualKey.RightWindows => string.Empty, + _ => key.ToString() + }; + } + protected override void RegisterRecipients() { base.RegisterRecipients(); diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml.cs b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml.cs index 158ecc47..0a48b77d 100644 --- a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml.cs +++ b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml.cs @@ -1,6 +1,12 @@ -using CommunityToolkit.Mvvm.Messaging; +using System; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models; using Wino.Mail.Views.Abstract; using Wino.Messaging.Client.Calendar; @@ -17,14 +23,13 @@ public sealed partial class CalendarAppShell : CalendarAppShellAbstract, public CalendarAppShell() { InitializeComponent(); + PreviewKeyDown += OnPreviewKeyDown; - // Window.Current.SetTitleBar(DragArea); ManageCalendarDisplayType(ViewModel.StatePersistenceService.CalendarDisplayType); } private void ManageCalendarDisplayType(Core.Domain.Enums.CalendarDisplayType displayType) { - // Go to different states based on the display type. if (displayType == Core.Domain.Enums.CalendarDisplayType.Month) { VisualStateManager.GoToState(this, STATE_VerticalCalendar, false); @@ -44,12 +49,6 @@ public sealed partial class CalendarAppShell : CalendarAppShellAbstract, ManageCalendarDisplayType(message.NewDisplayType); } - //private void ShellFrameContentNavigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e) - // => RealAppBar.ShellFrameContent = (e.Content as BasePage).ShellContent; - - //private void AppBarBackButtonClicked(Core.UWP.Controls.WinoAppTitleBar sender, RoutedEventArgs args) - // => ViewModel.NavigationService.GoBack(); - protected override void RegisterRecipients() { base.RegisterRecipients(); @@ -63,4 +62,98 @@ public sealed partial class CalendarAppShell : CalendarAppShellAbstract, WeakReferenceMessenger.Default.Unregister(this); } + + private async void OnPreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.KeyStatus.RepeatCount > 1 || ShouldIgnoreShortcut()) + return; + + var key = NormalizeKey(e.Key); + if (string.IsNullOrEmpty(key)) + return; + + var shortcutService = WinoApplication.Current.Services.GetRequiredService(); + var shortcut = await shortcutService.GetShortcutForKeyAsync(WinoApplicationMode.Calendar, key, GetCurrentModifierKeys()); + + if (shortcut == null) + return; + + var details = new KeyboardShortcutTriggerDetails + { + ShortcutId = shortcut.Id, + Mode = shortcut.Mode, + Action = shortcut.Action, + Key = shortcut.Key, + ModifierKeys = shortcut.ModifierKeys, + Sender = sender, + Origin = FocusManager.GetFocusedElement(XamlRoot) + }; + + await ViewModel.KeyboardShortcutHook(details); + + if (InnerShellFrame.Content is BasePage activePage && activePage.AssociatedViewModel != null) + { + await activePage.AssociatedViewModel.KeyboardShortcutHook(details); + } + + if (details.Handled) + { + e.Handled = true; + } + } + + private bool ShouldIgnoreShortcut() + { + var focusedElement = FocusManager.GetFocusedElement(XamlRoot); + + if (focusedElement is TextBox or AutoSuggestBox or PasswordBox or RichEditBox or ComboBox) + return true; + + if (focusedElement is FrameworkElement frameworkElement) + { + var typeName = frameworkElement.GetType().Name; + if (typeName.Contains("WebView", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + private static ModifierKeys GetCurrentModifierKeys() + { + var modifiers = ModifierKeys.None; + + if (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Control).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down)) + modifiers |= ModifierKeys.Control; + if (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Menu).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down)) + modifiers |= ModifierKeys.Alt; + if (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Shift).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down)) + modifiers |= ModifierKeys.Shift; + if (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.LeftWindows).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down) || + Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.RightWindows).HasFlag(Windows.UI.Core.CoreVirtualKeyStates.Down)) + { + modifiers |= ModifierKeys.Windows; + } + + return modifiers; + } + + private static string NormalizeKey(Windows.System.VirtualKey key) + { + return key switch + { + Windows.System.VirtualKey.Control or + Windows.System.VirtualKey.LeftControl or + Windows.System.VirtualKey.RightControl or + Windows.System.VirtualKey.Menu or + Windows.System.VirtualKey.LeftMenu or + Windows.System.VirtualKey.RightMenu or + Windows.System.VirtualKey.Shift or + Windows.System.VirtualKey.LeftShift or + Windows.System.VirtualKey.RightShift or + Windows.System.VirtualKey.LeftWindows or + Windows.System.VirtualKey.RightWindows => string.Empty, + _ => key.ToString() + }; + } } diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml b/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml index 3295d3e9..4a9b29f4 100644 --- a/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml +++ b/Wino.Mail.WinUI/Views/Calendar/CalendarEventComposePage.xaml @@ -470,8 +470,8 @@ - + @@ -560,7 +563,20 @@ - + + + + + + + + + + + isDarkMode ? WinoIconGlyph.LightEditor : WinoIconGlyph.DarkEditor; + + public string GetEditorThemeToolTip(bool isDarkMode) => isDarkMode ? Translator.Composer_LightTheme : Translator.Composer_DarkTheme; + + private void ToggleNotesEditorThemeClicked(object sender, RoutedEventArgs e) + { + NotesEditor.ToggleEditorTheme(); + } + protected override async void OnNavigatedTo(NavigationEventArgs e) { base.OnNavigatedTo(e); diff --git a/Wino.Mail.WinUI/Views/Mail/ComposePage.xaml b/Wino.Mail.WinUI/Views/Mail/ComposePage.xaml index 1dc13c29..195a86ba 100644 --- a/Wino.Mail.WinUI/Views/Mail/ComposePage.xaml +++ b/Wino.Mail.WinUI/Views/Mail/ComposePage.xaml @@ -145,14 +145,13 @@ Visibility="{x:Bind ViewModel.IsDraftBusy, Mode=OneWay}"> - - - - - + + + + + diff --git a/Wino.Mail.WinUI/Views/Mail/ComposePage.xaml.cs b/Wino.Mail.WinUI/Views/Mail/ComposePage.xaml.cs index 155ce559..4819c7b3 100644 --- a/Wino.Mail.WinUI/Views/Mail/ComposePage.xaml.cs +++ b/Wino.Mail.WinUI/Views/Mail/ComposePage.xaml.cs @@ -22,6 +22,7 @@ using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Models.Reader; using Wino.Mail.ViewModels.Data; using Wino.Mail.ViewModels.Messages; +using Wino.Mail.WinUI.Controls; using Wino.Mail.WinUI.Extensions; using Wino.Messaging.Client.Mails; using Wino.Messaging.Client.Shell; @@ -43,6 +44,15 @@ public sealed partial class ComposePage : ComposePageAbstract, InitializeComponent(); } + public WinoIconGlyph GetEditorThemeIcon(bool isDarkMode) => isDarkMode ? WinoIconGlyph.LightEditor : WinoIconGlyph.DarkEditor; + + public string GetEditorThemeToolTip(bool isDarkMode) => isDarkMode ? Translator.Composer_LightTheme : Translator.Composer_DarkTheme; + + private void ToggleEditorThemeClicked(object sender, RoutedEventArgs e) + { + WebViewEditor.ToggleEditorTheme(); + } + private async void GlobalFocusManagerGotFocus(object? sender, FocusManagerGotFocusEventArgs e) { // In order to delegate cursor to the inner editor for WebView2. diff --git a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs index 855c2e64..d2587e5d 100644 --- a/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs +++ b/Wino.Mail.WinUI/Views/Mail/MailListPage.xaml.cs @@ -52,7 +52,6 @@ public sealed partial class MailListPage : MailListPageAbstract, private IStatePersistanceService StatePersistenceService { get; } = WinoApplication.Current.Services.GetService() ?? throw new Exception($"Can't resolve {nameof(KeyPressService)}"); private IKeyPressService KeyPressService { get; } = WinoApplication.Current.Services.GetService() ?? throw new Exception($"Can't resolve {nameof(KeyPressService)}"); - private IKeyboardShortcutService KeyboardShortcutService { get; } = WinoApplication.Current.Services.GetService() ?? throw new Exception($"Can't resolve {nameof(IKeyboardShortcutService)}"); public MailListPage() { InitializeComponent(); @@ -671,19 +670,7 @@ public sealed partial class MailListPage : MailListPageAbstract, } else { - // Check keyboard shortcuts from service. - ModifierKeys modifiers = args.Modifiers.ToDomainModifierKeys(); - - var operation = await KeyboardShortcutService.GetMailOperationForKeyAsync(args.Key.ToString(), modifiers); - - if (operation != null) - { - ViewModel.ExecuteMailOperationCommand.Execute(operation); - } - else - { - args.Handled = false; - } + args.Handled = false; } } diff --git a/Wino.Mail.WinUI/Views/ManageAccountsPage.xaml.cs b/Wino.Mail.WinUI/Views/ManageAccountsPage.xaml.cs index 8c3238b2..20ef8f9e 100644 --- a/Wino.Mail.WinUI/Views/ManageAccountsPage.xaml.cs +++ b/Wino.Mail.WinUI/Views/ManageAccountsPage.xaml.cs @@ -3,9 +3,9 @@ using System.Linq; using CommunityToolkit.Mvvm.Messaging; using Microsoft.UI.Xaml.Media.Animation; using Microsoft.UI.Xaml.Navigation; -using MoreLinq; using Wino.Core.Domain; using Wino.Core.Domain.Enums; +using Wino.Helpers; using Wino.Mail.ViewModels.Data; using Wino.Mail.WinUI.Views.Abstract; using Wino.Messaging.Client.Navigation; @@ -42,7 +42,7 @@ public sealed partial class ManageAccountsPage : ManageAccountsPageAbstract, AccountPagesFrame.Navigated += AccountPagesFrameNavigated; var initialRequest = new BreadcrumbNavigationRequested(Translator.MenuManageAccounts, WinoPage.AccountManagementPage); - PageHistory.Add(new BreadcrumbNavigationItemViewModel(initialRequest, true)); + PageHistory.Add(new BreadcrumbNavigationItemViewModel(initialRequest, true, backStackDepth: AccountPagesFrame.BackStack.Count + 1)); var accountManagementPageType = ViewModel.NavigationService.GetPageType(WinoPage.AccountManagementPage); @@ -69,15 +69,7 @@ public sealed partial class ManageAccountsPage : ManageAccountsPageAbstract, void IRecipient.Receive(BreadcrumbNavigationRequested message) { - var pageType = ViewModel.NavigationService.GetPageType(message.PageType); - - if (pageType == null) return; - - AccountPagesFrame.Navigate(pageType, message.Parameter, new SlideNavigationTransitionInfo() { Effect = Microsoft.UI.Xaml.Media.Animation.SlideNavigationTransitionEffect.FromRight }); - - PageHistory.ForEach(a => a.IsActive = false); - - PageHistory.Add(new BreadcrumbNavigationItemViewModel(message, true)); + BreadcrumbNavigationHelper.Navigate(AccountPagesFrame, PageHistory, message, ViewModel.NavigationService.GetPageType); UpdateWindowTitle(); } @@ -89,40 +81,20 @@ public sealed partial class ManageAccountsPage : ManageAccountsPageAbstract, private void GoBackFrame(Core.Domain.Enums.NavigationTransitionEffect slideEffect) { - if (AccountPagesFrame.CanGoBack) - { - PageHistory.RemoveAt(PageHistory.Count - 1); + if (!BreadcrumbNavigationHelper.GoBack(AccountPagesFrame, PageHistory, slideEffect)) + return; - var winuiEffect = slideEffect switch - { - Core.Domain.Enums.NavigationTransitionEffect.FromLeft => Microsoft.UI.Xaml.Media.Animation.SlideNavigationTransitionEffect.FromLeft, - _ => Microsoft.UI.Xaml.Media.Animation.SlideNavigationTransitionEffect.FromRight, - }; - - AccountPagesFrame.GoBack(new SlideNavigationTransitionInfo() { Effect = winuiEffect }); - - // Set the new last item as active - if (PageHistory.Count > 0) - { - PageHistory.ForEach(a => a.IsActive = false); - PageHistory[PageHistory.Count - 1].IsActive = true; - } - - // Update back button visibility after navigation - ViewModel.StatePersistenceService.IsManageAccountsNavigating = AccountPagesFrame.CanGoBack; - UpdateWindowTitle(); - } + ViewModel.StatePersistenceService.IsManageAccountsNavigating = AccountPagesFrame.CanGoBack; + UpdateWindowTitle(); } private void BreadItemClicked(Microsoft.UI.Xaml.Controls.BreadcrumbBar sender, Microsoft.UI.Xaml.Controls.BreadcrumbBarItemClickedEventArgs args) { - var clickedPageHistory = PageHistory[args.Index]; + if (!BreadcrumbNavigationHelper.NavigateTo(AccountPagesFrame, PageHistory, args.Index)) + return; - // Trigger GoBack repeatedly until we reach the clicked breadcrumb item - while (PageHistory.FirstOrDefault(a => a.IsActive) != clickedPageHistory) - { - ViewModel.NavigationService.GoBack(); - } + ViewModel.StatePersistenceService.IsManageAccountsNavigating = AccountPagesFrame.CanGoBack; + UpdateWindowTitle(); } public void Receive(BackBreadcrumNavigationRequested message) diff --git a/Wino.Mail.WinUI/Views/SettingOptionsPage.xaml b/Wino.Mail.WinUI/Views/SettingOptionsPage.xaml index 17bb7d32..880f2bc2 100644 --- a/Wino.Mail.WinUI/Views/SettingOptionsPage.xaml +++ b/Wino.Mail.WinUI/Views/SettingOptionsPage.xaml @@ -221,6 +221,17 @@ + + + + + + diff --git a/Wino.Mail.WinUI/Views/Settings/KeyboardShortcutsPage.xaml b/Wino.Mail.WinUI/Views/Settings/KeyboardShortcutsPage.xaml index 8f9fefc0..6a674f5b 100644 --- a/Wino.Mail.WinUI/Views/Settings/KeyboardShortcutsPage.xaml +++ b/Wino.Mail.WinUI/Views/Settings/KeyboardShortcutsPage.xaml @@ -52,19 +52,28 @@ + + Text="{x:Bind ActionDisplayName}" /> + +