diff --git a/src/Files.App/Actions/Show/ToggleFilterHeaderAction.cs b/src/Files.App/Actions/Show/ToggleFilterHeaderAction.cs index 965776269510..6835bdf450ba 100644 --- a/src/Files.App/Actions/Show/ToggleFilterHeaderAction.cs +++ b/src/Files.App/Actions/Show/ToggleFilterHeaderAction.cs @@ -31,6 +31,11 @@ public Task ExecuteAsync(object? parameter = null) if (IsOn) ContentPageContext.ShellPage!.ShellViewModel.InvokeFocusFilterHeader(); + else + { + // Clear the filter query when the header is hidden + ContentPageContext.ShellPage!.ShellViewModel.FilesAndFoldersFilter = string.Empty; + } return Task.CompletedTask; } diff --git a/src/Files.App/Data/Contracts/IFoldersSettingsService.cs b/src/Files.App/Data/Contracts/IFoldersSettingsService.cs index f916ed3cbb2c..67e9c77d47fd 100644 --- a/src/Files.App/Data/Contracts/IFoldersSettingsService.cs +++ b/src/Files.App/Data/Contracts/IFoldersSettingsService.cs @@ -89,5 +89,10 @@ public interface IFoldersSettingsService : IBaseSettingsService, INotifyProperty /// Gets or sets a value indicating which format to use when displaying item sizes. /// SizeUnitTypes SizeUnitFormat { get; set; } + + /// + /// Gets or sets a value indicating the keyboard typing behavior. + /// + KeyboardTypingBehavior KeyboardTypingBehavior { get; set; } } } diff --git a/src/Files.App/Data/Enums/KeyboardTypingBehavior.cs b/src/Files.App/Data/Enums/KeyboardTypingBehavior.cs new file mode 100644 index 000000000000..05718ae47021 --- /dev/null +++ b/src/Files.App/Data/Enums/KeyboardTypingBehavior.cs @@ -0,0 +1,18 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +namespace Files.App.Data.Enums +{ + public enum KeyboardTypingBehavior + { + /// + /// Jump to matching item. + /// + JumpToFile, + + /// + /// Filter items. + /// + FilterItems + } +} diff --git a/src/Files.App/Services/Settings/FoldersSettingsService.cs b/src/Files.App/Services/Settings/FoldersSettingsService.cs index 45c16b0bcd31..656bc1470d8c 100644 --- a/src/Files.App/Services/Settings/FoldersSettingsService.cs +++ b/src/Files.App/Services/Settings/FoldersSettingsService.cs @@ -114,6 +114,13 @@ public SizeUnitTypes SizeUnitFormat set => Set(value); } + /// + public KeyboardTypingBehavior KeyboardTypingBehavior + { + get => (KeyboardTypingBehavior)Get((long)KeyboardTypingBehavior.JumpToFile); + set => Set((long)value); + } + protected override void RaiseOnSettingChangedEvent(object sender, SettingChangedEventArgs e) { base.RaiseOnSettingChangedEvent(sender, e); diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index dc6dc947fbbf..c8e9002bb1a9 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -4278,6 +4278,15 @@ Filename + + + Behavior when typing in the file area + + + Jump to file + + + Filter items Signatures diff --git a/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs b/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs index 6183a1330b78..bf87ef03af4b 100644 --- a/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs +++ b/src/Files.App/Utils/Storage/Operations/FilesystemHelpers.cs @@ -34,8 +34,8 @@ private static char[] RestrictedCharacters { var userSettingsService = Ioc.Default.GetRequiredService(); return userSettingsService.FoldersSettingsService.AreAlternateStreamsVisible - ? ['\\', '/', '*', '?', '"', '<', '>', '|'] // Allow ":" char - : ['\\', '/', ':', '*', '?', '"', '<', '>', '|']; + ? Path.GetInvalidFileNameChars().Where(c => c != ':').ToArray() // Allow ":" char when alternate streams are visible + : Path.GetInvalidFileNameChars(); } } diff --git a/src/Files.App/ViewModels/Settings/AdvancedViewModel.cs b/src/Files.App/ViewModels/Settings/AdvancedViewModel.cs index 2d8344bffaf2..0e9dd06ae541 100644 --- a/src/Files.App/ViewModels/Settings/AdvancedViewModel.cs +++ b/src/Files.App/ViewModels/Settings/AdvancedViewModel.cs @@ -28,6 +28,7 @@ public sealed partial class AdvancedViewModel : ObservableObject public ICommand ImportSettingsCommand { get; } public AsyncRelayCommand OpenFilesOnWindowsStartupCommand { get; } + public Dictionary KeyboardTypingBehaviors { get; private set; } = []; public AdvancedViewModel() { @@ -40,6 +41,11 @@ public AdvancedViewModel() ImportSettingsCommand = new AsyncRelayCommand(ImportSettingsAsync); OpenFilesOnWindowsStartupCommand = new AsyncRelayCommand(OpenFilesOnWindowsStartupAsync); + // Keyboard typing behavior + KeyboardTypingBehaviors.Add(Data.Enums.KeyboardTypingBehavior.JumpToFile, Strings.JumpToFile.GetLocalizedResource()); + KeyboardTypingBehaviors.Add(Data.Enums.KeyboardTypingBehavior.FilterItems, Strings.FilterItems.GetLocalizedResource()); + KeyboardTypingBehavior = KeyboardTypingBehaviors[UserSettingsService.FoldersSettingsService.KeyboardTypingBehavior]; + _ = DetectOpenFilesAtStartupAsync(); } @@ -354,6 +360,20 @@ public bool ShowFlattenOptions OnPropertyChanged(); } } + + private string keyboardTypingBehavior; + public string KeyboardTypingBehavior + { + get => keyboardTypingBehavior; + set + { + if (SetProperty(ref keyboardTypingBehavior, value)) + { + UserSettingsService.FoldersSettingsService.KeyboardTypingBehavior = KeyboardTypingBehaviors.First(e => e.Value == value).Key; + } + } + } + public async Task OpenFilesOnWindowsStartupAsync() { var stateMode = await ReadState(); diff --git a/src/Files.App/ViewModels/ShellViewModel.cs b/src/Files.App/ViewModels/ShellViewModel.cs index bab020065ce7..ebdace09169c 100644 --- a/src/Files.App/ViewModels/ShellViewModel.cs +++ b/src/Files.App/ViewModels/ShellViewModel.cs @@ -777,9 +777,42 @@ public void UpdateEmptyTextType() public string? FilesAndFoldersFilter { get => _filesAndFoldersFilter; - set => SetProperty(ref _filesAndFoldersFilter, value); + set + { + if (SetProperty(ref _filesAndFoldersFilter, value)) + { + // Apply the updated filter to the files and folders list + FilesAndFolderFilterUpdated(); + } + } + } + + private void FilesAndFolderFilterUpdated() + { + _ = ApplyFilesAndFoldersChangesAsync(); } + /// + /// Clears the files and folder filter. + /// This is used when the directory is changed or refreshed. + /// + private void ClearFilesAndFolderFilter() + { + // Hide the filter header if: + // - Keyboard behavior is set to filter items + // - A filter is currently applied + // + // Keep the header visible if: + // - The filter is already empty (e.g. opened manually) + if (UserSettingsService.FoldersSettingsService.KeyboardTypingBehavior == KeyboardTypingBehavior.FilterItems && + !string.IsNullOrEmpty(FilesAndFoldersFilter)) + { + UserSettingsService.GeneralSettingsService.ShowFilterHeader = false; + } + + // Clear the filter + FilesAndFoldersFilter = string.Empty; + } // Apply changes immediately after manipulating on filesAndFolders completed public async Task ApplyFilesAndFoldersChangesAsync() @@ -1894,7 +1927,6 @@ await Task.Run(async () => }); filesAndFolders.AddRange(fileList); - FilesAndFoldersFilter = null; await OrderFilesAndFoldersAsync(); await ApplyFilesAndFoldersChangesAsync(); @@ -1903,6 +1935,7 @@ await dispatcherQueue.EnqueueOrInvokeAsync(() => { GetDesktopIniFileData(); CheckForBackgroundImage(); + ClearFilesAndFolderFilter(); }, Microsoft.UI.Dispatching.DispatcherQueuePriority.Low); }); diff --git a/src/Files.App/Views/Layouts/BaseGroupableLayoutPage.cs b/src/Files.App/Views/Layouts/BaseGroupableLayoutPage.cs index 2d867c252741..65d88d704272 100644 --- a/src/Files.App/Views/Layouts/BaseGroupableLayoutPage.cs +++ b/src/Files.App/Views/Layouts/BaseGroupableLayoutPage.cs @@ -95,7 +95,7 @@ protected override void UnhookEvents() ItemManipulationModel.RefreshItemsThumbnailInvoked -= ItemManipulationModel_RefreshItemsThumbnail; } - protected override void Page_CharacterReceived(UIElement sender, CharacterReceivedRoutedEventArgs args) + protected override void Page_PreviewKeyDown(object sender, KeyRoutedEventArgs e) { if (ParentShellPageInstance is null || ParentShellPageInstance.CurrentPageType != this.GetType() || @@ -112,7 +112,7 @@ focusedElement is PasswordBox || DependencyObjectHelpers.FindParent(focusedElement) is not null) return; - base.Page_CharacterReceived(sender, args); + base.Page_PreviewKeyDown(sender, e); } // Virtual methods diff --git a/src/Files.App/Views/Layouts/BaseLayoutPage.cs b/src/Files.App/Views/Layouts/BaseLayoutPage.cs index 277bd8df7815..addd47f6b267 100644 --- a/src/Files.App/Views/Layouts/BaseLayoutPage.cs +++ b/src/Files.App/Views/Layouts/BaseLayoutPage.cs @@ -15,6 +15,7 @@ using System.IO; using System.Runtime.CompilerServices; using System.Runtime.InteropServices.ComTypes; +using System.Text; using Vanara.Extensions; using Vanara.PInvoke; using Windows.ApplicationModel.DataTransfer; @@ -23,6 +24,7 @@ using Windows.Foundation.Collections; using Windows.Storage; using Windows.System; +using Windows.Win32; using static Files.App.Helpers.PathNormalization; using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer; using SortDirection = Files.App.Data.Enums.SortDirection; @@ -40,6 +42,8 @@ public abstract class BaseLayoutPage : Page, IBaseLayoutPage, INotifyPropertyCha protected IFileTagsSettingsService FileTagsSettingsService { get; } = Ioc.Default.GetService()!; protected IUserSettingsService UserSettingsService { get; } = Ioc.Default.GetService()!; protected ILayoutSettingsService LayoutSettingsService { get; } = Ioc.Default.GetService()!; + protected IGeneralSettingsService GeneralSettingsService { get; } = Ioc.Default.GetService()!; + protected IFoldersSettingsService FoldersSettingsService { get; } = Ioc.Default.GetService()!; protected ICommandManager Commands { get; } = Ioc.Default.GetRequiredService(); public InfoPaneViewModel InfoPaneViewModel { get; } = Ioc.Default.GetRequiredService(); protected readonly IWindowContext WindowContext = Ioc.Default.GetRequiredService(); @@ -401,7 +405,7 @@ protected override async void OnNavigatedTo(NavigationEventArgs e) base.OnNavigatedTo(e); // Add item jumping handler - CharacterReceived += Page_CharacterReceived; + PreviewKeyDown += Page_PreviewKeyDown; navigationArguments = (NavigationArguments)e.Parameter; ParentShellPageInstance = navigationArguments.AssociatedTabInstance; @@ -565,7 +569,7 @@ protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) base.OnNavigatingFrom(e); // Remove item jumping handler - CharacterReceived -= Page_CharacterReceived; + PreviewKeyDown -= Page_PreviewKeyDown; FolderSettings!.LayoutModeChangeRequested -= BaseFolderSettings_LayoutModeChangeRequested; FolderSettings.GroupOptionPreferenceUpdated -= FolderSettings_GroupOptionPreferenceUpdated; FolderSettings.GroupDirectionPreferenceUpdated -= FolderSettings_GroupDirectionPreferenceUpdated; @@ -996,12 +1000,82 @@ private void RemoveOverflow(CommandBarFlyout contextMenuFlyout) overflowSeparator.Visibility = Visibility.Collapsed; } - protected virtual void Page_CharacterReceived(UIElement sender, CharacterReceivedRoutedEventArgs args) + protected virtual void Page_PreviewKeyDown(object sender, KeyRoutedEventArgs e) { - if (ParentShellPageInstance!.IsCurrentInstance) + var shellPage = ParentShellPageInstance; + if (shellPage?.IsCurrentInstance != true) + return; + + var pressedKey = e.Key; + var currentFilter = shellPage.ShellViewModel.FilesAndFoldersFilter ?? string.Empty; + var isFilterModeOn = FoldersSettingsService.KeyboardTypingBehavior == KeyboardTypingBehavior.FilterItems; + var buffer = new StringBuilder(4); + var state = new byte[256]; + char? typedCharacter = null; + + if (PInvoke.GetKeyboardState(state)) + { + var virtualKey = (uint)pressedKey; + var scanCode = PInvoke.MapVirtualKey(virtualKey, 0); + var keyboardLayout = PInvoke.GetKeyboardLayout(0); + + if (Win32PInvoke.ToUnicodeEx(virtualKey, scanCode, state, buffer, buffer.Capacity, 0, keyboardLayout) > 0) + { + var character = buffer[^1]; + if (character != ' ') + typedCharacter = character; + } + } + + if (!typedCharacter.HasValue) + return; + + // Handle valid character input + if (!FilesystemHelpers.ContainsRestrictedCharacters(char.ToString(typedCharacter.Value))) + { + var lowerCharString = char.ToLowerInvariant(typedCharacter.Value).ToString(); + + if (isFilterModeOn) + { + if (!GeneralSettingsService.ShowFilterHeader) + GeneralSettingsService.ShowFilterHeader = true; + shellPage.ShellViewModel.FilesAndFoldersFilter += lowerCharString; + } + else + { + JumpString += lowerCharString; + } + } + // Handle special keys in filter mode + else if (isFilterModeOn && !string.IsNullOrEmpty(currentFilter)) + { + switch (pressedKey) + { + case VirtualKey.Back when currentFilter.Length > 1: + shellPage.ShellViewModel.FilesAndFoldersFilter = currentFilter[..^1]; + break; + + case VirtualKey.Back when currentFilter.Length == 1: + shellPage.ShellViewModel.FilesAndFoldersFilter = string.Empty; + GeneralSettingsService.ShowFilterHeader = false; + break; + } + } + + // Update selection in filter mode + if (isFilterModeOn) { - char letter = args.Character; - JumpString += letter.ToString().ToLowerInvariant(); + var filterText = shellPage.ShellViewModel.FilesAndFoldersFilter; + var matchedItem = shellPage.ShellViewModel.FilesAndFolders + .FirstOrDefault(item => !string.IsNullOrEmpty(filterText) && + item.Name?.Contains(filterText, StringComparison.OrdinalIgnoreCase) == true); + + if (matchedItem != null) + { + ItemManipulationModel.SetSelectedItem(matchedItem); + ItemManipulationModel.ScrollIntoView(matchedItem); + ItemManipulationModel.FocusSelectedItems(); + } } } diff --git a/src/Files.App/Views/MainPage.xaml.cs b/src/Files.App/Views/MainPage.xaml.cs index 6f694cad775e..7596c6128342 100644 --- a/src/Files.App/Views/MainPage.xaml.cs +++ b/src/Files.App/Views/MainPage.xaml.cs @@ -216,6 +216,13 @@ private async Task OnPreviewKeyDownAsync(KeyRoutedEventArgs e) if (source?.FindAscendantOrSelf() is not null) break; + // Prevent the Back and Space keys from executing a command if the keyboard + // typing behavior is set to filter items and a filter is currently applied. + if ((e.Key is VirtualKey.Back or VirtualKey.Space) && + UserSettingsService.FoldersSettingsService.KeyboardTypingBehavior == KeyboardTypingBehavior.FilterItems && + !string.IsNullOrEmpty(SidebarAdaptiveViewModel.PaneHolder?.ActivePaneOrColumn!.ShellViewModel.FilesAndFoldersFilter)) + break; + // Execute command for hotkey var command = Commands[hotKey]; diff --git a/src/Files.App/Views/Settings/AdvancedPage.xaml b/src/Files.App/Views/Settings/AdvancedPage.xaml index 4a001c502182..d55051c8e5f3 100644 --- a/src/Files.App/Views/Settings/AdvancedPage.xaml +++ b/src/Files.App/Views/Settings/AdvancedPage.xaml @@ -8,6 +8,7 @@ xmlns:helpers="using:Files.App.Helpers" xmlns:i="using:Microsoft.Xaml.Interactivity" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:uc="using:Files.App.UserControls" xmlns:vm="using:Files.App.ViewModels.Settings" xmlns:wctcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> @@ -161,6 +162,18 @@ + + + + + + + + + diff --git a/src/Files.App/Views/Shells/ModernShellPage.xaml b/src/Files.App/Views/Shells/ModernShellPage.xaml index cfd2f0c1564c..afbb5ad7b82c 100644 --- a/src/Files.App/Views/Shells/ModernShellPage.xaml +++ b/src/Files.App/Views/Shells/ModernShellPage.xaml @@ -149,7 +149,7 @@ VerticalAlignment="Center" PlaceholderText="{helpers:ResourceString Name=Filename}" PreviewKeyDown="FilterTextBox_PreviewKeyDown" - TextChanged="FilterTextBox_TextChanged" /> + Text="{x:Bind ShellViewModel.FilesAndFoldersFilter, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> diff --git a/src/Files.App/Views/Shells/ModernShellPage.xaml.cs b/src/Files.App/Views/Shells/ModernShellPage.xaml.cs index 98282ca8764d..dfad042c919a 100644 --- a/src/Files.App/Views/Shells/ModernShellPage.xaml.cs +++ b/src/Files.App/Views/Shells/ModernShellPage.xaml.cs @@ -45,7 +45,6 @@ public ModernShellPage() : base(new CurrentInstanceViewModel()) ShellViewModel.PageTypeUpdated += FilesystemViewModel_PageTypeUpdated; ShellViewModel.OnSelectionRequestedEvent += FilesystemViewModel_OnSelectionRequestedEvent; ShellViewModel.GitDirectoryUpdated += FilesystemViewModel_GitDirectoryUpdated; - ShellViewModel.DirectoryInfoUpdated += ShellViewModel_DirectoryInfoUpdated; ShellViewModel.FocusFilterHeader += ShellViewModel_FocusFilterHeader; ToolbarViewModel.PathControlDisplayText = Strings.Home.GetLocalizedResource(); @@ -63,13 +62,6 @@ private async void ShellViewModel_FocusFilterHeader(object sender, EventArgs e) FilterTextBox.Focus(FocusState.Programmatic); } - private void ShellViewModel_DirectoryInfoUpdated(object sender, EventArgs e) - { - // Regular binding causes issues when refreshing the directory so we set the text manually - if (FilterTextBox?.IsLoaded ?? false) - FilterTextBox.Text = ShellViewModel.FilesAndFoldersFilter ?? string.Empty; - } - private void ModernShellPage_RefreshWidgetsRequested(object sender, EventArgs e) { if (ItemDisplayFrame?.Content is HomePage currentPage) @@ -297,8 +289,6 @@ public override void NavigateToReleaseNotes() public override void NavigateToPath(string? navigationPath, Type? sourcePageType, NavigationArguments? navArgs = null) { - ShellViewModel.FilesAndFoldersFilter = null; - if (sourcePageType is null && !string.IsNullOrEmpty(navigationPath)) sourcePageType = InstanceViewModel.FolderSettings.GetLayoutType(navigationPath); @@ -342,16 +332,6 @@ public override void NavigateToPath(string? navigationPath, Type? sourcePageType ToolbarViewModel.PathControlDisplayText = ShellViewModel.WorkingDirectory; } - private async void FilterTextBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) - { - if (args.Reason is AutoSuggestionBoxTextChangeReason.UserInput) - { - ShellViewModel.FilesAndFoldersFilter = sender.Text; - await ShellViewModel.ApplyFilesAndFoldersChangesAsync(); - } - - } - private void FilterTextBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) { if (e.Key is VirtualKey.Escape &&