diff --git a/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml index 570897a..f35c2e6 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml +++ b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml @@ -15,7 +15,7 @@ - + + diff --git a/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml.cs b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml.cs index 1b3e011..29f46d4 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/CsvViewerPanel.xaml.cs @@ -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 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 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(row); + if (presenter == null) + { + row.ApplyTemplate(); + presenter = FindVisualChild(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(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(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[] x, T[] y) { if (x == null) throw new ArgumentNullException("x"); diff --git a/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/SearchPanel.xaml b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/SearchPanel.xaml new file mode 100644 index 0000000..c2e2f8a --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/SearchPanel.xaml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + diff --git a/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/SearchPanel.xaml.cs b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/SearchPanel.xaml.cs new file mode 100644 index 0000000..7057417 --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.CsvViewer/SearchPanel.xaml.cs @@ -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 SearchTextChanged; + + public event EventHandler 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); + } +}