diff --git a/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/Controls/SearchPanel.xaml b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/Controls/SearchPanel.xaml new file mode 100644 index 000000000..061f1a6c4 --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/Controls/SearchPanel.xaml @@ -0,0 +1,85 @@ + + + + + + + + + + + + diff --git a/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/Controls/SearchPanel.xaml.cs b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/Controls/SearchPanel.xaml.cs new file mode 100644 index 000000000..8743a787f --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/Controls/SearchPanel.xaml.cs @@ -0,0 +1,124 @@ +// Copyright © 2017-2025 QL-Win Contributors +// +// This file is part of QuickLook program. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace QuickLook.Plugin.CsvViewer.Controls; + +public partial class SearchPanel : UserControl +{ + public event EventHandler SearchRequested; + public event EventHandler NavigateRequested; + public event EventHandler CloseRequested; + + public SearchPanel() + { + InitializeComponent(); + } + + public string SearchText => searchTextBox.Text; + public bool MatchCase => matchCaseCheckBox.IsChecked == true; + + public new void Focus() + { + searchTextBox.Focus(); + searchTextBox.SelectAll(); + } + + public void UpdateMatchCount(int totalCount, int currentIndex) + { + if (totalCount == 0) + { + matchCountText.Text = string.IsNullOrEmpty(searchTextBox.Text) ? "" : "0/0"; + } + else + { + matchCountText.Text = $"{currentIndex + 1}/{totalCount}"; + } + } + + private void SearchTextBox_TextChanged(object sender, TextChangedEventArgs e) + { + SearchRequested?.Invoke(this, new SearchEventArgs(searchTextBox.Text, MatchCase)); + } + + private void SearchTextBox_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) + { + if (Keyboard.Modifiers == ModifierKeys.Shift) + { + FindPrevious_Click(sender, e); + } + else + { + FindNext_Click(sender, e); + } + e.Handled = true; + } + else if (e.Key == Key.Escape) + { + CloseSearch_Click(sender, e); + e.Handled = true; + } + } + + private void FindPrevious_Click(object sender, RoutedEventArgs e) + { + NavigateRequested?.Invoke(this, new NavigateEventArgs(false)); + } + + private void FindNext_Click(object sender, RoutedEventArgs e) + { + NavigateRequested?.Invoke(this, new NavigateEventArgs(true)); + } + + private void MatchCase_Changed(object sender, RoutedEventArgs e) + { + SearchRequested?.Invoke(this, new SearchEventArgs(searchTextBox.Text, MatchCase)); + } + + private void CloseSearch_Click(object sender, RoutedEventArgs e) + { + CloseRequested?.Invoke(this, EventArgs.Empty); + } +} + +public class SearchEventArgs : EventArgs +{ + public string SearchText { get; } + public bool MatchCase { get; } + + public SearchEventArgs(string searchText, bool matchCase) + { + SearchText = searchText; + MatchCase = matchCase; + } +} + +public class NavigateEventArgs : EventArgs +{ + public bool Forward { get; } + + public NavigateEventArgs(bool forward) + { + Forward = forward; + } +} diff --git a/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml index 570897aab..df33e8ee5 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml +++ b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml @@ -1,12 +1,14 @@  @@ -30,5 +32,7 @@ ItemsSource="{Binding Path=Rows, ElementName=csvViewer}" RowBackground="#00FFFFFF" VerticalGridLinesBrush="#19000000" /> + diff --git a/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml.cs b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml.cs index c6d1daa25..b1e7434f2 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml.cs @@ -17,6 +17,7 @@ using CsvHelper; using CsvHelper.Configuration; +using QuickLook.Plugin.CsvViewer.Controls; using System; using System.Collections.Generic; using System.Globalization; @@ -25,7 +26,9 @@ using System.Text; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; using System.Windows.Data; +using System.Windows.Input; using System.Windows.Media; using UtfUnknown; @@ -33,13 +36,228 @@ namespace QuickLook.Plugin.CsvViewer; public partial class CsvViewerPanel : UserControl { + // Highlight color for search results (semi-transparent yellow) + private static readonly SolidColorBrush HighlightBrush = new SolidColorBrush(Color.FromArgb(128, 255, 255, 0)); + + private List<(int Row, int Column)> _searchResults = new List<(int, int)>(); + private int _currentResultIndex = -1; + private string _currentSearchText = string.Empty; + private bool _currentMatchCase; + private DataGridCell _highlightedCell; // Track currently highlighted cell for efficient clearing + public CsvViewerPanel() { InitializeComponent(); + + KeyDown += CsvViewerPanel_KeyDown; + searchPanel.SearchRequested += SearchPanel_SearchRequested; + searchPanel.NavigateRequested += SearchPanel_NavigateRequested; + searchPanel.CloseRequested += SearchPanel_CloseRequested; } public List Rows { get; private set; } = []; + private void CsvViewerPanel_KeyDown(object sender, KeyEventArgs e) + { + if ((Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control && e.Key == Key.F) + { + OpenSearchPanel(); + e.Handled = true; + } + else if (e.Key == Key.Escape && searchPanel.Visibility == Visibility.Visible) + { + CloseSearchPanel(); + e.Handled = true; + } + else if (e.Key == Key.F3) + { + if (searchPanel.Visibility == Visibility.Visible && _searchResults.Count > 0) + { + if ((Keyboard.Modifiers & ModifierKeys.Shift) == ModifierKeys.Shift) + { + NavigateToPreviousResult(); + } + else + { + NavigateToNextResult(); + } + } + e.Handled = true; + } + } + + private void OpenSearchPanel() + { + searchPanel.Visibility = Visibility.Visible; + searchPanel.Focus(); + } + + private void CloseSearchPanel() + { + searchPanel.Visibility = Visibility.Collapsed; + ClearHighlighting(); + _searchResults.Clear(); + _currentResultIndex = -1; + dataGrid.Focus(); + } + + private void SearchPanel_SearchRequested(object sender, SearchEventArgs e) + { + _currentSearchText = e.SearchText; + _currentMatchCase = e.MatchCase; + PerformSearch(); + } + + private void SearchPanel_NavigateRequested(object sender, NavigateEventArgs e) + { + if (e.Forward) + { + NavigateToNextResult(); + } + else + { + NavigateToPreviousResult(); + } + } + + private void SearchPanel_CloseRequested(object sender, EventArgs e) + { + CloseSearchPanel(); + } + + private void PerformSearch() + { + ClearHighlighting(); + _searchResults.Clear(); + _currentResultIndex = -1; + + if (string.IsNullOrEmpty(_currentSearchText)) + { + searchPanel.UpdateMatchCount(0, _currentResultIndex); + return; + } + + var comparison = _currentMatchCase ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + + for (int rowIndex = 0; rowIndex < Rows.Count; rowIndex++) + { + var row = Rows[rowIndex]; + for (int colIndex = 0; colIndex < row.Length; colIndex++) + { + if (row[colIndex] != null && row[colIndex].IndexOf(_currentSearchText, comparison) >= 0) + { + _searchResults.Add((rowIndex, colIndex)); + } + } + } + + if (_searchResults.Count > 0) + { + _currentResultIndex = 0; + NavigateToCurrentResult(); + } + + searchPanel.UpdateMatchCount(_searchResults.Count, _currentResultIndex); + } + + private void NavigateToNextResult() + { + if (_searchResults.Count == 0) + return; + + _currentResultIndex = (_currentResultIndex + 1) % _searchResults.Count; + NavigateToCurrentResult(); + searchPanel.UpdateMatchCount(_searchResults.Count, _currentResultIndex); + } + + private void NavigateToPreviousResult() + { + if (_searchResults.Count == 0) + return; + + _currentResultIndex = (_currentResultIndex - 1 + _searchResults.Count) % _searchResults.Count; + NavigateToCurrentResult(); + searchPanel.UpdateMatchCount(_searchResults.Count, _currentResultIndex); + } + + private void NavigateToCurrentResult() + { + if (_currentResultIndex < 0 || _currentResultIndex >= _searchResults.Count) + return; + + var (rowIndex, colIndex) = _searchResults[_currentResultIndex]; + + // Scroll to the row + if (rowIndex < dataGrid.Items.Count) + { + dataGrid.ScrollIntoView(dataGrid.Items[rowIndex]); + dataGrid.UpdateLayout(); + + // Select the cell + dataGrid.SelectedIndex = rowIndex; + + // Try to highlight the specific cell + HighlightCurrentCell(rowIndex, colIndex); + } + } + + private void HighlightCurrentCell(int rowIndex, int colIndex) + { + // Clear previous highlight first + ClearHighlighting(); + + try + { + var row = dataGrid.ItemContainerGenerator.ContainerFromIndex(rowIndex) as DataGridRow; + if (row != null) + { + var presenter = FindVisualChild(row); + if (presenter != null) + { + var cell = presenter.ItemContainerGenerator.ContainerFromIndex(colIndex) as DataGridCell; + if (cell != null) + { + cell.Background = HighlightBrush; + _highlightedCell = cell; + } + } + } + } + catch (InvalidOperationException) + { + // Can occur when visual tree is being rebuilt during scrolling. + // Safe to ignore as the cell will be highlighted on next navigation. + } + } + + private void ClearHighlighting() + { + // Only clear the previously highlighted cell instead of iterating all cells + if (_highlightedCell != null) + { + _highlightedCell.ClearValue(DataGridCell.BackgroundProperty); + _highlightedCell = null; + } + } + + private static T FindVisualChild(DependencyObject parent) where T : DependencyObject + { + for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + if (child is T typedChild) + { + return typedChild; + } + var result = FindVisualChild(child); + if (result != null) + { + return result; + } + } + return null; + } + public void LoadFile(string path) { const int limit = 10000;