Skip to content

Commit

Permalink
Added search result outlines, pressing CTRL+F with selected text puts…
Browse files Browse the repository at this point in the history
… that text in the search field, sped up the rate at which a search beings after the search query changes (for a more responsive UI), and also added the caret offset+selection length to the bottom right corner for convenience (it updates slower now though due to the Offset calculation lag)
  • Loading branch information
AngryCarrot789 committed Mar 17, 2024
1 parent e779427 commit 08c0492
Show file tree
Hide file tree
Showing 10 changed files with 288 additions and 77 deletions.
6 changes: 6 additions & 0 deletions SharpPad.sln
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@ EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{DCFF60C3-0984-4579-B730-217FB8AAFF78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DCFF60C3-0984-4579-B730-217FB8AAFF78}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DCFF60C3-0984-4579-B730-217FB8AAFF78}.Debug|x64.ActiveCfg = Debug|x64
{DCFF60C3-0984-4579-B730-217FB8AAFF78}.Debug|x64.Build.0 = Debug|x64
{DCFF60C3-0984-4579-B730-217FB8AAFF78}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DCFF60C3-0984-4579-B730-217FB8AAFF78}.Release|Any CPU.Build.0 = Release|Any CPU
{DCFF60C3-0984-4579-B730-217FB8AAFF78}.Release|x64.ActiveCfg = Release|x64
{DCFF60C3-0984-4579-B730-217FB8AAFF78}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
6 changes: 6 additions & 0 deletions SharpPad/Notepads/Commands/FindModelCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ public override Executability CanExecute(CommandEventArgs e) {
protected override void Execute(CommandEventArgs e) {
if (DataKeys.NotepadEditorKey.TryGetContext(e.ContextData, out NotepadEditor editor)) {
editor.IsFindPanelOpen = true;

string selection = editor.TextEditor?.SelectedText;
if (!string.IsNullOrEmpty(selection)) {
editor.FindModel.SearchText = selection;
}

if (DataKeys.UINotepadEditorKey.TryGetContext(e.ContextData, out INotepadEditorUI ui)) {
ui.FocusFindSearchBox();
}
Expand Down
176 changes: 156 additions & 20 deletions SharpPad/Notepads/Controls/NotepadEditorControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,16 @@
//

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Threading;
using ICSharpCode.AvalonEdit;
using ICSharpCode.AvalonEdit.Document;
using ICSharpCode.AvalonEdit.Rendering;
using SharpPad.Interactivity;
using SharpPad.Interactivity.Contexts;
using SharpPad.Utils;
Expand Down Expand Up @@ -70,6 +76,8 @@ public bool IsDroppableTargetOver {
// use RDA to prevent lag if the active document changes very quickly, e.g. opening files
// but the active document is set during each file opened for some reason
private readonly RapidDispatchAction<NotepadEditor> updateActiveEditorRda;
private ColorizeSearchResultsBackgroundRenderer searchColorizor;
// private FindResultOutline findResultOutliner;

public NotepadEditorControl() {
this.contextData = new ContextData().Set(DataKeys.UINotepadEditorKey, this);
Expand All @@ -83,6 +91,10 @@ public override void OnApplyTemplate() {
TemplateUtils.GetTemplateChild(this, nameof(this.PART_FindAndReplacePanel), out this.PART_FindAndReplacePanel);
TemplateUtils.GetTemplateChild(this, nameof(this.PART_FindAndReplaceControl), out this.PART_FindAndReplaceControl);

this.searchColorizor = new ColorizeSearchResultsBackgroundRenderer();
this.PART_TextEditor.TextArea.TextView.BackgroundRenderers.Add(this.searchColorizor);

// this.PART_TextEditor.TextArea.TextView.LineTransformers.Add(this.findResultOutliner = new FindResultOutline(this));
this.PART_FindAndReplacePanel.Visibility = Visibility.Collapsed;
if (this.activeEditor != null)
this.activeEditor.TextEditor = this.PART_TextEditor;
Expand Down Expand Up @@ -114,7 +126,7 @@ public void SetActiveEditor(NotepadEditor editor) {
if (this.activeEditor != null) {
this.activeEditor.DocumentChanged -= this.OnActiveEditorDocumentChanged;
this.activeEditor.IsFindPanelOpenChanged -= this.OnIsFindPanelOpenChanged;
this.SetFindModel(null);
this.SetVisibleFindModel(null);
this.activeEditor.TextEditor = null;
this.activeEditor = null;
}
Expand All @@ -131,7 +143,7 @@ public void SetActiveEditor(NotepadEditor editor) {
this.SetVisibility(Visibility.Visible);
this.SetActiveDocument(editor.Document);
if (editor.IsFindPanelOpen)
this.SetFindModel(editor.FindModel, false);
this.SetVisibleFindModel(editor.FindModel, false);
}
else {
this.SetVisibility(Visibility.Collapsed);
Expand All @@ -144,11 +156,11 @@ public void SetActiveEditor(NotepadEditor editor) {
}
}

private void OnIsFindPanelOpenChanged(NotepadEditor editor) => this.SetFindModel(editor.IsFindPanelOpen ? editor.FindModel : null);
private void OnIsFindPanelOpenChanged(NotepadEditor editor) => this.SetVisibleFindModel(editor.IsFindPanelOpen ? editor.FindModel : null);

private void OnActiveEditorDocumentChanged(NotepadEditor editor, NotepadDocument olddoc, NotepadDocument newDoc) {
this.SetActiveDocument(newDoc);
this.SetFindModel(editor.FindModel);
this.SetVisibleFindModel(editor.FindModel);
}

private void SetActiveDocument(NotepadDocument document) {
Expand All @@ -170,7 +182,8 @@ private void SetActiveDocument(NotepadDocument document) {
DataManager.SetContextData(this, this.contextData.Set(DataKeys.DocumentKey, document).Clone());
}

private void SetFindModel(FindAndReplaceModel model, bool focusTextBox = true) {
// Sets the find model that is being present. Null hides the find panel, non-null shows it
private void SetVisibleFindModel(FindAndReplaceModel model, bool focusTextBox = true) {
if (this.activeFindModel != null) {
this.activeFindModel.SearchResultsChanged -= this.OnSearchResultsChanged;
this.activeFindModel.CurrentResultIndexChanged -= this.OnCurrentResultIndexChanged;
Expand All @@ -182,7 +195,7 @@ private void SetFindModel(FindAndReplaceModel model, bool focusTextBox = true) {
this.PART_FindAndReplacePanel.Visibility = Visibility.Visible;
this.PART_FindAndReplaceControl.FindModel = model;
if (focusTextBox) {
this.PART_FindAndReplaceControl.FocusSearchText();
this.FocusFindSearchBox();
}
}
else {
Expand All @@ -193,6 +206,8 @@ private void SetFindModel(FindAndReplaceModel model, bool focusTextBox = true) {

if (this.contextData.TryReplace(DataKeys.FindModelKey, model))
DataManager.SetContextData(this, this.contextData.Clone());

this.UpdateSearchResultRender();
}

private void OnCurrentResultIndexChanged(FindAndReplaceModel model) {
Expand All @@ -201,8 +216,19 @@ private void OnCurrentResultIndexChanged(FindAndReplaceModel model) {
}

int index = model.CurrentResultIndex;
if (index < 0)
index = 0;
if (index == -1) {
// Instead of defaulting to the first result, we just do nothing. This is to present the GUI from spasming
// when CurrentResultIndex changes between dispatcher calls, which it does, since:
// - Find/Search begins: results get cleared, but CurrentResultIndex becomes -1 first,
// so GUI scrolls to top if there is at least one result.
// - Search happens, is uses an activity so it uses the dispatcher to jump between threads
// - Search completes, some code somewhere (I forgot) sets CurrentResultIndex to the nearest result
// nearest to the editor caret, and scrolls to it.
// When the search operation takes less than maybe half a second, you can see it
// flash as it scrolls to the top then to the old caret extremely quickly. By doing nothing
// here, we prevent that happening. Or... could just use an RDAEx on Background to update the caret ;)
return;
}

if (index < model.Results.Count) {
TextRange range = model.Results[index];
Expand All @@ -214,21 +240,31 @@ private void OnCurrentResultIndexChanged(FindAndReplaceModel model) {
}

private void OnSearchResultsChanged(FindAndReplaceModel model) {
if (this.PART_TextEditor.IsFocused || this.PART_TextEditor.TextArea.IsFocused) {
return;
if (!this.PART_TextEditor.IsFocused && !this.PART_TextEditor.TextArea.IsFocused) {
int selection = this.PART_TextEditor.SelectionLength;
int currentOffset = this.PART_TextEditor.CaretOffset - selection;
int index = BinarySearch.IndexOf(model.Results, currentOffset, (e) => e.Index);
if (index < 0)
index = ~index;

if (index < model.Results.Count) {
model.CurrentResultIndex = index;
}
else {
this.PART_TextEditor.SelectionLength = 0;
}
}

int selection = this.PART_TextEditor.SelectionLength;
int currentOffset = this.PART_TextEditor.CaretOffset - selection;
int index = BinarySearch.IndexOf(model.Results, currentOffset, (e) => e.Index);
if (index < 0)
index = ~index;
this.UpdateSearchResultRender();

if (index < model.Results.Count) {
model.CurrentResultIndex = index;
}
else {
this.PART_TextEditor.SelectionLength = 0;
// this.PART_TextEditor.TextArea.TextView.LineTransformers.Remove(this.findResultOutliner);
// this.PART_TextEditor.TextArea.TextView.LineTransformers.Add(this.findResultOutliner);
}

private void UpdateSearchResultRender() {
if (this.searchColorizor != null) {
this.searchColorizor.OnSearchUpdated(this.activeFindModel?.Results);
// this.PART_TextEditor.TextArea.TextView.InvalidateLayer(KnownLayer.Selection);
}
}

Expand Down Expand Up @@ -304,5 +340,105 @@ protected override async void OnDrop(DragEventArgs e) {
#endregion

public void FocusFindSearchBox() => this.PART_FindAndReplaceControl?.FocusSearchText();

#region Search Result Outlines

// A modified implementation from: https://stackoverflow.com/a/47955290/11034928
public class ColorizeSearchResultsBackgroundRenderer : IBackgroundRenderer {
private static readonly Brush BgBrush;
private static readonly Pen BdPen;
private readonly TextSegmentCollection<TextSegment> myResults = new TextSegmentCollection<TextSegment>();

public KnownLayer Layer => KnownLayer.Selection; // draw behind selection

public ColorizeSearchResultsBackgroundRenderer() {
}

static ColorizeSearchResultsBackgroundRenderer() {
Color bgc = Colors.Orange;
Color brc = Colors.White;
BgBrush = new SolidColorBrush(new Color() {R = bgc.R, G = bgc.G, B = bgc.B, A = 175});
BdPen = new Pen(new SolidColorBrush(new Color() {R = brc.R, G = brc.G, B = brc.B, A = 255}), 1.0);

// big performance helper
if (BgBrush.CanFreeze)
BgBrush.Freeze();
if (BdPen.CanFreeze)
BdPen.Freeze();
}

public void OnSearchUpdated(IEnumerable<TextRange> ranges) {
this.myResults.Clear();
if (ranges != null)
this.myResults.AddCollectionRange(ranges.Select(x => new TextSegment() {StartOffset = x.Index, Length = x.Length}));
}

/// <summary>Causes the background renderer to draw.</summary>
public void Draw(TextView textView, DrawingContext drawingContext) {
if (this.myResults == null || !textView.VisualLinesValid) {
return;
}

ReadOnlyCollection<VisualLine> visualLines = textView.VisualLines;
if (visualLines.Count == 0) {
return;
}

int viewStart = visualLines.First().FirstDocumentLine.Offset;
int viewEnd = visualLines.Last().LastDocumentLine.EndOffset;

foreach (TextSegment result in this.myResults.FindOverlappingSegments(viewStart, viewEnd - viewStart)) {
BackgroundGeometryBuilder geoBuilder = new BackgroundGeometryBuilder {
AlignToWholePixels = true, BorderThickness = 1, CornerRadius = 0
};

geoBuilder.AddSegment(textView, result);
Geometry geometry = geoBuilder.CreateGeometry();
if (geometry != null) {
drawingContext.DrawGeometry(BgBrush, BdPen, geometry);
}
}
}
}

// Old version. Works, but getting the white outline doesn't work that well
private class FindResultOutline : ColorizingTransformer {
private static readonly Brush BgBrush = new SolidColorBrush(new Color() {R = Colors.Orange.R, G = Colors.Orange.G, B = Colors.Orange.B, A = 150});

private readonly NotepadEditorControl control;

public FindResultOutline(NotepadEditorControl control) {
this.control = control;
}

static FindResultOutline() {
// big performance helper
if (BgBrush.CanFreeze)
BgBrush.Freeze();
}

protected override void Colorize(ITextRunConstructionContext context) {
IReadOnlyList<TextRange> results = this.control.activeFindModel?.Results;
if (results == null || results.Count < 1) {
return;
}

int lineStartOffset = context.VisualLine.FirstDocumentLine.Offset;
foreach (TextRange range in results) {
if (range.Index < lineStartOffset) {
continue;
}

int startColumn = context.VisualLine.GetVisualColumn(range.Index - lineStartOffset);
int endColumn = context.VisualLine.GetVisualColumn(range.EndIndex - lineStartOffset);

this.ChangeVisualElements(startColumn, endColumn, element => {
element.TextRunProperties.SetBackgroundBrush(BgBrush);
});
}
}
}

#endregion
}
}
12 changes: 6 additions & 6 deletions SharpPad/Notepads/FindAndReplaceModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ namespace SharpPad.Notepads {
public delegate void FindAndReplaceEventHandler(FindAndReplaceModel model);

public class FindAndReplaceModel {
private readonly RateLimitedDispatchAction queryChangedRlda;
private readonly RateLimitedDispatchAction invalidateSearchStateRlda;
private readonly List<TextRange> results;

private volatile string searchText;
Expand Down Expand Up @@ -151,7 +151,7 @@ public int CurrentResultIndex {
public FindAndReplaceModel(NotepadDocument document) {
this.currentResultIndex = -1;
this.Document = document ?? throw new ArgumentNullException(nameof(document));
this.queryChangedRlda = RateLimitedDispatchAction.ForDispatcherAsync(this.OnSearchQueryChanged_AMT, TimeSpan.FromSeconds(0.25));
this.invalidateSearchStateRlda = RateLimitedDispatchAction.ForDispatcherAsync(this.OnSearchStateInvalidated, TimeSpan.FromSeconds(0.1));
this.results = new List<TextRange>();
this.criticalLock = new object();
document.Document.Changed += this.OnDocumentModified;
Expand Down Expand Up @@ -228,10 +228,10 @@ public void UpdateSearch() {
return;
}

this.queryChangedRlda.InvokeAsync();
this.invalidateSearchStateRlda.InvokeAsync();
}

private async Task OnSearchQueryChanged_AMT() {
private async Task OnSearchStateInvalidated() {
this.ClearResults();
if (string.IsNullOrEmpty(this.searchText)) {
return;
Expand All @@ -248,7 +248,7 @@ private async Task OnSearchQueryChanged_AMT() {
lock (this.criticalLock) {
this.isActiveSearchInvalid = false;
this.queryChangedRlda.ClearCriticalState();
this.invalidateSearchStateRlda.ClearCriticalState();
}
// Keep searching in a loop. SearchImpl returns false when the document is modified externally,
Expand All @@ -274,7 +274,7 @@ private async Task OnSearchQueryChanged_AMT() {
// But at a guess, this is maybe a sub-millisecond time window where the user
// would have to modify the document or search query riiight as the search is
// about to finish, and I'm fine with that being the case, it won't crash :)
this.queryChangedRlda.ClearCriticalState();
this.invalidateSearchStateRlda.ClearCriticalState();
}
}
}, new DefaultProgressTracker(System.Windows.Threading.DispatcherPriority.Background));
Expand Down
9 changes: 7 additions & 2 deletions SharpPad/Notepads/NotepadEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@
// along with SharpPad. If not, see <https://www.gnu.org/licenses/>.
//

using System.Collections.Generic;
using System.Collections.ObjectModel;
using ICSharpCode.AvalonEdit;

namespace SharpPad.Notepads {
Expand Down Expand Up @@ -91,6 +89,13 @@ public bool IsFindPanelOpen {
return;
this.isFindPanelOpen = value;
this.IsFindPanelOpenChanged?.Invoke(this);

if (value) {
this.findModel?.UpdateSearch();
}
else {
this.findModel?.ClearResults();
}
}
}

Expand Down
Loading

0 comments on commit 08c0492

Please sign in to comment.