Support search panel in CSV viewer #1824

This commit is contained in:
ema
2026-04-23 02:53:12 +08:00
parent 368ace9fe6
commit fefe4d8c37
4 changed files with 507 additions and 2 deletions
@@ -15,7 +15,7 @@
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</UserControl.Resources>
<Grid>
<Grid x:Name="LayoutRoot">
<DataGrid Name="dataGrid"
AlternatingRowBackground="#1900FF70"
AlternationCount="2"
@@ -29,6 +29,13 @@
IsReadOnly="True"
ItemsSource="{Binding Path=Rows, ElementName=csvViewer}"
RowBackground="#00FFFFFF"
SelectionMode="Single"
SelectionUnit="Cell"
VerticalGridLinesBrush="#19000000" />
<local:SearchPanel x:Name="searchPanel"
Margin="8"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Visibility="Collapsed" />
</Grid>
</UserControl>
@@ -25,7 +25,9 @@ using System.Linq;
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;
@@ -36,20 +38,42 @@ public partial class CsvViewerPanel : UserControl
public CsvViewerPanel()
{
InitializeComponent();
searchPanel.Visibility = Visibility.Collapsed;
searchPanel.SearchTextChanged += SearchPanel_SearchTextChanged;
searchPanel.MatchCaseChanged += SearchPanel_MatchCaseChanged;
searchPanel.FindNextRequested += SearchPanel_FindNextRequested;
searchPanel.FindPreviousRequested += SearchPanel_FindPreviousRequested;
searchPanel.CloseRequested += SearchPanel_CloseRequested;
PreviewKeyDown += CsvViewerPanel_PreviewKeyDown;
dataGrid.LoadingRow += DataGrid_LoadingRow;
}
public List<string[]> Rows { get; private set; } = [];
private readonly List<(int RowIndex, int ColumnIndex)> _matches = new();
private readonly HashSet<(int RowIndex, int ColumnIndex)> _matchSet = new();
private int _currentMatchIndex = -1;
public void LoadFile(string path)
{
const int limit = 10000;
var binded = false;
Rows.Clear();
dataGrid.Columns.Clear();
_matches.Clear();
_matchSet.Clear();
_currentMatchIndex = -1;
searchPanel.SetMatchCount(0, 0);
searchPanel.Visibility = Visibility.Collapsed;
var encoding = CharsetDetector.DetectFromFile(path).Detected?.Encoding ??
Encoding.Default;
using var sr = new StreamReader(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite), encoding);
// Use fixed delimiters for known extensions to avoid mis-detection on small samples.
var extension = Path.GetExtension(path);
var delimiter = extension.Equals(".tsv", StringComparison.OrdinalIgnoreCase)
@@ -110,6 +134,324 @@ public partial class CsvViewerPanel : UserControl
}
}
private void SearchPanel_SearchTextChanged(object sender, string searchText)
{
ExecuteSearch();
}
private void SearchPanel_MatchCaseChanged(object sender, bool matchCase)
{
ExecuteSearch();
}
private void SearchPanel_FindNextRequested(object sender, EventArgs e)
{
MoveMatch(1);
}
private void SearchPanel_FindPreviousRequested(object sender, EventArgs e)
{
MoveMatch(-1);
}
private void SearchPanel_CloseRequested(object sender, EventArgs e)
{
HideSearchPanel();
}
private void CsvViewerPanel_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.F && Keyboard.Modifiers.HasFlag(ModifierKeys.Control))
{
ShowSearchPanel();
e.Handled = true;
return;
}
if (e.Key == Key.F3)
{
MoveMatch(Keyboard.Modifiers.HasFlag(ModifierKeys.Shift) ? -1 : 1);
e.Handled = true;
return;
}
if (e.Key == Key.Escape && searchPanel.Visibility == Visibility.Visible)
{
HideSearchPanel();
e.Handled = true;
}
}
private void DataGrid_LoadingRow(object sender, DataGridRowEventArgs e)
{
UpdateRowHighlights(e.Row);
}
private void UpdateRowHighlights(DataGridRow row)
{
if (row.Item is not string[] rowData)
{
return;
}
var rowIndex = dataGrid.ItemContainerGenerator.IndexFromContainer(row);
for (var columnIndex = 0; columnIndex < dataGrid.Columns.Count; columnIndex++)
{
var cell = GetCell(row, columnIndex);
if (cell == null)
{
continue;
}
if (_matchSet.Contains((rowIndex, columnIndex)))
{
cell.Background = _currentMatchIndex >= 0 && _matches[_currentMatchIndex].RowIndex == rowIndex && _matches[_currentMatchIndex].ColumnIndex == columnIndex
? CurrentMatchBrush
: MatchBrush;
}
else
{
cell.ClearValue(BackgroundProperty);
}
}
}
private void ExecuteSearch()
{
var query = searchPanel.SearchText ?? string.Empty;
_matches.Clear();
_matchSet.Clear();
_currentMatchIndex = -1;
if (!string.IsNullOrEmpty(query))
{
var comparison = searchPanel.MatchCase
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase;
for (var rowIndex = 0; rowIndex < Rows.Count; rowIndex++)
{
var row = Rows[rowIndex];
for (var columnIndex = 0; columnIndex < row.Length; columnIndex++)
{
var cellValue = row[columnIndex];
if (!string.IsNullOrEmpty(cellValue) && cellValue.IndexOf(query, comparison) >= 0)
{
_matches.Add((rowIndex, columnIndex));
_matchSet.Add((rowIndex, columnIndex));
}
}
}
if (_matches.Count > 0)
{
_currentMatchIndex = 0;
}
}
searchPanel.SetMatchCount(_currentMatchIndex + 1, _matches.Count);
UpdateVisibleCellHighlights();
UpdateCurrentMatchSelection();
}
private void MoveMatch(int direction)
{
if (_matches.Count == 0)
{
return;
}
_currentMatchIndex = (_currentMatchIndex + direction + _matches.Count) % _matches.Count;
UpdateCurrentMatchSelection();
}
private void UpdateCurrentMatchSelection()
{
if (_matches.Count == 0)
{
dataGrid.SelectedCells.Clear();
return;
}
var match = _matches[_currentMatchIndex];
if (match.RowIndex < 0 || match.RowIndex >= Rows.Count)
{
return;
}
var rowItem = Rows[match.RowIndex];
if (match.ColumnIndex < 0 || match.ColumnIndex >= dataGrid.Columns.Count)
{
return;
}
var column = dataGrid.Columns[match.ColumnIndex];
dataGrid.SelectedCells.Clear();
var cellInfo = new DataGridCellInfo(rowItem, column);
dataGrid.CurrentCell = cellInfo;
dataGrid.SelectedCells.Add(cellInfo);
dataGrid.ScrollIntoView(rowItem, column);
Dispatcher.BeginInvoke(new Action(() =>
{
UpdateVisibleCellHighlights();
var row = GetRow(match.RowIndex);
var cell = GetCell(row, match.ColumnIndex);
cell?.Focus();
}), System.Windows.Threading.DispatcherPriority.Background);
searchPanel.SetMatchCount(_currentMatchIndex + 1, _matches.Count);
}
private void ShowSearchPanel()
{
searchPanel.Visibility = Visibility.Visible;
searchPanel.FocusSearchText();
}
private void HideSearchPanel()
{
searchPanel.Visibility = Visibility.Collapsed;
dataGrid.SelectedCells.Clear();
_matches.Clear();
_matchSet.Clear();
_currentMatchIndex = -1;
searchPanel.SetMatchCount(0, 0);
UpdateVisibleCellHighlights();
}
private void UpdateVisibleCellHighlights()
{
foreach (var row in GetVisibleRows())
{
if (row.Item is not string[] rowData)
{
continue;
}
var rowIndex = dataGrid.ItemContainerGenerator.IndexFromContainer(row);
for (var columnIndex = 0; columnIndex < dataGrid.Columns.Count; columnIndex++)
{
var cell = GetCell(row, columnIndex);
if (cell == null)
{
continue;
}
if (_matchSet.Contains((rowIndex, columnIndex)))
{
cell.Background = _currentMatchIndex >= 0 && _matches[_currentMatchIndex].RowIndex == rowIndex && _matches[_currentMatchIndex].ColumnIndex == columnIndex
? CurrentMatchBrush
: MatchBrush;
}
else
{
cell.ClearValue(BackgroundProperty);
}
}
}
}
private IEnumerable<DataGridRow> GetVisibleRows()
{
for (var index = 0; index < dataGrid.Items.Count; index++)
{
var row = dataGrid.ItemContainerGenerator.ContainerFromIndex(index) as DataGridRow;
if (row != null)
{
yield return row;
}
}
}
private DataGridRow GetRow(int index)
{
if (index < 0 || index >= dataGrid.Items.Count)
{
return null;
}
var row = dataGrid.ItemContainerGenerator.ContainerFromIndex(index) as DataGridRow;
if (row != null)
{
return row;
}
dataGrid.UpdateLayout();
dataGrid.ScrollIntoView(dataGrid.Items[index]);
return dataGrid.ItemContainerGenerator.ContainerFromIndex(index) as DataGridRow;
}
private DataGridCell GetCell(DataGridRow row, int columnIndex)
{
if (row == null)
{
return null;
}
var presenter = FindVisualChild<DataGridCellsPresenter>(row);
if (presenter == null)
{
row.ApplyTemplate();
presenter = FindVisualChild<DataGridCellsPresenter>(row);
}
if (presenter == null)
{
return null;
}
var cell = presenter.ItemContainerGenerator.ContainerFromIndex(columnIndex) as DataGridCell;
if (cell == null)
{
dataGrid.ScrollIntoView(row.Item, dataGrid.Columns[columnIndex]);
cell = presenter.ItemContainerGenerator.ContainerFromIndex(columnIndex) as DataGridCell;
}
return cell;
}
private static T FindVisualChild<T>(DependencyObject parent)
where T : DependencyObject
{
if (parent == null)
{
return null;
}
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (child is T typedChild)
{
return typedChild;
}
var foundChild = FindVisualChild<T>(child);
if (foundChild != null)
{
return foundChild;
}
}
return null;
}
private static readonly Brush MatchBrush = CreateFrozenBrush(Color.FromArgb(0x55, 0xFF, 0xFF, 0x00));
private static readonly Brush CurrentMatchBrush = CreateFrozenBrush(Color.FromArgb(0xAA, 0xFF, 0xD3, 0x00));
private static Brush CreateFrozenBrush(Color color)
{
var brush = new SolidColorBrush(color);
if (brush.CanFreeze)
{
brush.Freeze();
}
return brush;
}
public static T[] Concat<T>(T[] x, T[] y)
{
if (x == null) throw new ArgumentNullException("x");
@@ -0,0 +1,71 @@
<UserControl x:Class="QuickLook.Plugin.CsvViewer.SearchPanel"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
MinWidth="360"
MinHeight="36"
mc:Ignorable="d">
<Border Padding="6"
Background="{DynamicResource WindowBackground}"
BorderBrush="#102E2E3E"
BorderThickness="1"
CornerRadius="4"
SnapsToDevicePixels="True">
<StackPanel VerticalAlignment="Center" Orientation="Horizontal">
<TextBox x:Name="searchTextBox"
Width="180"
Margin="0,0,8,0"
VerticalContentAlignment="Center"
FocusVisualStyle="{x:Null}"
KeyDown="SearchTextBox_KeyDown"
TextChanged="SearchTextBox_TextChanged"
ToolTip="Search" />
<CheckBox x:Name="matchCaseToggle"
Margin="0,0,8,0"
VerticalAlignment="Center"
Checked="MatchCaseToggle_Checked"
Content="Match case"
Unchecked="MatchCaseToggle_Checked" />
<Button x:Name="previousButton"
Margin="0,0,4,0"
Click="PreviousButton_Click"
ToolTip="Previous match">
<TextBlock HorizontalAlignment="Center"
VerticalAlignment="Center"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
FontSize="12"
Text="&#xe76b;" />
</Button>
<Button x:Name="nextButton"
Margin="0,0,8,0"
Click="NextButton_Click"
ToolTip="Next match">
<TextBlock HorizontalAlignment="Center"
VerticalAlignment="Center"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
FontSize="12"
Text="&#xe76c;" />
</Button>
<TextBlock x:Name="matchCountTextBlock"
VerticalAlignment="Center"
Foreground="{DynamicResource WindowTextForeground}"
Text="No matches" />
<Button x:Name="closeButton"
Margin="8,0,0,0"
Click="CloseButton_Click"
ToolTip="Close">
<TextBlock HorizontalAlignment="Center"
VerticalAlignment="Center"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
FontSize="12"
Text="&#xe711;" />
</Button>
</StackPanel>
</Border>
</UserControl>
@@ -0,0 +1,85 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace QuickLook.Plugin.CsvViewer;
public partial class SearchPanel : UserControl
{
public event EventHandler<string> SearchTextChanged;
public event EventHandler<bool> MatchCaseChanged;
public event EventHandler FindNextRequested;
public event EventHandler FindPreviousRequested;
public event EventHandler CloseRequested;
public SearchPanel()
{
InitializeComponent();
}
public string SearchText
{
get => searchTextBox.Text;
set => searchTextBox.Text = value;
}
public bool MatchCase => matchCaseToggle.IsChecked == true;
public void FocusSearchText()
{
searchTextBox.Focus();
searchTextBox.SelectAll();
}
public void SetMatchCount(int current, int total)
{
matchCountTextBlock.Text = total == 0 ? "No matches" : $"{current} of {total}";
}
private void SearchTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
SearchTextChanged?.Invoke(this, searchTextBox.Text);
}
private void SearchTextBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
if (Keyboard.Modifiers.HasFlag(ModifierKeys.Shift))
{
FindPreviousRequested?.Invoke(this, EventArgs.Empty);
}
else
{
FindNextRequested?.Invoke(this, EventArgs.Empty);
}
e.Handled = true;
}
}
private void MatchCaseToggle_Checked(object sender, RoutedEventArgs e)
{
MatchCaseChanged?.Invoke(this, MatchCase);
}
private void PreviousButton_Click(object sender, RoutedEventArgs e)
{
FindPreviousRequested?.Invoke(this, EventArgs.Empty);
}
private void NextButton_Click(object sender, RoutedEventArgs e)
{
FindNextRequested?.Invoke(this, EventArgs.Empty);
}
private void CloseButton_Click(object sender, RoutedEventArgs e)
{
CloseRequested?.Invoke(this, EventArgs.Empty);
}
}