diff --git a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs index 1387e2b1..dac66a10 100644 --- a/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs +++ b/src/SharpIDE.Godot/Features/SolutionExplorer/SolutionExplorerPanel.cs @@ -11,7 +11,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; @@ -31,20 +30,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,7 +80,114 @@ 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); + ShowEntireTree(_rootItem); + ScrollToSelectedTreeItem(); + } + else + { + FilterTree(_rootItem, newText); } + + _tree.QueueRedraw(); + } + + private static void ShowEntireTree(TreeItem item) + { + item.Visible = true; + foreach (var child in item.GetChildren()) + { + ShowEntireTree(child); + } + } + + private static bool FilterTree(TreeItem item, string searchText) + { + var itemText = item.GetText(0); + var isMatch = itemText.Contains(searchText, StringComparison.OrdinalIgnoreCase); + + var hasMatchingChild = false; + foreach (var child in item.GetChildren()) + { + if (FilterTree(child, searchText)) + hasMatchingChild = true; + } + + item.Visible = isMatch || hasMatchingChild; + item.Collapsed = !hasMatchingChild; + + return isMatch || hasMatchingChild; + } + + private bool IsSearchActive() => _searchInput.IsVisible(); + + private void ShowSearch() + { + if (IsSearchActive()) return; + + _treeItemCollapsedStates.Clear(); + SaveTreeItemCollapsedStates(_rootItem); + _searchInput.GrabFocus(hideFocus: true); + _searchInput.Show(); + if (!string.IsNullOrWhiteSpace(_searchInput.Text)) + FilterTree(_rootItem, _searchInput.Text); + } + + private void HideSearch() + { + if (!IsSearchActive()) return; + + _searchInput.Hide(); + RestoreTreeItemCollapsedStates(_rootItem); + ShowEntireTree(_rootItem); + ScrollToSelectedTreeItem(); + } + + private void SaveTreeItemCollapsedStates(TreeItem item) + { + _treeItemCollapsedStates[item] = item.Collapsed; + + foreach (var child in item.GetChildren()) + { + SaveTreeItemCollapsedStates(child); + } + } + + private void RestoreTreeItemCollapsedStates(TreeItem item) + { + // 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()) + { + RestoreTreeItemCollapsedStates(child); + } + } + + 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) @@ -160,7 +273,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 +308,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 (!IsSearchActive() || 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 +382,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 +437,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 +487,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]