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;