From e2f2354d3bc31988a7fac64d8b515f81f6f9bbbf Mon Sep 17 00:00:00 2001 From: Manuel Gasser Date: Sat, 21 Mar 2026 23:17:10 +0100 Subject: [PATCH 1/4] Add initial search changes --- .../SolutionExplorer/SolutionExplorerPanel.cs | 155 +++++++++++++++++- .../SolutionExplorerPanel.tscn | 30 +++- src/SharpIDE.Godot/InputStringNames.cs | 3 + src/SharpIDE.Godot/Resources/LightTheme.tres | 2 + src/SharpIDE.Godot/project.godot | 5 + 5 files changed, 187 insertions(+), 8 deletions(-) diff --git a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs index 1387e2b1..3240186f 100644 --- a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs +++ b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs @@ -1,5 +1,9 @@ using System.Collections.Specialized; using Ardalis.GuardClauses; + +using ClrDebug; +using ClrDebug.TTD; + using Godot; using ObservableCollections; using R3; @@ -31,20 +35,27 @@ public partial class SolutionExplorerPanel : MarginContainer public Texture2D UnloadedProjectIcon { get; set; } = null!; [Export] public Texture2D SlnIcon { get; set; } = null!; + [Export] + public StyleBoxFlat SearchMatchHighlight { get; set; } = null!; public SharpIdeSolutionModel SolutionModel { get; set; } = null!; private PanelContainer _panelContainer = null!; private Tree _tree = null!; private TreeItem _rootItem = null!; + private LineEdit _searchInput = null!; + + private readonly Dictionary _treeItemCollapsedStates = []; private enum ClipboardOperation { Cut, Copy } private (List, ClipboardOperation)? _itemsOnClipboard; public override void _Ready() { - _panelContainer = GetNode("PanelContainer"); + _panelContainer = GetNode("%TreeContainer"); _tree = GetNode("%Tree"); _tree.ItemMouseSelected += TreeOnItemMouseSelected; + _searchInput = GetNode("%SearchInput"); + _searchInput.TextChanged += OnSearchInputChanged; // Remove the tree from the scene tree for now, we will add it back when we bind to a solution _panelContainer.RemoveChild(_tree); GodotGlobalEvents.Instance.FileExternallySelected.Subscribe(OnFileExternallySelected); @@ -74,6 +85,109 @@ public override void _UnhandledKeyInput(InputEvent @event) else if (@event is InputEventKey { Pressed: true, Keycode: Key.Escape }) { ClearSlnExplorerClipboard(); + HideSearch(); + } + else if (@event.IsActionPressed(InputStringNames.FindInSolutionExplorer, exactMatch: true)) + { + if (!IsSearchActive()) + ShowSearch(); + else + HideSearch(); + + AcceptEvent(); + } + } + + private void OnSearchInputChanged(string newText) + { + if (!IsSearchActive() || string.IsNullOrWhiteSpace(newText)) + { + RestoreTreeItemCollapsedStates(_rootItem); + ShowTree(_rootItem); + } + else + { + FilterTree(_rootItem, newText); + } + + _tree.QueueRedraw(); + } + + private static void ShowTree(TreeItem item) + { + item.Visible = true; + + foreach (var child in item.GetChildren()) + { + ShowTree(child); + } + } + + private static void FilterTree(TreeItem item, string searchText) + { + var itemText = item.GetText(0); + if (itemText.Contains(searchText, StringComparison.CurrentCultureIgnoreCase)) + { + // Set ancestors visible to ensure the matching item will be visible in the tree. + for (var ancestor = item.GetParent(); ancestor is not null; ancestor = ancestor.GetParent()) + { + ancestor.Visible = true; + } + + item.Visible = true; + item.UncollapseTree(); + } + else + { + item.Visible = false; + } + + foreach (var child in item.GetChildren()) + { + FilterTree(child, searchText); + } + } + + private bool IsSearchActive() => _searchInput.IsVisible(); + + private void ShowSearch() + { + if (IsSearchActive()) return; + + SaveTreeItemCollapsedStates(_rootItem); + _searchInput.GrabFocus(); + _searchInput.Show(); + if (!string.IsNullOrWhiteSpace(_searchInput.Text)) + FilterTree(_rootItem, _searchInput.Text); + } + + private void HideSearch() + { + if (!IsSearchActive()) return; + + RestoreTreeItemCollapsedStates(_rootItem); + _searchInput.Hide(); + ShowTree(_rootItem); + } + + private void SaveTreeItemCollapsedStates(TreeItem item) + { + _treeItemCollapsedStates[item] = item.Collapsed; + + foreach (var child in item.GetChildren()) + { + SaveTreeItemCollapsedStates(child); + } + } + + private void RestoreTreeItemCollapsedStates(TreeItem item) + { + if (!item.IsSelected(0) && _treeItemCollapsedStates.TryGetValue(item, out var collapsed)) + item.Collapsed = collapsed; + + foreach (var child in item.GetChildren()) + { + RestoreTreeItemCollapsedStates(child); } } @@ -160,7 +274,7 @@ public async Task BindToSolution(SharpIdeSolutionModel solution) _tree.Clear(); // Root - var rootItem = _tree.CreateItem(); + var rootItem = CreateTreeItem(); rootItem.SetText(0, solution.Name); rootItem.SetIcon(0, SlnIcon); _rootItem = rootItem; @@ -195,10 +309,39 @@ await this.InvokeAsync(() => }); } + private TreeItem CreateTreeItem(TreeItem? parent = null, int index = -1) + { + var item = _tree.CreateItem(parent, index); + item.SetCellMode(0, TreeItem.TreeCellMode.Custom); + item.SetCustomDrawCallback(0, Callable.From(TreeItemCustomDraw)); + return item; + } + + private void TreeItemCustomDraw(TreeItem item, Rect2 rect) + { + if (!_searchInput.IsVisible() || string.IsNullOrWhiteSpace(_searchInput.Text)) return; + + var text = item.GetText(0); + var matchIndex = text.FindN(_searchInput.Text); + + if (matchIndex < 0) return; + + var icon = item.GetIcon(0); + var font = _tree.GetThemeFont(ThemeStringNames.Font); + var fontSize = _tree.GetThemeFontSize(ThemeStringNames.FontSize); + var separation = _tree.GetThemeConstant(ThemeStringNames.HSeparation); + var textMatchX = separation + font.GetStringSize(text.Left(matchIndex), HorizontalAlignment.Left, width: -1f, fontSize).X; + var highlightPosition = new Vector2(rect.Position.X + textMatchX + (icon?.GetWidth() ?? 0), rect.Position.Y); + var highlightSize = new Vector2(font.GetStringSize(_searchInput.Text, HorizontalAlignment.Left, width: -1f, fontSize).X, rect.Size.Y); + + var highlightRect = new Rect2(highlightPosition, highlightSize); + _tree.DrawStyleBox(SearchMatchHighlight, highlightRect); + } + [RequiresGodotUiThread] private TreeItem CreateSlnFolderTreeItem(Tree tree, TreeItem parent, SharpIdeSolutionFolder slnFolder) { - var folderItem = tree.CreateItem(parent); + var folderItem = CreateTreeItem(parent); folderItem.SetText(0, slnFolder.Name); folderItem.SetIcon(0, SlnFolderIcon); folderItem.SharpIdeNode = slnFolder; @@ -240,7 +383,7 @@ private TreeItem CreateSlnFolderTreeItem(Tree tree, TreeItem parent, SharpIdeSol [RequiresGodotUiThread] private TreeItem CreateProjectTreeItem(Tree tree, TreeItem parent, SharpIdeProjectModel projectModel) { - var projectItem = tree.CreateItem(parent); + var projectItem = CreateTreeItem(parent); projectItem.SetText(0, projectModel.Name.Value); var icon = projectModel.IsLoading ? LoadingProjectIcon : projectModel.IsInvalid ? UnloadedProjectIcon : CsprojIcon; projectItem.SetIcon(0, icon); @@ -295,7 +438,7 @@ await this.InvokeAsync(() => [RequiresGodotUiThread] private TreeItem CreateFolderTreeItem(Tree tree, TreeItem parent, SharpIdeFolder sharpIdeFolder, int newStartingIndex = -1) { - var folderItem = tree.CreateItem(parent, newStartingIndex); + var folderItem = CreateTreeItem(parent, newStartingIndex); folderItem.SetText(0, sharpIdeFolder.Name.Value); folderItem.SetIcon(0, FolderIcon); folderItem.SharpIdeNode = sharpIdeFolder; @@ -345,7 +488,7 @@ private TreeItem CreateFileTreeItem(Tree tree, TreeItem parent, SharpIdeFile sha var folderCount = sharpIdeParent.Folders.Count; newStartingIndex += folderCount; } - var fileItem = tree.CreateItem(parent, newStartingIndex); + var fileItem = CreateTreeItem(parent, newStartingIndex); fileItem.SetText(0, sharpIdeFile.Name.Value); fileItem.SetIconsForFileExtension(sharpIdeFile); if (GitColours.GetColorForGitFileStatus(sharpIdeFile.GitStatus) is { } notnullColor) fileItem.SetCustomColor(0, notnullColor); diff --git a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.tscn b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.tscn index 6c6a8d3a..55d9b9ed 100644 --- a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.tscn +++ b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.tscn @@ -9,6 +9,15 @@ [ext_resource type="Texture2D" uid="uid://mnpe6bcnycfh" path="res://Features/SolutionExplorer/Resources/LoadingProjectIcon.svg" id="6_wpvwf"] [ext_resource type="Texture2D" uid="uid://b4n5n20uey34i" path="res://Features/SolutionExplorer/Resources/UnloadedProjectIcon.svg" id="7_ykfad"] +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ykfad"] +bg_color = Color(0.12156863, 0.6901961, 0.6431373, 0.47843137) +corner_radius_top_left = 2 +corner_radius_top_right = 2 +corner_radius_bottom_right = 2 +corner_radius_bottom_left = 2 +expand_margin_left = 1.0 +expand_margin_right = 1.0 + [sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_idvpu"] content_margin_left = 4.0 content_margin_top = 4.0 @@ -50,11 +59,28 @@ CsprojIcon = ExtResource("5_r1qfc") LoadingProjectIcon = ExtResource("6_wpvwf") UnloadedProjectIcon = ExtResource("7_ykfad") SlnIcon = ExtResource("6_idvpu") +SearchMatchHighlight = SubResource("StyleBoxFlat_ykfad") + +[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=1396470192] +layout_mode = 2 -[node name="PanelContainer" type="PanelContainer" parent="." unique_id=1468166306] +[node name="SearchInput" type="LineEdit" parent="VBoxContainer" unique_id=724786348] +unique_name_in_owner = true +visible = false +custom_minimum_size = Vector2(0, 40) +layout_mode = 2 +size_flags_horizontal = 3 +theme_type_variation = &"SearchInput" +placeholder_text = "Search" +clear_button_enabled = true +select_all_on_focus = true + +[node name="TreeContainer" type="PanelContainer" parent="VBoxContainer" unique_id=1468166306] +unique_name_in_owner = true layout_mode = 2 +size_flags_vertical = 3 -[node name="Tree" type="Tree" parent="PanelContainer" unique_id=1327553265] +[node name="Tree" type="Tree" parent="VBoxContainer/TreeContainer" unique_id=1327553265] unique_name_in_owner = true layout_mode = 2 theme_override_constants/v_separation = 1 diff --git a/src/SharpIDE.Godot/InputStringNames.cs b/src/SharpIDE.Godot/InputStringNames.cs index 222f056d..c3acb4dd 100644 --- a/src/SharpIDE.Godot/InputStringNames.cs +++ b/src/SharpIDE.Godot/InputStringNames.cs @@ -26,6 +26,7 @@ public static class InputStringNames public static readonly StringName CodeEditorDuplicateLine = nameof(CodeEditorDuplicateLine); public static readonly StringName FindInCurrentFile = nameof(FindInCurrentFile); public static readonly StringName ReplaceInCurrentFile = nameof(ReplaceInCurrentFile); + public static readonly StringName FindInSolutionExplorer = nameof(FindInSolutionExplorer); } public static class ThemeStringNames @@ -47,6 +48,8 @@ public static class ThemeStringNames public static readonly StringName CompletionScrollColor = "completion_scroll_color"; public static readonly StringName CompletionExistingColor = "completion_existing_color"; public static readonly StringName CompletionColorBgIcon = "completion_color_bg"; + + public static readonly StringName HSeparation = "h_separation"; } public static class ThemeVariationStringNames diff --git a/src/SharpIDE.Godot/Resources/LightTheme.tres b/src/SharpIDE.Godot/Resources/LightTheme.tres index 27baadbc..f2a11a5c 100644 --- a/src/SharpIDE.Godot/Resources/LightTheme.tres +++ b/src/SharpIDE.Godot/Resources/LightTheme.tres @@ -282,6 +282,8 @@ IdeSidebarButton/styles/normal = SubResource("StyleBoxFlat_dsk6k") IdeSidebarButton/styles/pressed = SubResource("StyleBoxFlat_njudc") Label/colors/font_color = Color(0.17, 0.17, 0.17, 1) LineEdit/colors/caret_color = Color(0.05, 0.05, 0.05, 1) +LineEdit/colors/clear_button_color = Color(0.3019608, 0.3019608, 0.3019608, 1) +LineEdit/colors/clear_button_color_pressed = Color(0.3019608, 0.3019608, 0.3019608, 1) LineEdit/colors/font_color = Color(0.12, 0.12, 0.12, 1) LineEdit/colors/font_placeholder_color = Color(0.12, 0.12, 0.12, 0.6) LineEdit/styles/normal = SubResource("StyleBoxFlat_guqd5") diff --git a/src/SharpIDE.Godot/project.godot b/src/SharpIDE.Godot/project.godot index 253cca5d..aee69ea9 100644 --- a/src/SharpIDE.Godot/project.godot +++ b/src/SharpIDE.Godot/project.godot @@ -148,6 +148,11 @@ CodeEditorDuplicateLine={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":68,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +FindInSolutionExplorer={ +"deadzone": 0.2, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":70,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} [rendering] From c66cda45c6371a5a267f9bc58013ff08385c39e9 Mon Sep 17 00:00:00 2001 From: Manuel Gasser Date: Sun, 22 Mar 2026 12:44:07 +0100 Subject: [PATCH 2/4] Minor improvements and add comments --- .../SolutionExplorer/SolutionExplorerPanel.cs | 51 ++++++++++--------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs index 3240186f..36e68ee2 100644 --- a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs +++ b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs @@ -1,9 +1,6 @@ using System.Collections.Specialized; using Ardalis.GuardClauses; -using ClrDebug; -using ClrDebug.TTD; - using Godot; using ObservableCollections; using R3; @@ -15,7 +12,6 @@ using SharpIDE.Godot.Features.BottomPanel; using SharpIDE.Godot.Features.Common; using SharpIDE.Godot.Features.Git; -using SharpIDE.Godot.Features.Problems; namespace SharpIDE.Godot.Features.SolutionExplorer; @@ -104,6 +100,7 @@ private void OnSearchInputChanged(string newText) { RestoreTreeItemCollapsedStates(_rootItem); ShowTree(_rootItem); + ScrollToSelectedTreeItem(); } else { @@ -123,29 +120,22 @@ private static void ShowTree(TreeItem item) } } - private static void FilterTree(TreeItem item, string searchText) + private static bool FilterTree(TreeItem item, string searchText) { var itemText = item.GetText(0); - if (itemText.Contains(searchText, StringComparison.CurrentCultureIgnoreCase)) - { - // Set ancestors visible to ensure the matching item will be visible in the tree. - for (var ancestor = item.GetParent(); ancestor is not null; ancestor = ancestor.GetParent()) - { - ancestor.Visible = true; - } - - item.Visible = true; - item.UncollapseTree(); - } - else - { - item.Visible = false; - } + var isMatch = itemText.Contains(searchText, StringComparison.OrdinalIgnoreCase); + var hasMatchingChild = false; foreach (var child in item.GetChildren()) { - FilterTree(child, searchText); + if (FilterTree(child, searchText)) + hasMatchingChild = true; } + + item.Visible = isMatch || hasMatchingChild; + item.Collapsed = !hasMatchingChild; + + return isMatch || hasMatchingChild; } private bool IsSearchActive() => _searchInput.IsVisible(); @@ -154,6 +144,7 @@ private void ShowSearch() { if (IsSearchActive()) return; + _treeItemCollapsedStates.Clear(); SaveTreeItemCollapsedStates(_rootItem); _searchInput.GrabFocus(); _searchInput.Show(); @@ -165,9 +156,10 @@ private void HideSearch() { if (!IsSearchActive()) return; - RestoreTreeItemCollapsedStates(_rootItem); _searchInput.Hide(); + RestoreTreeItemCollapsedStates(_rootItem); ShowTree(_rootItem); + ScrollToSelectedTreeItem(); } private void SaveTreeItemCollapsedStates(TreeItem item) @@ -182,8 +174,8 @@ private void SaveTreeItemCollapsedStates(TreeItem item) private void RestoreTreeItemCollapsedStates(TreeItem item) { - if (!item.IsSelected(0) && _treeItemCollapsedStates.TryGetValue(item, out var collapsed)) - item.Collapsed = collapsed; + // If the item is selected during the search then we want to keep it uncollapsed, otherwise we restore it to the state before the search. + item.Collapsed = !HasSelectedChild(item) && _treeItemCollapsedStates.TryGetValue(item, out var collapsed) && collapsed; foreach (var child in item.GetChildren()) { @@ -191,6 +183,15 @@ private void RestoreTreeItemCollapsedStates(TreeItem item) } } + private static bool HasSelectedChild(TreeItem item) => item.GetChildren().Any(child => child.IsSelected(0) || HasSelectedChild(child)); + + private void ScrollToSelectedTreeItem() + { + if (_tree.GetSelected() is not { } selected) return; + + _tree.ScrollToItem(selected, centerOnItem: true); + } + private void TreeOnItemMouseSelected(Vector2 mousePosition, long mouseButtonIndex) { var selected = _tree.GetSelected(); @@ -319,7 +320,7 @@ private TreeItem CreateTreeItem(TreeItem? parent = null, int index = -1) private void TreeItemCustomDraw(TreeItem item, Rect2 rect) { - if (!_searchInput.IsVisible() || string.IsNullOrWhiteSpace(_searchInput.Text)) return; + if (!IsSearchActive() || string.IsNullOrWhiteSpace(_searchInput.Text)) return; var text = item.GetText(0); var matchIndex = text.FindN(_searchInput.Text); From 3d06e4b4742dad993c52ec377dee7af2086348fd Mon Sep 17 00:00:00 2001 From: Manuel Gasser Date: Sun, 22 Mar 2026 12:53:24 +0100 Subject: [PATCH 3/4] Minor cleanup --- .../SolutionExplorer/SolutionExplorerPanel.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs index 36e68ee2..303ea731 100644 --- a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs +++ b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs @@ -1,6 +1,5 @@ using System.Collections.Specialized; using Ardalis.GuardClauses; - using Godot; using ObservableCollections; using R3; @@ -99,7 +98,7 @@ private void OnSearchInputChanged(string newText) if (!IsSearchActive() || string.IsNullOrWhiteSpace(newText)) { RestoreTreeItemCollapsedStates(_rootItem); - ShowTree(_rootItem); + ShowEntireTree(_rootItem); ScrollToSelectedTreeItem(); } else @@ -110,13 +109,12 @@ private void OnSearchInputChanged(string newText) _tree.QueueRedraw(); } - private static void ShowTree(TreeItem item) + private static void ShowEntireTree(TreeItem item) { item.Visible = true; - foreach (var child in item.GetChildren()) { - ShowTree(child); + ShowEntireTree(child); } } @@ -158,7 +156,7 @@ private void HideSearch() _searchInput.Hide(); RestoreTreeItemCollapsedStates(_rootItem); - ShowTree(_rootItem); + ShowEntireTree(_rootItem); ScrollToSelectedTreeItem(); } @@ -174,7 +172,7 @@ private void SaveTreeItemCollapsedStates(TreeItem item) private void RestoreTreeItemCollapsedStates(TreeItem item) { - // If the item is selected during the search then we want to keep it uncollapsed, otherwise we restore it to the state before the search. + // If an item was selected during the search then we want to keep it uncollapsed, otherwise we restore it to the state before the search. item.Collapsed = !HasSelectedChild(item) && _treeItemCollapsedStates.TryGetValue(item, out var collapsed) && collapsed; foreach (var child in item.GetChildren()) From 7a1fa25f8977b6349de5181b0e9a29dd0f1d56ea Mon Sep 17 00:00:00 2001 From: Manuel Gasser Date: Sun, 22 Mar 2026 13:00:58 +0100 Subject: [PATCH 4/4] Hide focus when showing search --- .../Features/SolutionExplorer/SolutionExplorerPanel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs index 303ea731..dac66a10 100644 --- a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs +++ b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs @@ -144,7 +144,7 @@ private void ShowSearch() _treeItemCollapsedStates.Clear(); SaveTreeItemCollapsedStates(_rootItem); - _searchInput.GrabFocus(); + _searchInput.GrabFocus(hideFocus: true); _searchInput.Show(); if (!string.IsNullOrWhiteSpace(_searchInput.Text)) FilterTree(_rootItem, _searchInput.Text);