CodeEditorPanel - Change font size via Ctrl+Scroll Wheel (#44)

Co-authored-by: Matt Parker <61717342+MattParkerDev@users.noreply.github.com>
This commit is contained in:
XiaoDong Ma
2025-12-18 08:05:42 +08:00
committed by GitHub
parent 74f9ebbc18
commit d66ecfc017
3 changed files with 229 additions and 186 deletions

View File

@@ -15,202 +15,233 @@ namespace SharpIDE.Godot.Features.CodeEditor;
public partial class CodeEditorPanel : MarginContainer public partial class CodeEditorPanel : MarginContainer
{ {
[Export] [Export]
public Texture2D CsFileTexture { get; set; } = null!; public Texture2D CsFileTexture { get; set; } = null!;
public SharpIdeSolutionModel Solution { get; set; } = null!; public SharpIdeSolutionModel Solution { get; set; } = null!;
private PackedScene _sharpIdeCodeEditScene = GD.Load<PackedScene>("res://Features/CodeEditor/SharpIdeCodeEdit.tscn"); private PackedScene _sharpIdeCodeEditScene = GD.Load<PackedScene>("res://Features/CodeEditor/SharpIdeCodeEdit.tscn");
private TabContainer _tabContainer = null!; private TabContainer _tabContainer = null!;
private ConcurrentDictionary<SharpIdeProjectModel, ExecutionStopInfo> _debuggerExecutionStopInfoByProject = []; private ConcurrentDictionary<SharpIdeProjectModel, ExecutionStopInfo> _debuggerExecutionStopInfoByProject = [];
[Inject] private readonly RunService _runService = null!; [Inject] private readonly RunService _runService = null!;
public override void _Ready() public override void _Ready()
{ {
_tabContainer = GetNode<TabContainer>("TabContainer"); _tabContainer = GetNode<TabContainer>("TabContainer");
_tabContainer.RemoveChildAndQueueFree(_tabContainer.GetChild(0)); // Remove the default tab _tabContainer.RemoveChildAndQueueFree(_tabContainer.GetChild(0)); // Remove the default tab
_tabContainer.TabClicked += OnTabClicked; _tabContainer.TabClicked += OnTabClicked;
var tabBar = _tabContainer.GetTabBar(); var tabBar = _tabContainer.GetTabBar();
tabBar.TabCloseDisplayPolicy = TabBar.CloseButtonDisplayPolicy.ShowAlways; tabBar.TabCloseDisplayPolicy = TabBar.CloseButtonDisplayPolicy.ShowAlways;
tabBar.TabClosePressed += OnTabClosePressed; tabBar.TabClosePressed += OnTabClosePressed;
GlobalEvents.Instance.DebuggerExecutionStopped.Subscribe(OnDebuggerExecutionStopped); GlobalEvents.Instance.DebuggerExecutionStopped.Subscribe(OnDebuggerExecutionStopped);
GlobalEvents.Instance.ProjectStoppedDebugging.Subscribe(OnProjectStoppedDebugging); GlobalEvents.Instance.ProjectStoppedDebugging.Subscribe(OnProjectStoppedDebugging);
} }
public override void _ExitTree() public override void _GuiInput(InputEvent @event)
{ {
var selectedTabIndex = _tabContainer.CurrentTab; if (Input.IsActionPressed(InputStringNames.EditorFontSizeIncrease))
var thisSolution = Singletons.AppState.RecentSlns.Single(s => s.FilePath == Solution.FilePath); {
thisSolution.IdeSolutionState.OpenTabs = _tabContainer.GetChildren().OfType<SharpIdeCodeEdit>() AdjustCodeEditorUiScale(true);
.Select((t, index) => new OpenTab }
{ else if (Input.IsActionPressed(InputStringNames.EditorFontSizeDecrease))
FilePath = t.SharpIdeFile.Path, {
CaretLine = t.GetCaretLine(), AdjustCodeEditorUiScale(false);
CaretColumn = t.GetCaretColumn(), }
IsSelected = index == selectedTabIndex }
})
.ToList();
}
public override void _UnhandledKeyInput(InputEvent @event) private void AdjustCodeEditorUiScale(bool increase)
{ {
if (@event.IsActionPressed(InputStringNames.StepOver)) const int minFontSize = 8;
{ const int maxFontSize = 72;
SendDebuggerStepCommand(DebuggerStepAction.StepOver);
}
else if (@event.IsActionPressed(InputStringNames.DebuggerStepOut))
{
SendDebuggerStepCommand(DebuggerStepAction.StepOut);
}
else if (@event.IsActionPressed(InputStringNames.DebuggerStepIn))
{
SendDebuggerStepCommand(DebuggerStepAction.StepIn);
}
else if (@event.IsActionPressed(InputStringNames.DebuggerContinue))
{
SendDebuggerStepCommand(DebuggerStepAction.Continue);
}
}
private void OnTabClicked(long tab) var editors = _tabContainer.GetChildren().OfType<SharpIdeCodeEdit>().ToList();
{ if (editors.Count is 0) return;
var sharpIdeCodeEdit = _tabContainer.GetChild<SharpIdeCodeEdit>((int)tab);
var sharpIdeFile = sharpIdeCodeEdit.SharpIdeFile;
var caretLinePosition = new SharpIdeFileLinePosition(sharpIdeCodeEdit.GetCaretLine(), sharpIdeCodeEdit.GetCaretColumn());
GodotGlobalEvents.Instance.FileExternallySelected.InvokeParallelFireAndForget(sharpIdeFile, caretLinePosition);
}
private void OnTabClosePressed(long tabIndex) var currentFontSize = editors.First().GetThemeFontSize(ThemeStringNames.FontSize);
{ var newFontSize = increase
var tab = _tabContainer.GetChild<Control>((int)tabIndex); ? Mathf.Clamp(currentFontSize + 2, minFontSize, maxFontSize)
var previousSibling = _tabContainer.GetChildOrNull<SharpIdeCodeEdit>((int)tabIndex - 1); : Mathf.Clamp(currentFontSize - 2, minFontSize, maxFontSize);
if (previousSibling is not null)
{
var sharpIdeFile = previousSibling.SharpIdeFile;
var caretLinePosition = new SharpIdeFileLinePosition(previousSibling.GetCaretLine(), previousSibling.GetCaretColumn());
// This isn't actually necessary - closing a tab automatically selects the previous tab, however we need to do it to select the file in sln explorer, record navigation event etc
GodotGlobalEvents.Instance.FileExternallySelected.InvokeParallelFireAndForget(sharpIdeFile, caretLinePosition);
}
_tabContainer.RemoveChild(tab);
tab.QueueFree();
}
public async Task SetSharpIdeFile(SharpIdeFile file, SharpIdeFileLinePosition? fileLinePosition) foreach (var editor in editors)
{ {
editor.AddThemeFontSizeOverride(ThemeStringNames.FontSize, newFontSize);
}
}
public override void _ExitTree()
{
var selectedTabIndex = _tabContainer.CurrentTab;
var thisSolution = Singletons.AppState.RecentSlns.Single(s => s.FilePath == Solution.FilePath);
thisSolution.IdeSolutionState.OpenTabs = _tabContainer.GetChildren().OfType<SharpIdeCodeEdit>()
.Select((t, index) => new OpenTab
{
FilePath = t.SharpIdeFile.Path,
CaretLine = t.GetCaretLine(),
CaretColumn = t.GetCaretColumn(),
IsSelected = index == selectedTabIndex
})
.ToList();
}
public override void _UnhandledKeyInput(InputEvent @event)
{
if (@event.IsActionPressed(InputStringNames.StepOver))
{
SendDebuggerStepCommand(DebuggerStepAction.StepOver);
}
else if (@event.IsActionPressed(InputStringNames.DebuggerStepOut))
{
SendDebuggerStepCommand(DebuggerStepAction.StepOut);
}
else if (@event.IsActionPressed(InputStringNames.DebuggerStepIn))
{
SendDebuggerStepCommand(DebuggerStepAction.StepIn);
}
else if (@event.IsActionPressed(InputStringNames.DebuggerContinue))
{
SendDebuggerStepCommand(DebuggerStepAction.Continue);
}
}
private void OnTabClicked(long tab)
{
var sharpIdeCodeEdit = _tabContainer.GetChild<SharpIdeCodeEdit>((int)tab);
var sharpIdeFile = sharpIdeCodeEdit.SharpIdeFile;
var caretLinePosition = new SharpIdeFileLinePosition(sharpIdeCodeEdit.GetCaretLine(), sharpIdeCodeEdit.GetCaretColumn());
GodotGlobalEvents.Instance.FileExternallySelected.InvokeParallelFireAndForget(sharpIdeFile, caretLinePosition);
}
private void OnTabClosePressed(long tabIndex)
{
var tab = _tabContainer.GetChild<Control>((int)tabIndex);
var previousSibling = _tabContainer.GetChildOrNull<SharpIdeCodeEdit>((int)tabIndex - 1);
if (previousSibling is not null)
{
var sharpIdeFile = previousSibling.SharpIdeFile;
var caretLinePosition = new SharpIdeFileLinePosition(previousSibling.GetCaretLine(), previousSibling.GetCaretColumn());
// This isn't actually necessary - closing a tab automatically selects the previous tab, however we need to do it to select the file in sln explorer, record navigation event etc
GodotGlobalEvents.Instance.FileExternallySelected.InvokeParallelFireAndForget(sharpIdeFile, caretLinePosition);
}
_tabContainer.RemoveChild(tab);
tab.QueueFree();
}
public async Task SetSharpIdeFile(SharpIdeFile file, SharpIdeFileLinePosition? fileLinePosition)
{
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
var existingTab = await this.InvokeAsync(() => _tabContainer.GetChildren().OfType<SharpIdeCodeEdit>().FirstOrDefault(t => t.SharpIdeFile == file)); var existingTab = await this.InvokeAsync(() => _tabContainer.GetChildren().OfType<SharpIdeCodeEdit>().FirstOrDefault(t => t.SharpIdeFile == file));
if (existingTab is not null) if (existingTab is not null)
{ {
var existingTabIndex = existingTab.GetIndex(); var existingTabIndex = existingTab.GetIndex();
await this.InvokeAsync(() => await this.InvokeAsync(() =>
{ {
_tabContainer.CurrentTab = existingTabIndex; _tabContainer.CurrentTab = existingTabIndex;
if (fileLinePosition is not null) existingTab.SetFileLinePosition(fileLinePosition.Value); if (fileLinePosition is not null) existingTab.SetFileLinePosition(fileLinePosition.Value);
}); });
return; return;
} }
var newTab = _sharpIdeCodeEditScene.Instantiate<SharpIdeCodeEdit>(); var newTab = _sharpIdeCodeEditScene.Instantiate<SharpIdeCodeEdit>();
newTab.Solution = Solution; newTab.Solution = Solution;
await this.InvokeAsync(() => await this.InvokeAsync(() =>
{ {
_tabContainer.AddChild(newTab); _tabContainer.AddChild(newTab);
var newTabIndex = _tabContainer.GetTabCount() - 1; var newTabIndex = _tabContainer.GetTabCount() - 1;
_tabContainer.SetIconsForFileExtension(file, newTabIndex); _tabContainer.SetIconsForFileExtension(file, newTabIndex);
_tabContainer.SetTabTitle(newTabIndex, file.Name); _tabContainer.SetTabTitle(newTabIndex, file.Name);
_tabContainer.SetTabTooltip(newTabIndex, file.Path); _tabContainer.SetTabTooltip(newTabIndex, file.Path);
_tabContainer.CurrentTab = newTabIndex; _tabContainer.CurrentTab = newTabIndex;
file.IsDirty.Skip(1).SubscribeOnThreadPool().ObserveOnThreadPool().SubscribeAwait(async (isDirty, ct) => file.IsDirty.Skip(1).SubscribeOnThreadPool().ObserveOnThreadPool().SubscribeAwait(async (isDirty, ct) =>
{ {
//GD.Print($"File dirty state changed: {file.Path} is now {(isDirty ? "dirty" : "clean")}"); //GD.Print($"File dirty state changed: {file.Path} is now {(isDirty ? "dirty" : "clean")}");
await this.InvokeAsync(() => await this.InvokeAsync(() =>
{ {
var tabIndex = newTab.GetIndex(); var tabIndex = newTab.GetIndex();
var title = file.Name + (isDirty ? " (*)" : ""); var title = file.Name + (isDirty ? " (*)" : "");
_tabContainer.SetTabTitle(tabIndex, title); _tabContainer.SetTabTitle(tabIndex, title);
}); });
}).AddTo(newTab); // needs to be on ui thread }).AddTo(newTab); // needs to be on ui thread
}); });
await newTab.SetSharpIdeFile(file, fileLinePosition); await newTab.SetSharpIdeFile(file, fileLinePosition);
} }
private static readonly Color ExecutingLineColor = new Color("665001"); private static readonly Color ExecutingLineColor = new Color("665001");
private async Task OnDebuggerExecutionStopped(ExecutionStopInfo executionStopInfo) private async Task OnDebuggerExecutionStopped(ExecutionStopInfo executionStopInfo)
{ {
Guard.Against.Null(Solution, nameof(Solution)); Guard.Against.Null(Solution, nameof(Solution));
var currentSharpIdeFile = await this.InvokeAsync<SharpIdeFile>(() => _tabContainer.GetChild<SharpIdeCodeEdit>(_tabContainer.CurrentTab).SharpIdeFile); var currentSharpIdeFile = await this.InvokeAsync<SharpIdeFile>(() => _tabContainer.GetChild<SharpIdeCodeEdit>(_tabContainer.CurrentTab).SharpIdeFile);
if (executionStopInfo.FilePath != currentSharpIdeFile?.Path) if (executionStopInfo.FilePath != currentSharpIdeFile?.Path)
{ {
var file = Solution.AllFiles[executionStopInfo.FilePath]; var file = Solution.AllFiles[executionStopInfo.FilePath];
await GodotGlobalEvents.Instance.FileExternallySelected.InvokeParallelAsync(file, null).ConfigureAwait(false); await GodotGlobalEvents.Instance.FileExternallySelected.InvokeParallelAsync(file, null).ConfigureAwait(false);
} }
var lineInt = executionStopInfo.Line - 1; // Debugging is 1-indexed, Godot is 0-indexed var lineInt = executionStopInfo.Line - 1; // Debugging is 1-indexed, Godot is 0-indexed
Guard.Against.Negative(lineInt, nameof(lineInt)); Guard.Against.Negative(lineInt, nameof(lineInt));
if (_debuggerExecutionStopInfoByProject.TryGetValue(executionStopInfo.Project, out _)) throw new InvalidOperationException("Debugger is already stopped for this project."); if (_debuggerExecutionStopInfoByProject.TryGetValue(executionStopInfo.Project, out _)) throw new InvalidOperationException("Debugger is already stopped for this project.");
_debuggerExecutionStopInfoByProject[executionStopInfo.Project] = executionStopInfo; _debuggerExecutionStopInfoByProject[executionStopInfo.Project] = executionStopInfo;
await this.InvokeAsync(() => await this.InvokeAsync(() =>
{ {
var tabForStopInfo = _tabContainer.GetChildren().OfType<SharpIdeCodeEdit>().Single(t => t.SharpIdeFile.Path == executionStopInfo.FilePath); var tabForStopInfo = _tabContainer.GetChildren().OfType<SharpIdeCodeEdit>().Single(t => t.SharpIdeFile.Path == executionStopInfo.FilePath);
tabForStopInfo.SetLineBackgroundColor(lineInt, ExecutingLineColor); tabForStopInfo.SetLineBackgroundColor(lineInt, ExecutingLineColor);
tabForStopInfo.SetLineAsExecuting(lineInt, true); tabForStopInfo.SetLineAsExecuting(lineInt, true);
}); });
} }
private enum DebuggerStepAction { StepOver, StepIn, StepOut, Continue } private enum DebuggerStepAction { StepOver, StepIn, StepOut, Continue }
[RequiresGodotUiThread] [RequiresGodotUiThread]
private void SendDebuggerStepCommand(DebuggerStepAction debuggerStepAction) private void SendDebuggerStepCommand(DebuggerStepAction debuggerStepAction)
{ {
// TODO: Debugging needs a rework - debugging commands should be scoped to a debug session, ie the debug panel sub-tabs // TODO: Debugging needs a rework - debugging commands should be scoped to a debug session, ie the debug panel sub-tabs
// For now, just use the first project that is currently stopped // For now, just use the first project that is currently stopped
var stoppedProjects = _debuggerExecutionStopInfoByProject.Keys.ToList(); var stoppedProjects = _debuggerExecutionStopInfoByProject.Keys.ToList();
if (stoppedProjects.Count == 0) return; // ie not currently stopped anywhere if (stoppedProjects.Count == 0) return; // ie not currently stopped anywhere
var project = stoppedProjects[0]; var project = stoppedProjects[0];
if (!_debuggerExecutionStopInfoByProject.TryRemove(project, out var executionStopInfo)) return; if (!_debuggerExecutionStopInfoByProject.TryRemove(project, out var executionStopInfo)) return;
var godotLine = executionStopInfo.Line - 1; var godotLine = executionStopInfo.Line - 1;
var tabForStopInfo = _tabContainer.GetChildren().OfType<SharpIdeCodeEdit>().Single(t => t.SharpIdeFile.Path == executionStopInfo.FilePath); var tabForStopInfo = _tabContainer.GetChildren().OfType<SharpIdeCodeEdit>().Single(t => t.SharpIdeFile.Path == executionStopInfo.FilePath);
tabForStopInfo.SetLineAsExecuting(godotLine, false); tabForStopInfo.SetLineAsExecuting(godotLine, false);
tabForStopInfo.SetLineColour(godotLine); tabForStopInfo.SetLineColour(godotLine);
var threadId = executionStopInfo.ThreadId; var threadId = executionStopInfo.ThreadId;
_ = Task.GodotRun(async () => _ = Task.GodotRun(async () =>
{ {
var task = debuggerStepAction switch var task = debuggerStepAction switch
{ {
DebuggerStepAction.StepOver => _runService.SendDebuggerStepOver(threadId), DebuggerStepAction.StepOver => _runService.SendDebuggerStepOver(threadId),
DebuggerStepAction.StepIn => _runService.SendDebuggerStepInto(threadId), DebuggerStepAction.StepIn => _runService.SendDebuggerStepInto(threadId),
DebuggerStepAction.StepOut => _runService.SendDebuggerStepOut(threadId), DebuggerStepAction.StepOut => _runService.SendDebuggerStepOut(threadId),
DebuggerStepAction.Continue => _runService.SendDebuggerContinue(threadId), DebuggerStepAction.Continue => _runService.SendDebuggerContinue(threadId),
_ => throw new ArgumentOutOfRangeException(nameof(debuggerStepAction), debuggerStepAction, null) _ => throw new ArgumentOutOfRangeException(nameof(debuggerStepAction), debuggerStepAction, null)
}; };
await task; await task;
}); });
} }
private async Task OnProjectStoppedDebugging(SharpIdeProjectModel project) private async Task OnProjectStoppedDebugging(SharpIdeProjectModel project)
{ {
if (!_debuggerExecutionStopInfoByProject.TryRemove(project, out var executionStopInfo)) return; if (!_debuggerExecutionStopInfoByProject.TryRemove(project, out var executionStopInfo)) return;
await this.InvokeAsync(() => await this.InvokeAsync(() =>
{ {
var godotLine = executionStopInfo.Line - 1; var godotLine = executionStopInfo.Line - 1;
var tabForStopInfo = _tabContainer.GetChildren().OfType<SharpIdeCodeEdit>().Single(t => t.SharpIdeFile.Path == executionStopInfo.FilePath); var tabForStopInfo = _tabContainer.GetChildren().OfType<SharpIdeCodeEdit>().Single(t => t.SharpIdeFile.Path == executionStopInfo.FilePath);
tabForStopInfo.SetLineAsExecuting(godotLine, false); tabForStopInfo.SetLineAsExecuting(godotLine, false);
tabForStopInfo.SetLineColour(godotLine); tabForStopInfo.SetLineColour(godotLine);
}); });
} }
} }
file static class TabContainerExtensions file static class TabContainerExtensions
{ {
extension(TabContainer tabContainer) extension(TabContainer tabContainer)
{ {
public void SetIconsForFileExtension(SharpIdeFile file, int newTabIndex) public void SetIconsForFileExtension(SharpIdeFile file, int newTabIndex)
{ {
var (icon, overlayIcon) = FileIconHelper.GetIconForFileExtension(file.Extension); var (icon, overlayIcon) = FileIconHelper.GetIconForFileExtension(file.Extension);
tabContainer.SetTabIcon(newTabIndex, icon); tabContainer.SetTabIcon(newTabIndex, icon);
// Unfortunately TabContainer doesn't have a SetTabIconOverlay method // Unfortunately TabContainer doesn't have a SetTabIconOverlay method
//tabContainer.SetIconOverlay(0, overlayIcon); //tabContainer.SetIconOverlay(0, overlayIcon);
} }
} }
} }

View File

@@ -14,6 +14,8 @@ public static class InputStringNames
public static readonly StringName FindFiles = nameof(FindFiles); public static readonly StringName FindFiles = nameof(FindFiles);
public static readonly StringName SaveFile = nameof(SaveFile); public static readonly StringName SaveFile = nameof(SaveFile);
public static readonly StringName SaveAllFiles = nameof(SaveAllFiles); public static readonly StringName SaveAllFiles = nameof(SaveAllFiles);
public static readonly StringName EditorFontSizeIncrease = nameof(EditorFontSizeIncrease);
public static readonly StringName EditorFontSizeDecrease = nameof(EditorFontSizeDecrease);
} }
public static class ThemeStringNames public static class ThemeStringNames

View File

@@ -88,6 +88,16 @@ DebuggerStepOut={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194342,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":true,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194342,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
] ]
} }
EditorFontSizeIncrease={
"deadzone": 0.2,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":true,"meta_pressed":false,"button_mask":8,"position":Vector2(96, 15),"global_position":Vector2(105, 63),"factor":1.0,"button_index":4,"canceled":false,"pressed":true,"double_click":false,"script":null)
]
}
EditorFontSizeDecrease={
"deadzone": 0.2,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":true,"meta_pressed":false,"button_mask":16,"position":Vector2(120, 12),"global_position":Vector2(129, 60),"factor":1.0,"button_index":5,"canceled":false,"pressed":true,"double_click":false,"script":null)
]
}
[rendering] [rendering]