diff --git a/Directory.Build.props b/Directory.Build.props index 29b02b1..ba0c657 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,7 +13,7 @@ Copyright (c) gui-cs and contributors - 2.1.0-rc.* + 2.1.1-develop.* 2.1.1-develop.* diff --git a/src/Clet/Clets/Viewer/EditorClet.cs b/src/Clet/Clets/Viewer/EditorClet.cs index dcd7b38..e473f93 100644 --- a/src/Clet/Clets/Viewer/EditorClet.cs +++ b/src/Clet/Clets/Viewer/EditorClet.cs @@ -1,12 +1,16 @@ using System.Collections.ObjectModel; using Terminal.Gui.App; -using Terminal.Gui.Drawing; -using Terminal.Gui.Input; +using Terminal.Gui.Configuration; using Terminal.Gui.Document; +using Terminal.Gui.Document.Folding; +using Terminal.Gui.Drawing; using Terminal.Gui.Editor; using Terminal.Gui.Highlighting; +using Terminal.Gui.Input; +using Terminal.Gui.Text.Indentation; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; +using TextMateSharp.Grammars; using Command = Terminal.Gui.Input.Command; namespace Clet; @@ -62,12 +66,8 @@ public async Task RunAsync ( }; } - // Preserve explicit (non-glob) paths for files that don't exist yet. - // ExpandFiles skips missing files with a warning; for edit we want to - // open a new empty buffer bound to the given path. foreach (string arg in args) { - // Skip glob patterns (only * and ? are wildcards — matching ExpandFiles / Directory.GetFiles semantics). if (arg.Contains ('*') || arg.Contains ('?')) { continue; @@ -100,26 +100,271 @@ public async Task RunAsync ( BorderStyle = LineStyle.None, }; + // --- Settings are loaded by ConfigurationManager via [ConfigurationProperty] --- + Editor editor = new () { X = 0, - Y = 1, // below MenuBar + Y = 1, Width = Dim.Fill (), - Height = Dim.Fill (1), // above StatusBar + Height = Dim.Fill (1), ReadOnly = readOnly, - GutterOptions = GutterOptions.LineNumbers, - ConvertTabsToSpaces = true, + ConvertTabsToSpaces = EditorSettings.ConvertTabsToSpaces, + IndentationSize = EditorSettings.IndentSize, + WordWrap = EditorSettings.WordWrap, + ShowTabs = EditorSettings.ShowTabs, + UseThemeBackground = EditorSettings.UseThemeBackground, + ViewportSettings = ViewportSettingsFlags.HasScrollBars, }; + // Apply gutter options from settings + GutterOptions initGutter = GutterOptions.None; + + if (EditorSettings.LineNumbers) + { + initGutter |= GutterOptions.LineNumbers; + } + + if (EditorSettings.FoldIndicators) + { + initGutter |= GutterOptions.Folding; + } + + editor.GutterOptions = initGutter; + + if (EditorSettings.AutoIndent) + { + editor.IndentationStrategy = new DefaultIndentationStrategy (); + } + editor.HighlightingDefinition = filePath is not null ? HighlightingManager.Instance.GetDefinitionByExtension (Path.GetExtension (filePath)) : null; + // --- Folding support --- + + BraceFoldingStrategy braceFoldingStrategy = new (); + + void InstallFolding () + { + if (editor.Document is null) + { + return; + } + + FoldingManager fm = new (editor.Document); + braceFoldingStrategy.UpdateFoldings (fm, editor.Document); + editor.FoldingManager = fm; + + editor.Document.Changed += (_, _) => + { + if (editor.FoldingManager is not null && editor.Document is not null) + { + braceFoldingStrategy.UpdateFoldings (editor.FoldingManager, editor.Document); + } + }; + } + + // --- Markdown preview --- + + Markdown? markdownPreview = null; + bool syncingScroll = false; + bool isMarkdownFile = filePath is not null + && Path.GetExtension (filePath).Equals (".md", StringComparison.OrdinalIgnoreCase); + + // View-menu toggle items — declared early so preview toggle can reference them. + MenuItem previewMarkdownItem = new () { Title = " _Preview Markdown", Enabled = isMarkdownFile }; + + bool optUseThemeBg = EditorSettings.UseThemeBackground; + + void OnEditorViewportChanged (object? sender, DrawEventArgs e) + { + if (markdownPreview is null || syncingScroll) + { + return; + } + + syncingScroll = true; + + try + { + int editorContentHeight = editor.GetContentSize ().Height; + int editorViewportHeight = editor.Viewport.Height; + int maxEditorY = Math.Max (0, editorContentHeight - editorViewportHeight); + int editorY = editor.Viewport.Y; + + int previewContentHeight = markdownPreview.GetContentSize ().Height; + int previewViewportHeight = markdownPreview.Viewport.Height; + int maxPreviewY = Math.Max (0, previewContentHeight - previewViewportHeight); + + int newY = maxEditorY > 0 + ? (int)((long)editorY * maxPreviewY / maxEditorY) + : 0; + + markdownPreview.Viewport = markdownPreview.Viewport with { Y = Math.Clamp (newY, 0, maxPreviewY) }; + } + finally + { + syncingScroll = false; + } + } + + void OnPreviewViewportChanged (object? sender, DrawEventArgs e) + { + if (markdownPreview is null || syncingScroll) + { + return; + } + + syncingScroll = true; + + try + { + int previewContentHeight = markdownPreview.GetContentSize ().Height; + int previewViewportHeight = markdownPreview.Viewport.Height; + int maxPreviewY = Math.Max (0, previewContentHeight - previewViewportHeight); + int previewY = markdownPreview.Viewport.Y; + + int editorContentHeight = editor.GetContentSize ().Height; + int editorViewportHeight = editor.Viewport.Height; + int maxEditorY = Math.Max (0, editorContentHeight - editorViewportHeight); + + int newY = maxPreviewY > 0 + ? (int)((long)previewY * maxEditorY / maxPreviewY) + : 0; + + editor.Viewport = editor.Viewport with { Y = Math.Clamp (newY, 0, maxEditorY) }; + } + finally + { + syncingScroll = false; + } + } + + void OnDocumentChangedForPreview (object? sender, EventArgs e) + { + if (markdownPreview is null) + { + return; + } + + markdownPreview.Text = editor.Document?.Text ?? string.Empty; + } + + void ShowMarkdownPreview () + { + if (markdownPreview is not null) + { + return; + } + + markdownPreview = new Markdown () + { + X = Pos.Right (editor), + Y = editor.Y, + Width = Dim.Fill (), + Height = editor.Height, + Text = editor.Document?.Text ?? string.Empty, + ViewportSettings = ViewportSettingsFlags.HasScrollBars, + SyntaxHighlighter = new TextMateSyntaxHighlighter (ThemeName.DarkPlus), + UseThemeBackground = optUseThemeBg, + }; + + editor.Width = Dim.Percent (50); + window.Add (markdownPreview); + + // Sync scrolling bidirectionally. + editor.ViewportChanged += OnEditorViewportChanged; + markdownPreview.ViewportChanged += OnPreviewViewportChanged; + + // Update preview when document content changes. + if (editor.Document is not null) + { + editor.Document.Changed += OnDocumentChangedForPreview; + } + } + + void HideMarkdownPreview () + { + if (markdownPreview is null) + { + return; + } + + editor.ViewportChanged -= OnEditorViewportChanged; + markdownPreview.ViewportChanged -= OnPreviewViewportChanged; + + if (editor.Document is not null) + { + editor.Document.Changed -= OnDocumentChangedForPreview; + } + + window.Remove (markdownPreview); + markdownPreview.Dispose (); + markdownPreview = null; + + editor.Width = Dim.Fill (); + } + + void RefreshPreviewDocument () + { + if (markdownPreview is null) + { + return; + } + + if (editor.Document is not null) + { + editor.Document.Changed -= OnDocumentChangedForPreview; + editor.Document.Changed += OnDocumentChangedForPreview; + } + + markdownPreview.Text = editor.Document?.Text ?? string.Empty; + } + + void ToggleMarkdownPreview () + { + if (previewMarkdownItem.Title?.StartsWith ("✓") == true) + { + HideMarkdownPreview (); + previewMarkdownItem.Title = " _Preview Markdown"; + } + else + { + ShowMarkdownPreview (); + previewMarkdownItem.Title = "✓ _Preview Markdown"; + } + } + + void UpdatePreviewEnabled () + { + previewMarkdownItem.Enabled = isMarkdownFile; + + if (!isMarkdownFile && markdownPreview is not null) + { + HideMarkdownPreview (); + previewMarkdownItem.Title = " _Preview Markdown"; + } + else if (isMarkdownFile && markdownPreview is not null) + { + RefreshPreviewDocument (); + } + } + // --- StatusBar shortcuts (declared early for capture) --- Shortcut cursorPositionShortcut = new () { Title = "Ln 1, Col 1", MouseHighlightStates = MouseState.None, Enabled = false }; - Shortcut modifiedShortcut = new () { Title = "", MouseHighlightStates = MouseState.None, Enabled = false }; + Shortcut languageShortcut = new () + { Title = "Plain Text", MouseHighlightStates = MouseState.None, Enabled = false }; + + // Filename shortcut for MenuBar — full path, dialog scheme + Shortcut filenameShortcut = new () + { + Title = filePath ?? "", + MouseHighlightStates = MouseState.None, + SchemeName = SchemeManager.SchemesToSchemeName (Schemes.Dialog), + }; // --- Local state helpers --- @@ -128,13 +373,18 @@ public async Task RunAsync ( void UpdateModifiedIndicator () { bool dirty = UnsavedChanges (); - modifiedShortcut.Title = dirty ? "Modified" : ""; window.Title = dirty ? $"{fileName ?? "Untitled"}*" : fileName ?? "Untitled"; } + void UpdateLanguageShortcut () + { + languageShortcut.Title = editor.HighlightingDefinition?.Name ?? "Plain Text"; + } + void UpdateSyntaxLanguage (string path) { editor.HighlightingDefinition = HighlightingManager.Instance.GetDefinitionByExtension (Path.GetExtension (path)); + UpdateLanguageShortcut (); } void UpdateLocShortcut () @@ -144,12 +394,19 @@ void UpdateLocShortcut () if (document is null) { cursorPositionShortcut.Title = "Ln 1, Col 1"; + + return; } - else + + DocumentLine line = document.GetLineByOffset (editor.CaretOffset); + string loc = $"Ln {line.LineNumber}, Col {editor.CaretOffset - line.Offset + 1}"; + + if (editor.HasMultipleCarets) { - DocumentLine line = document.GetLineByOffset (editor.CaretOffset); - cursorPositionShortcut.Title = $"Ln {line.LineNumber}, Col {editor.CaretOffset - line.Offset + 1}"; + loc += $" ({editor.AdditionalCaretOffsets.Count + 1} carets)"; } + + cursorPositionShortcut.Title = loc; } // --- File operations --- @@ -166,7 +423,11 @@ void LoadFile (string path) savedText = string.Empty; editor.Document = new TextDocument (); UpdateSyntaxLanguage (fullPath); + InstallFolding (); UpdateModifiedIndicator (); + filenameShortcut.Title = fullPath; + isMarkdownFile = Path.GetExtension (fullPath).Equals (".md", StringComparison.OrdinalIgnoreCase); + UpdatePreviewEnabled (); return; } @@ -180,7 +441,11 @@ void LoadFile (string path) editor.Document = new TextDocument (text); editor.CaretOffset = 0; UpdateSyntaxLanguage (fullPath); + InstallFolding (); UpdateModifiedIndicator (); + filenameShortcut.Title = fullPath; + isMarkdownFile = Path.GetExtension (fullPath).Equals (".md", StringComparison.OrdinalIgnoreCase); + UpdatePreviewEnabled (); } bool SaveFile () @@ -254,15 +519,15 @@ bool PromptSaveIfDirty () if (result is null or 0) { - return false; // Cancel + return false; } if (result == 2) { - return SaveFile (); // Yes + return SaveFile (); } - return true; // No — discard + return true; } void NewFile () @@ -278,7 +543,13 @@ void NewFile () editor.ClearSelection (); editor.Document = new TextDocument (); editor.CaretOffset = 0; + editor.HighlightingDefinition = null; + InstallFolding (); UpdateModifiedIndicator (); + UpdateLanguageShortcut (); + filenameShortcut.Title = ""; + isMarkdownFile = false; + UpdatePreviewEnabled (); } void OpenFile () @@ -324,7 +595,7 @@ void QuitEditor () window.RequestStop (); } - // --- Clipboard helpers (Editor doesn't have built-in clipboard commands) --- + // --- Clipboard helpers --- void Paste () { @@ -371,9 +642,111 @@ void Cut () editor.ReplaceSelection (string.Empty); } + // --- Find/Replace --- + + void ShowFindReplace (bool showReplace = false) + { + FindReplaceDialog dlg = new (editor, showReplace); + app.Run (dlg); + dlg.Dispose (); + } + + // --- Edit menu items (reusable for context menu) --- + + MenuItem[] CreateEditMenuItems () => + [ + new () { Title = "_Undo", Key = Key.Z.WithCtrl, Action = () => editor.Document?.UndoStack.Undo () }, + new () { Title = "_Redo", Key = Key.Y.WithCtrl, Action = () => editor.Document?.UndoStack.Redo () }, + null!, + new () { Title = "Cu_t", Key = Key.X.WithCtrl, Action = Cut }, + new () { Title = "_Copy", Key = Key.C.WithCtrl, Action = Copy }, + new () { Title = "_Paste", Key = Key.V.WithCtrl, Action = Paste }, + null!, + new () { Title = "Select _All", Key = Key.A.WithCtrl, Action = () => editor.SelectAll () }, + ]; + + // --- About dialog --- + + void ShowAbout () + { + string editorVersion = VersionInfo.GetAssemblyVersion ( + typeof (Editor).Assembly, "unknown"); + + Dialog about = new () + { + Title = "About clet edit", + Width = Dim.Percent (50), + Height = 12, + }; + + Label info = new () + { + X = 1, + Y = 0, + Width = Dim.Fill (1), + Text = $""" + clet {VersionInfo.GetCletVersion ()} + Terminal.Gui {VersionInfo.GetTerminalGuiVersion ()} + Terminal.Gui.Editor {editorVersion} + + https://github.com/gui-cs/clet + """, + }; + + Button ok = new () { Text = "OK", X = Pos.Center (), Y = Pos.Bottom (info) + 1, IsDefault = true }; + ok.Accepting += (_, _) => about.RequestStop (); + about.Add (info, ok); + app.Run (about); + about.Dispose (); + } + + // --- Settings dialog --- + + void ShowSettings () + { + EditorSettingsDialog dlg = new (editor); + app.Run (dlg); + + if (dlg.WasAccepted) + { + dlg.ApplyTo (editor); + SaveViewSettings (); + } + + dlg.Dispose (); + } + + // --- View menu toggle state --- + + bool optLineNumbers = EditorSettings.LineNumbers; + bool optFoldIndicators = EditorSettings.FoldIndicators; + bool optWordWrap = EditorSettings.WordWrap; + bool optShowTabs = EditorSettings.ShowTabs; + + void UpdateGutterOptions () + { + GutterOptions g = GutterOptions.None; + + if (optLineNumbers) + { + g |= GutterOptions.LineNumbers; + } + + if (optFoldIndicators) + { + g |= GutterOptions.Folding; + } + + editor.GutterOptions = g; + } + + string ToggleTitle (bool on, string label) => on ? $"✓ {label}" : $" {label}"; + // --- MenuBar --- - MenuBar menu = new (); + MenuBar menu = new () { AlignmentModes = AlignmentModes.IgnoreFirstOrLast }; + + filenameShortcut.Accepting += (_, _) => OpenFile (); menu.Add (new MenuBarItem ("_File", [ @@ -381,22 +754,141 @@ void Cut () new MenuItem { Title = "_Open", Key = Key.O.WithCtrl, Action = OpenFile }, new MenuItem { Title = "_Save", Key = Key.S.WithCtrl, Action = () => SaveFile () }, new MenuItem { Title = "Save _As", Action = () => SaveAs () }, - null!, // separator + null!, new MenuItem { Title = "_Quit", Key = Key.Q.WithCtrl, Action = QuitEditor }, ])); menu.Add (new MenuBarItem ("_Edit", [ - new MenuItem { Title = "_Undo", Key = Key.Z.WithCtrl, Action = () => editor.Document?.UndoStack.Undo () }, - new MenuItem { Title = "_Redo", Key = Key.Y.WithCtrl, Action = () => editor.Document?.UndoStack.Redo () }, - null!, // separator - new MenuItem { Title = "Cu_t", Key = Key.X.WithCtrl, Action = Cut }, - new MenuItem { Title = "_Copy", Key = Key.C.WithCtrl, Action = Copy }, - new MenuItem { Title = "_Paste", Key = Key.V.WithCtrl, Action = Paste }, - null!, // separator - new MenuItem { Title = "Select _All", Key = Key.A.WithCtrl, Action = () => editor.SelectAll () }, + new MenuItem { Title = "_Find...", Key = Key.F.WithCtrl, Action = () => ShowFindReplace () }, + new MenuItem { Title = "_Replace...", Key = Key.H.WithCtrl, Action = () => ShowFindReplace (true) }, + null!, + .. CreateEditMenuItems (), + ])); + + // --- View menu --- + + MenuItem viewLineNumbersItem = new () { Title = ToggleTitle (optLineNumbers, "_Line Numbers") }; + MenuItem viewFoldIndicatorsItem = new () { Title = ToggleTitle (optFoldIndicators, "_Fold Indicators") }; + MenuItem viewWordWrapItem = new () { Title = ToggleTitle (optWordWrap, "_Word Wrap") }; + MenuItem viewShowTabsItem = new () { Title = ToggleTitle (optShowTabs, "Show _Tabs") }; + MenuItem viewUseThemeBgItem = new () { Title = ToggleTitle (optUseThemeBg, "Use _Theme Background") }; + + void SaveViewSettings () + { + EditorSettings.LineNumbers = optLineNumbers; + EditorSettings.FoldIndicators = optFoldIndicators; + EditorSettings.WordWrap = optWordWrap; + EditorSettings.ShowTabs = optShowTabs; + EditorSettings.UseThemeBackground = optUseThemeBg; + EditorSettings.IndentSize = editor.IndentationSize; + EditorSettings.ConvertTabsToSpaces = editor.ConvertTabsToSpaces; + EditorSettings.AutoIndent = editor.IndentationStrategy is not null; + EditorSettings.Save (); + } + + viewLineNumbersItem.Action = () => + { + optLineNumbers = !optLineNumbers; + viewLineNumbersItem.Title = ToggleTitle (optLineNumbers, "_Line Numbers"); + UpdateGutterOptions (); + SaveViewSettings (); + }; + + viewFoldIndicatorsItem.Action = () => + { + optFoldIndicators = !optFoldIndicators; + viewFoldIndicatorsItem.Title = ToggleTitle (optFoldIndicators, "_Fold Indicators"); + UpdateGutterOptions (); + SaveViewSettings (); + }; + + viewWordWrapItem.Action = () => + { + optWordWrap = !optWordWrap; + viewWordWrapItem.Title = ToggleTitle (optWordWrap, "_Word Wrap"); + editor.WordWrap = optWordWrap; + SaveViewSettings (); + }; + + viewShowTabsItem.Action = () => + { + optShowTabs = !optShowTabs; + viewShowTabsItem.Title = ToggleTitle (optShowTabs, "Show _Tabs"); + editor.ShowTabs = optShowTabs; + SaveViewSettings (); + }; + + viewUseThemeBgItem.Action = () => + { + optUseThemeBg = !optUseThemeBg; + viewUseThemeBgItem.Title = ToggleTitle (optUseThemeBg, "Use _Theme Background"); + editor.UseThemeBackground = optUseThemeBg; + + if (markdownPreview is not null) + { + markdownPreview.UseThemeBackground = optUseThemeBg; + } + + SaveViewSettings (); + }; + + previewMarkdownItem.Action = () => + { + if (isMarkdownFile) + { + ToggleMarkdownPreview (); + } + }; + + menu.Add (new MenuBarItem ("_View", + [ + viewLineNumbersItem, + viewFoldIndicatorsItem, + viewWordWrapItem, + viewShowTabsItem, + null!, + previewMarkdownItem, + null!, + viewUseThemeBgItem, ])); + // --- Options menu --- + + menu.Add (new MenuBarItem ("_Options", + [ + new MenuItem { Title = "_Settings...", Action = ShowSettings }, + ])); + + menu.Add (new MenuBarItem ("_Help", + [ + new MenuItem { Title = "_About", Action = ShowAbout }, + ]), + filenameShortcut); + + // --- Right-click context menu --- + + PopoverMenu contextMenu = new (CreateEditMenuItems ()) + { + Target = new WeakReference (editor), + }; + + editor.MouseEvent += (_, e) => + { + if (!e.Flags.HasFlag (MouseFlags.RightButtonClicked)) + { + return; + } + + contextMenu.MakeVisible (e.ScreenPosition); + e.Handled = true; + }; + + // --- Wire find/replace events --- + + editor.FindRequested += (_, _) => ShowFindReplace (); + editor.ReplaceRequested += (_, _) => ShowFindReplace (true); + // --- Wire events --- editor.CaretChanged += (_, _) => @@ -412,8 +904,8 @@ void Cut () new Shortcut (Application.GetDefaultKey (Command.Quit), "Quit", QuitEditor), new Shortcut (Key.F2, "Open", OpenFile), new Shortcut (Key.F3, "Save", () => SaveFile ()), - modifiedShortcut, cursorPositionShortcut, + languageShortcut, ]; // File selector: dropdown when multiple files, plain label otherwise @@ -421,8 +913,6 @@ void Cut () if (files.Count > 1) { - // Use basenames when they are all distinct; fall back to relative paths - // so that files like a/readme.md and b/readme.md get unique labels. List basenames = [.. files.Select (f => Path.GetFileName (f) ?? f)]; bool hasCollisions = basenames.Count != basenames.Distinct (StringComparer.OrdinalIgnoreCase).Count (); string cwd = Directory.GetCurrentDirectory (); @@ -449,8 +939,6 @@ void Cut () return; } - // Use the unique display name list — since labels are guaranteed distinct, - // IndexOf is unambiguous even when files share the same basename. int index = displayNames.IndexOf (fileSelector.Text); if (index < 0 || index >= files.Count) @@ -460,7 +948,6 @@ void Cut () if (!PromptSaveIfDirty ()) { - // Revert dropdown to current file switchingFile = true; int currentIndex = filePath is not null ? files.IndexOf (filePath) : -1; @@ -479,14 +966,6 @@ void Cut () statusItems.Add (new Shortcut () { CommandView = fileSelector, HelpText = "File" }); } - else - { - Shortcut fileInfoShortcut = new () - { Title = fileName ?? "Untitled", MouseHighlightStates = MouseState.None, Enabled = false }; - - // UpdateTitle needs to update this shortcut - statusItems.Add (fileInfoShortcut); - } StatusBar statusBar = new (statusItems) { AlignmentModes = AlignmentModes.StartToEnd | AlignmentModes.IgnoreFirstOrLast }; @@ -505,19 +984,20 @@ void Cut () } else if (filePath is not null) { - // File doesn't exist yet — treat as new file at that path savedText = string.Empty; editor.Document = new TextDocument (); + InstallFolding (); UpdateModifiedIndicator (); } else if (content is not null) { - // Piped content via --initial editor.Document = new TextDocument (content); - savedText = string.Empty; // Mark as unsaved + savedText = string.Empty; + InstallFolding (); UpdateModifiedIndicator (); } + UpdateLanguageShortcut (); editor.SetFocus (); }; diff --git a/src/Clet/Clets/Viewer/EditorSettings.cs b/src/Clet/Clets/Viewer/EditorSettings.cs new file mode 100644 index 0000000..4fd8824 --- /dev/null +++ b/src/Clet/Clets/Viewer/EditorSettings.cs @@ -0,0 +1,197 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Terminal.Gui.App; +using Terminal.Gui.Configuration; + +namespace Clet; + +/// +/// Persisted settings for the editor clet. Each property is discovered by +/// via +/// and is loaded automatically from ~/.tui/clet.config.json. +/// +internal static class EditorSettings +{ + // --- View toggles --- + + [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static bool LineNumbers { get; set; } = true; + + [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static bool FoldIndicators { get; set; } = true; + + [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static bool WordWrap { get; set; } + + [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static bool ShowTabs { get; set; } + + [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static bool UseThemeBackground { get; set; } + + // --- Tab settings --- + + [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static int IndentSize { get; set; } = 4; + + [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static bool ConvertTabsToSpaces { get; set; } = true; + + [ConfigurationProperty (Scope = typeof (SettingsScope))] + public static bool AutoIndent { get; set; } + + /// + /// All keys managed by this class. Used for selective persistence. + /// + private static readonly string[] _keys = + [ + "EditorSettings.LineNumbers", + "EditorSettings.FoldIndicators", + "EditorSettings.WordWrap", + "EditorSettings.ShowTabs", + "EditorSettings.UseThemeBackground", + "EditorSettings.IndentSize", + "EditorSettings.ConvertTabsToSpaces", + "EditorSettings.AutoIndent", + ]; + + /// + /// Saves current property values to ~/.tui/clet.config.json, + /// preserving all JSONC content (comments, formatting, non-editor keys). + /// After writing, reloads so that in-memory + /// state matches the persisted file. + /// + internal static void Save () => Save (ConfigClet.GetConfigPath ()); + + /// + /// Saves current property values to the specified config file path, + /// preserving all JSONC content (comments, formatting, non-editor keys). + /// + internal static void Save (string path) + { + ConfigClet.EnsureConfigFile (path); + + try + { + string text = File.ReadAllText (path); + + // Build key → JSON-value pairs for each managed setting. + Dictionary entries = new () + { + ["EditorSettings.LineNumbers"] = ToJson (LineNumbers), + ["EditorSettings.FoldIndicators"] = ToJson (FoldIndicators), + ["EditorSettings.WordWrap"] = ToJson (WordWrap), + ["EditorSettings.ShowTabs"] = ToJson (ShowTabs), + ["EditorSettings.UseThemeBackground"] = ToJson (UseThemeBackground), + ["EditorSettings.IndentSize"] = IndentSize.ToString (), + ["EditorSettings.ConvertTabsToSpaces"] = ToJson (ConvertTabsToSpaces), + ["EditorSettings.AutoIndent"] = ToJson (AutoIndent), + }; + + List toInsert = []; + + foreach (KeyValuePair kvp in entries) + { + // Replace an existing key in-place (preserves surrounding JSONC). + // The negative lookbehind skips keys inside JSONC line comments. + // Only matches bool and int values (all current EditorSettings types). + string pattern = $@"(? 0) + { + int lastBrace = text.LastIndexOf ('}'); + + if (lastBrace >= 0) + { + // Find the position of the last non-whitespace, non-comment character + // before the closing brace so we can insert a comma after it. + int insertCommaAfter = FindLastJsonTokenPosition (text, lastBrace); + + if (insertCommaAfter >= 0 && text[insertCommaAfter] != ',' && text[insertCommaAfter] != '{') + { + // Insert comma after the last JSON value + text = text.Insert (insertCommaAfter + 1, ","); + + // Adjust lastBrace since we inserted a character + lastBrace = text.LastIndexOf ('}'); + } + + string insertion = $"\n\n{string.Join (",\n", toInsert)}\n"; + text = text.Insert (lastBrace, insertion); + } + } + + File.WriteAllText (path, text); + + // Sync ConfigurationManager so in-memory state matches the file. + if (ConfigurationManager.IsEnabled) + { + ConfigurationManager.Load (ConfigLocations.All); + ConfigurationManager.Apply (); + } + } + catch (Exception ex) + { + Logging.Error ($"EditorSettings.Save: {ex.GetType ().Name}: {ex.Message}"); + } + } + + /// + /// Returns the keys managed by this class. Useful for testing. + /// + internal static IReadOnlyList ManagedKeys => _keys; + + /// Converts a boolean to its JSON literal. + private static string ToJson (bool value) => value ? "true" : "false"; + + /// + /// Finds the position of the last non-whitespace, non-comment character + /// before . This is where a trailing comma + /// should be inserted when appending new properties. + /// Returns -1 if only whitespace/comments precede the brace. + /// + private static int FindLastJsonTokenPosition (string text, int braceIndex) + { + int i = braceIndex - 1; + + while (i >= 0) + { + char c = text[i]; + + if (char.IsWhiteSpace (c)) + { + i--; + + continue; + } + + // Check if we're at the end of a line comment. + // Walk back to find if this line starts with "//". + int lineStart = text.LastIndexOf ('\n', i) + 1; + string line = text[lineStart..(i + 1)].TrimStart (); + + if (line.StartsWith ("//", StringComparison.Ordinal)) + { + // This entire line is a comment — skip to before it. + i = lineStart - 1; + + continue; + } + + return i; + } + + return -1; + } +} diff --git a/src/Clet/Clets/Viewer/EditorSettingsDialog.cs b/src/Clet/Clets/Viewer/EditorSettingsDialog.cs new file mode 100644 index 0000000..0342916 --- /dev/null +++ b/src/Clet/Clets/Viewer/EditorSettingsDialog.cs @@ -0,0 +1,124 @@ +using Terminal.Gui.App; +using Terminal.Gui.Editor; +using Terminal.Gui.Input; +using Terminal.Gui.Text.Indentation; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace Clet; + +/// +/// Settings dialog for the editor clet. Provides tabs for Config and Tab Settings. +/// +internal sealed class EditorSettingsDialog : Dialog +{ + private readonly NumericUpDown _indentSize; + private readonly CheckBox _convertTabsCheck; + private readonly CheckBox _autoIndentCheck; + + internal bool WasAccepted { get; private set; } + + internal EditorSettingsDialog (Editor editor) + { + Title = "Settings"; + Width = Dim.Percent (60); + Height = 16; + + // --- Tab Settings tab --- + View tabSettingsTab = new () + { + Title = "_Tab Settings", + Width = Dim.Fill (), + Height = Dim.Fill (), + }; + + _indentSize = new () + { + X = 20, + Y = 1, + Value = editor.IndentationSize, + Width = 8, + }; + + _convertTabsCheck = new () + { + X = 1, + Y = 3, + Title = "Con_vert Tabs to Spaces", + Value = editor.ConvertTabsToSpaces ? CheckState.Checked : CheckState.UnChecked, + }; + + _autoIndentCheck = new () + { + X = 1, + Y = 5, + Title = "_Auto Indent", + Value = editor.IndentationStrategy is not null ? CheckState.Checked : CheckState.UnChecked, + }; + + tabSettingsTab.Add ( + new Label () { X = 1, Y = 1, Text = "_Indent size:" }, + _indentSize, + _convertTabsCheck, + _autoIndentCheck); + + // --- Config tab (empty for now) --- + View configTab = new () + { + Title = "_Config", + Width = Dim.Fill (), + Height = Dim.Fill (), + }; + + configTab.Add (new Label () { X = 1, Y = 1, Text = "No settings yet." }); + + // --- Tabs --- + Tabs tabs = new () + { + X = 0, + Y = 0, + Width = Dim.Fill (), + Height = Dim.Fill (2), + }; + + tabs.InsertTab (0, configTab); + tabs.InsertTab (1, tabSettingsTab); + + Button okBtn = new () + { + Text = "OK", + X = Pos.Center () - 6, + Y = Pos.Bottom (tabs), + IsDefault = true, + }; + + Button cancelBtn = new () + { + Text = "Cancel", + X = Pos.Right (okBtn) + 2, + Y = Pos.Bottom (tabs), + }; + + okBtn.Accepting += (_, _) => + { + WasAccepted = true; + RequestStop (); + }; + + cancelBtn.Accepting += (_, _) => RequestStop (); + + Add (tabs, okBtn, cancelBtn); + } + + /// + /// Applies the accepted settings to the editor. Call only when is true. + /// + internal void ApplyTo (Editor editor) + { + editor.IndentationSize = _indentSize.Value; + editor.ConvertTabsToSpaces = _convertTabsCheck.Value == CheckState.Checked; + editor.IndentationStrategy = _autoIndentCheck.Value == CheckState.Checked + ? new DefaultIndentationStrategy () + : null; + } +} diff --git a/src/Clet/Clets/Viewer/FindReplaceDialog.cs b/src/Clet/Clets/Viewer/FindReplaceDialog.cs new file mode 100644 index 0000000..5e5c446 --- /dev/null +++ b/src/Clet/Clets/Viewer/FindReplaceDialog.cs @@ -0,0 +1,200 @@ +using Terminal.Gui.App; +using Terminal.Gui.Document.Search; +using Terminal.Gui.Editor; +using Terminal.Gui.Input; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace Clet; + +internal sealed class FindReplaceDialog : Dialog +{ + private readonly Editor _editor; + private readonly TextField _findField; + private readonly TextField _replaceField; + private readonly CheckBox _matchCase; + private readonly CheckBox _wholeWord; + private readonly CheckBox _regex; + private readonly Label _statusLabel; + + internal FindReplaceDialog (Editor editor, bool showReplace = false) + { + _editor = editor; + Title = "Find and Replace"; + Width = Dim.Percent (60); + Height = 14; + + _findField = new () { X = 12, Y = 0, Width = Dim.Fill (1) }; + _replaceField = new () { X = 12, Y = 0, Width = Dim.Fill (1) }; + _matchCase = new () { Title = "Match _case" }; + _wholeWord = new () { Title = "_Whole word" }; + _regex = new () { Title = "Re_gex" }; + _statusLabel = new () { X = 0, Width = Dim.Fill (), Text = "" }; + + // Pre-populate from editor selection + if (_editor.HasSelection) + { + int start = Math.Min (_editor.SelectionStart, _editor.SelectionEnd); + int end = Math.Max (_editor.SelectionStart, _editor.SelectionEnd); + string selected = _editor.Document?.Text?.Substring (start, end - start) ?? ""; + + if (!selected.Contains ('\n')) + { + _findField.Text = selected; + } + } + + // Find tab content + Label findLabel = new () { Text = "_Find what:", X = 0, Y = 0 }; + + View findTab = new () + { + Title = "Find", + Width = Dim.Fill (), + Height = Dim.Fill (), + }; + findTab.Add (findLabel, _findField); + + // Replace tab content + Label replaceLabel = new () { Text = "_Replace:", X = 0, Y = 0 }; + + View replaceTab = new () + { + Title = "Replace", + Width = Dim.Fill (), + Height = Dim.Fill (), + }; + replaceTab.Add (replaceLabel, _replaceField); + + // Tabs + Tabs tabs = new () + { + X = 0, + Y = 0, + Width = Dim.Fill (), + Height = 3, + }; + tabs.InsertTab (0, findTab); + tabs.InsertTab (1, replaceTab); + + if (showReplace) + { + tabs.Value = replaceTab; + } + + // Buttons + Button findNextBtn = new () { Text = "Find _Next", IsDefault = true }; + findNextBtn.Accepting += (_, _) => DoFind (forward: true); + + Button findPrevBtn = new () { Text = "Find _Previous" }; + findPrevBtn.Accepting += (_, _) => DoFind (forward: false); + + Button replaceBtn = new () { Text = "_Replace" }; + replaceBtn.Accepting += (_, _) => DoReplace (); + + Button replaceAllBtn = new () { Text = "Replace _All" }; + replaceAllBtn.Accepting += (_, _) => DoReplaceAll (); + + // Layout below tabs + View controlsArea = new () + { + X = 0, + Y = Pos.Bottom (tabs), + Width = Dim.Fill (), + Height = 5, + }; + + _matchCase.X = 0; + _matchCase.Y = 0; + _wholeWord.X = Pos.Right (_matchCase) + 2; + _wholeWord.Y = 0; + _regex.X = Pos.Right (_wholeWord) + 2; + _regex.Y = 0; + + findNextBtn.X = 0; + findNextBtn.Y = 1; + findPrevBtn.X = Pos.Right (findNextBtn) + 1; + findPrevBtn.Y = 1; + replaceBtn.X = Pos.Right (findPrevBtn) + 1; + replaceBtn.Y = 1; + replaceAllBtn.X = Pos.Right (replaceBtn) + 1; + replaceAllBtn.Y = 1; + _statusLabel.Y = 2; + + controlsArea.Add (_matchCase, _wholeWord, _regex); + controlsArea.Add (findNextBtn, findPrevBtn, replaceBtn, replaceAllBtn); + controlsArea.Add (_statusLabel); + + Button closeBtn = new () { Text = "Close", X = Pos.Center (), Y = Pos.Bottom (controlsArea) }; + closeBtn.Accepting += (_, _) => RequestStop (); + + Add (tabs, controlsArea, closeBtn); + } + + private void ApplySearchStrategy () + { + string searchText = _findField.Text ?? ""; + + if (string.IsNullOrEmpty (searchText)) + { + _editor.SearchStrategy = null; + + return; + } + + bool ignoreCase = _matchCase.Value != CheckState.Checked; + bool wholeWord = _wholeWord.Value == CheckState.Checked; + SearchMode mode = _regex.Value == CheckState.Checked ? SearchMode.RegEx : SearchMode.Normal; + + try + { + _editor.SearchStrategy = SearchStrategyFactory.Create (searchText, ignoreCase, wholeWord, mode); + _statusLabel.Text = ""; + } + catch (SearchPatternException ex) + { + _statusLabel.Text = $"Pattern error: {ex.Message}"; + _editor.SearchStrategy = null; + } + } + + private void DoFind (bool forward) + { + ApplySearchStrategy (); + + if (_editor.SearchStrategy is null) + { + return; + } + + bool found = forward ? _editor.FindNext (true) : _editor.FindPrevious (true); + _statusLabel.Text = found ? "" : "No match"; + } + + private void DoReplace () + { + ApplySearchStrategy (); + + if (_editor.SearchStrategy is null) + { + return; + } + + string replacement = _replaceField.Text ?? ""; + _editor.ReplaceNext (replacement, true); + } + + private void DoReplaceAll () + { + ApplySearchStrategy (); + + if (_editor.SearchStrategy is null) + { + return; + } + + string replacement = _replaceField.Text ?? ""; + int count = _editor.ReplaceAll (replacement); + _statusLabel.Text = count > 0 ? $"Replaced {count} occurrences" : "No match"; + } +} diff --git a/tests/Clet.UnitTests/EditorSettingsTests.cs b/tests/Clet.UnitTests/EditorSettingsTests.cs new file mode 100644 index 0000000..d3cbf58 --- /dev/null +++ b/tests/Clet.UnitTests/EditorSettingsTests.cs @@ -0,0 +1,408 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Terminal.Gui.Configuration; +using Xunit; + +namespace Clet.UnitTests; + +/// +/// Defines a non-parallel collection for tests that interact with +/// , which uses global static state. +/// +[CollectionDefinition (nameof (ConfigurationManagerCollection), DisableParallelization = true)] +public class ConfigurationManagerCollection; + +/// +/// Tests for round-tripping through +/// . +/// +[Collection (nameof (ConfigurationManagerCollection))] +public class EditorSettingsTests : IDisposable +{ + private readonly string _tempDir; + private readonly string _configPath; + private readonly string? _originalHome; + + public EditorSettingsTests () + { + _tempDir = Path.Combine (Path.GetTempPath (), $"clet-test-{Guid.NewGuid ():N}"); + string tuiDir = Path.Combine (_tempDir, ".tui"); + Directory.CreateDirectory (tuiDir); + _configPath = Path.Combine (tuiDir, ConfigClet.ConfigFileName); + + // Save original HOME so we can restore it on cleanup. + _originalHome = Environment.GetEnvironmentVariable ("HOME"); + + // Point HOME at our temp directory (used by Save's CM reload on Linux). + Environment.SetEnvironmentVariable ("HOME", _tempDir); + + // Ensure CM uses the "clet" app name (matches the clet binary; in tests + // the assembly name is different). + ConfigurationManager.AppName = "clet"; + } + + public void Dispose () + { + // Reset CM so other tests start clean. + try + { + ConfigurationManager.Disable (resetToHardCodedDefaults: true); + } + catch + { + // Best-effort cleanup. + } + + // Restore original HOME. + Environment.SetEnvironmentVariable ("HOME", _originalHome); + + if (Directory.Exists (_tempDir)) + { + Directory.Delete (_tempDir, true); + } + } + + [Fact] + public void ManagedKeys_ContainsAllProperties () + { + Assert.Contains ("EditorSettings.LineNumbers", EditorSettings.ManagedKeys); + Assert.Contains ("EditorSettings.FoldIndicators", EditorSettings.ManagedKeys); + Assert.Contains ("EditorSettings.WordWrap", EditorSettings.ManagedKeys); + Assert.Contains ("EditorSettings.ShowTabs", EditorSettings.ManagedKeys); + Assert.Contains ("EditorSettings.UseThemeBackground", EditorSettings.ManagedKeys); + Assert.Contains ("EditorSettings.IndentSize", EditorSettings.ManagedKeys); + Assert.Contains ("EditorSettings.ConvertTabsToSpaces", EditorSettings.ManagedKeys); + Assert.Contains ("EditorSettings.AutoIndent", EditorSettings.ManagedKeys); + Assert.Equal (8, EditorSettings.ManagedKeys.Count); + } + + [Fact] + public void ConfigurationManager_Discovers_EditorSettings () + { + ConfigurationManager.Enable (ConfigLocations.None); + + foreach (string key in EditorSettings.ManagedKeys) + { + Assert.True ( + ConfigurationManager.Settings!.Keys.Contains (key), + $"ConfigurationManager.Settings should contain '{key}'"); + } + } + + [Fact] + public void Save_WritesAllKeys_ToConfigFile () + { + // Arrange — set known values + EditorSettings.LineNumbers = false; + EditorSettings.FoldIndicators = false; + EditorSettings.WordWrap = true; + EditorSettings.ShowTabs = true; + EditorSettings.UseThemeBackground = true; + EditorSettings.IndentSize = 2; + EditorSettings.ConvertTabsToSpaces = false; + EditorSettings.AutoIndent = true; + + // Write a minimal config file so Save can insert into it + File.WriteAllText (_configPath, "{}"); + + // Act + EditorSettings.Save (_configPath); + + // Assert — parse the written file and check values + string json = File.ReadAllText (_configPath); + + JsonNode? root = JsonNode.Parse ( + json, + documentOptions: new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }); + + Assert.NotNull (root); + JsonObject obj = Assert.IsType (root); + + Assert.False ((bool)obj["EditorSettings.LineNumbers"]!); + Assert.False ((bool)obj["EditorSettings.FoldIndicators"]!); + Assert.True ((bool)obj["EditorSettings.WordWrap"]!); + Assert.True ((bool)obj["EditorSettings.ShowTabs"]!); + Assert.True ((bool)obj["EditorSettings.UseThemeBackground"]!); + Assert.Equal (2, (int)obj["EditorSettings.IndentSize"]!); + Assert.False ((bool)obj["EditorSettings.ConvertTabsToSpaces"]!); + Assert.True ((bool)obj["EditorSettings.AutoIndent"]!); + } + + [Fact] + public void Save_PreservesExistingKeys () + { + // Arrange + File.WriteAllText ( + _configPath, + """ + { + "$schema": "https://example.com/schema.json", + "Theme": "Dark" + } + """); + + EditorSettings.LineNumbers = true; + + // Act + EditorSettings.Save (_configPath); + + // Assert + string json = File.ReadAllText (_configPath); + + JsonNode? root = JsonNode.Parse ( + json, + documentOptions: new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }); + + Assert.NotNull (root); + JsonObject obj = Assert.IsType (root); + + Assert.Equal ("https://example.com/schema.json", (string)obj["$schema"]!); + Assert.Equal ("Dark", (string)obj["Theme"]!); + Assert.True ((bool)obj["EditorSettings.LineNumbers"]!); + } + + [Fact] + public void Save_PreservesJsoncComments () + { + // Arrange — write a JSONC file with comments + string jsonc = + """ + { + // This is a comment + "$schema": "https://example.com/schema.json", + + // Theme configuration + // "Theme": "Anders", + + "Key.Separator": "+" + } + """; + File.WriteAllText (_configPath, jsonc); + + EditorSettings.LineNumbers = false; + EditorSettings.IndentSize = 2; + + // Act + EditorSettings.Save (_configPath); + + // Assert — JSONC comments and existing keys are preserved + string result = File.ReadAllText (_configPath); + + Assert.Contains ("// This is a comment", result); + Assert.Contains ("// Theme configuration", result); + Assert.Contains ("// \"Theme\": \"Anders\",", result); + Assert.Contains ("\"Key.Separator\": \"+\"", result); + Assert.Contains ("\"$schema\": \"https://example.com/schema.json\"", result); + Assert.Contains ("\"EditorSettings.LineNumbers\": false", result); + Assert.Contains ("\"EditorSettings.IndentSize\": 2", result); + } + + [Fact] + public void Save_PreservesDefaultConfigContent () + { + // Arrange — use the full ConfigClet default JSONC template + File.WriteAllText (_configPath, ConfigClet.DefaultConfigContent); + + EditorSettings.LineNumbers = false; + + // Act + EditorSettings.Save (_configPath); + + // Assert — the original JSONC structure is intact + string result = File.ReadAllText (_configPath); + + Assert.Contains ("clet configuration", result); + Assert.Contains ("Terminal.Gui's ConfigurationManager", result); + Assert.Contains ("$schema", result); + Assert.Contains ("\"EditorSettings.LineNumbers\": false", result); + } + + [Fact] + public void Save_UpdatesExistingEditorSettingsKeys () + { + // Arrange — write a file that already has EditorSettings keys + File.WriteAllText ( + _configPath, + """ + { + // comments + "EditorSettings.LineNumbers": true, + "EditorSettings.IndentSize": 4 + } + """); + + EditorSettings.LineNumbers = false; + EditorSettings.IndentSize = 8; + + // Act + EditorSettings.Save (_configPath); + + // Assert — existing keys are updated in place + string result = File.ReadAllText (_configPath); + + Assert.Contains ("// comments", result); + Assert.Contains ("\"EditorSettings.LineNumbers\": false", result); + Assert.Contains ("\"EditorSettings.IndentSize\": 8", result); + + // Verify no duplicate keys + Assert.Equal (1, CountOccurrences (result, "EditorSettings.LineNumbers")); + Assert.Equal (1, CountOccurrences (result, "EditorSettings.IndentSize")); + } + + [Fact] + public void Save_DoesNotModifyCommentedOutKeys () + { + // Arrange — file has a commented-out EditorSettings key + File.WriteAllText ( + _configPath, + """ + { + // "EditorSettings.LineNumbers": true, + "EditorSettings.IndentSize": 4 + } + """); + + EditorSettings.LineNumbers = false; + EditorSettings.IndentSize = 2; + + // Act + EditorSettings.Save (_configPath); + + // Assert — commented-out key is untouched, active key is updated + string result = File.ReadAllText (_configPath); + + Assert.Contains ("// \"EditorSettings.LineNumbers\": true,", result); + Assert.Contains ("\"EditorSettings.IndentSize\": 2", result); + Assert.Contains ("\"EditorSettings.LineNumbers\": false", result); + } + + [Fact] + public void RoundTrip_LoadApply_RestoresPersistedValues () + { + // Arrange — JSON with non-default values + string json = """ + { + "EditorSettings.LineNumbers": false, + "EditorSettings.IndentSize": 8, + "EditorSettings.WordWrap": true, + "EditorSettings.AutoIndent": true + } + """; + + // Reset to defaults first + EditorSettings.LineNumbers = true; + EditorSettings.IndentSize = 4; + EditorSettings.WordWrap = false; + EditorSettings.AutoIndent = false; + + // Act — load via RuntimeConfig (cross-platform; avoids ~ resolution + // issues on Windows where GetFolderPath ignores env var changes). + ConfigurationManager.RuntimeConfig = json; + ConfigurationManager.Enable (ConfigLocations.Runtime); + + // Assert + Assert.False (EditorSettings.LineNumbers); + Assert.Equal (8, EditorSettings.IndentSize); + Assert.True (EditorSettings.WordWrap); + Assert.True (EditorSettings.AutoIndent); + } + + [Fact] + public void RoundTrip_SaveThenLoad_RestoresValues () + { + // Arrange — write initial config, set non-default values, save + File.WriteAllText (_configPath, "{}"); + + EditorSettings.LineNumbers = false; + EditorSettings.FoldIndicators = false; + EditorSettings.IndentSize = 3; + EditorSettings.ConvertTabsToSpaces = false; + EditorSettings.Save (_configPath); + + // Reset in-memory to defaults + EditorSettings.LineNumbers = true; + EditorSettings.FoldIndicators = true; + EditorSettings.IndentSize = 4; + EditorSettings.ConvertTabsToSpaces = true; + + // Act — load saved file via RuntimeConfig (cross-platform). + string savedJson = File.ReadAllText (_configPath); + ConfigurationManager.RuntimeConfig = savedJson; + ConfigurationManager.Enable (ConfigLocations.Runtime); + + // Assert — values should match what we saved + Assert.False (EditorSettings.LineNumbers); + Assert.False (EditorSettings.FoldIndicators); + Assert.Equal (3, EditorSettings.IndentSize); + Assert.False (EditorSettings.ConvertTabsToSpaces); + } + + [Fact] + public void Save_CreatesConfigFile_WhenMissing () + { + // Arrange — no config file exists yet + Assert.False (File.Exists (_configPath)); + + EditorSettings.IndentSize = 6; + + // Act + EditorSettings.Save (_configPath); + + // Assert — file was created and contains the setting + Assert.True (File.Exists (_configPath)); + + string json = File.ReadAllText (_configPath); + + JsonNode? root = JsonNode.Parse ( + json, + documentOptions: new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }); + + Assert.NotNull (root); + Assert.Equal (6, (int)root!["EditorSettings.IndentSize"]!); + } + + [Fact] + public void Defaults_AreCorrect () + { + // Reset CM so properties are at hard-coded defaults + ConfigurationManager.Enable (ConfigLocations.None); + ConfigurationManager.Load (ConfigLocations.HardCoded); + ConfigurationManager.Apply (); + + Assert.True (EditorSettings.LineNumbers); + Assert.True (EditorSettings.FoldIndicators); + Assert.False (EditorSettings.WordWrap); + Assert.False (EditorSettings.ShowTabs); + Assert.False (EditorSettings.UseThemeBackground); + Assert.Equal (4, EditorSettings.IndentSize); + Assert.True (EditorSettings.ConvertTabsToSpaces); + Assert.False (EditorSettings.AutoIndent); + } + + /// Counts the number of occurrences of in . + private static int CountOccurrences (string text, string substring) + { + int count = 0; + int index = 0; + + while ((index = text.IndexOf (substring, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += substring.Length; + } + + return count; + } +}