diff --git a/examples/ted/EditorSettings.cs b/examples/ted/EditorSettings.cs new file mode 100644 index 0000000..69e828c --- /dev/null +++ b/examples/ted/EditorSettings.cs @@ -0,0 +1,280 @@ +using System.Text.RegularExpressions; +using Terminal.Gui.App; +using Terminal.Gui.Configuration; + +namespace Ted; + +internal sealed class TedSettingsScope; + +internal static class EditorSettings +{ + [ConfigurationProperty (Scope = typeof (TedSettingsScope))] + public static bool LineNumbers { get; set; } = true; + + [ConfigurationProperty (Scope = typeof (TedSettingsScope))] + public static bool FoldIndicators { get; set; } = true; + + [ConfigurationProperty (Scope = typeof (TedSettingsScope))] + public static bool WordWrap { get; set; } + + [ConfigurationProperty (Scope = typeof (TedSettingsScope))] + public static bool ShowTabs { get; set; } + + [ConfigurationProperty (Scope = typeof (TedSettingsScope))] + public static bool UseThemeBackground { get; set; } = true; + + [ConfigurationProperty (Scope = typeof (TedSettingsScope))] + public static int IndentSize { get; set; } = 4; + + [ConfigurationProperty (Scope = typeof (TedSettingsScope))] + public static bool ConvertTabsToSpaces { get; set; } = true; + + [ConfigurationProperty (Scope = typeof (TedSettingsScope))] + public static bool AutoIndent { get; set; } = true; + + /// + /// Loads settings from the config file at . + /// Called once at startup before constructing . + /// + internal static void Load () + { + Load (GetConfigPath ()); + } + + internal static void Load (string path) + { + if (!File.Exists (path)) + { + return; + } + + try + { + string text = File.ReadAllText (path); + + LineNumbers = ReadBool (text, "EditorSettings.LineNumbers", LineNumbers); + FoldIndicators = ReadBool (text, "EditorSettings.FoldIndicators", FoldIndicators); + WordWrap = ReadBool (text, "EditorSettings.WordWrap", WordWrap); + ShowTabs = ReadBool (text, "EditorSettings.ShowTabs", ShowTabs); + UseThemeBackground = ReadBool (text, "EditorSettings.UseThemeBackground", UseThemeBackground); + IndentSize = ReadInt (text, "EditorSettings.IndentSize", IndentSize); + ConvertTabsToSpaces = ReadBool (text, "EditorSettings.ConvertTabsToSpaces", ConvertTabsToSpaces); + AutoIndent = ReadBool (text, "EditorSettings.AutoIndent", AutoIndent); + } + catch (Exception ex) + { + Logging.Error ($"EditorSettings.Load: {ex.GetType ().Name}: {ex.Message}"); + } + } + + internal static void Save () + { + Save (GetConfigPath ()); + } + + internal static void Save (string path) + { + try + { + EnsureConfigFile (path); + string text = File.ReadAllText (path); + 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 ((string key, string value) in entries) + { + Regex pattern = new ( + $@"^(?\s*""{Regex.Escape (key)}""\s*:\s*)(?:true|false|-?\d+)(?\s*,?\s*(?://.*)?)$", + RegexOptions.Multiline); + bool replaced = false; + text = pattern.Replace ( + text, + match => + { + replaced = true; + + return $"{match.Groups["prefix"].Value}{value}{match.Groups["suffix"].Value}"; + }, + 1); + + if (replaced) + { + continue; + } + + toInsert.Add ($" \"{key}\": {value}"); + } + + if (toInsert.Count > 0) + { + int lastBrace = FindRootClosingBrace (text); + + if (lastBrace >= 0) + { + int insertCommaAfter = FindLastObjectMemberCharacterPosition (text, lastBrace); + + if (insertCommaAfter >= 0 && text[insertCommaAfter] != ',' && text[insertCommaAfter] != '{') + { + text = text.Insert (insertCommaAfter + 1, ","); + lastBrace = FindRootClosingBrace (text); + } + + string insertion = $"\n\n{string.Join (",\n", toInsert)}\n"; + text = text.Insert (lastBrace, insertion); + } + } + + File.WriteAllText (path, text); + } + catch (Exception ex) + { + Logging.Error ($"EditorSettings.Save: {ex.GetType ().Name}: {ex.Message}"); + } + } + + internal static string GetConfigPath () + { + string home = + Environment.GetEnvironmentVariable ("HOME") + ?? Environment.GetFolderPath (Environment.SpecialFolder.UserProfile) + ?? Directory.GetCurrentDirectory (); + + return Path.Combine (home, ".tui", "ted.config.json"); + } + + private static void EnsureConfigFile (string path) + { + string? directory = Path.GetDirectoryName (path); + + if (!string.IsNullOrWhiteSpace (directory)) + { + Directory.CreateDirectory (directory); + } + + if (!File.Exists (path)) + { + File.WriteAllText (path, "{}"); + } + } + + private static string ToJson (bool value) + { + return value ? "true" : "false"; + } + + private static bool ReadBool (string json, string key, bool defaultValue) + { + // Match key only at a JSON property position: line starts with optional whitespace, + // then the key. Negative lookahead skips // comment lines. + Match m = Regex.Match ( + json, + $@"^(?!\s*//)\s*""{Regex.Escape (key)}""\s*:\s*(?true|false)", + RegexOptions.IgnoreCase | RegexOptions.Multiline); + + return m.Success ? string.Equals (m.Groups["v"].Value, "true", StringComparison.OrdinalIgnoreCase) : defaultValue; + } + + private static int ReadInt (string json, string key, int defaultValue) + { + Match m = Regex.Match ( + json, + $@"^(?!\s*//)\s*""{Regex.Escape (key)}""\s*:\s*(?-?\d+)", + RegexOptions.Multiline); + + return m.Success && int.TryParse (m.Groups["v"].Value, out int v) ? v : defaultValue; + } + + /// + /// Finds the last '}' that is NOT inside a // comment. + /// Scans backwards, skipping any '}' on a line whose non-whitespace content starts with //. + /// + private static int FindRootClosingBrace (string text) + { + int i = text.Length - 1; + + while (i >= 0) + { + i = text.LastIndexOf ('}', i); + + if (i < 0) + { + return -1; + } + + // Check if this '}' is on a comment line + int lineStart = text.LastIndexOf ('\n', i) + 1; + string lineBeforeBrace = text[lineStart..i]; + + if (lineBeforeBrace.TrimStart ().StartsWith ("//", StringComparison.Ordinal)) + { + i--; + + continue; + } + + return i; + } + + return -1; + } + + private static int FindLastObjectMemberCharacterPosition (string text, int braceIndex) + { + int i = braceIndex - 1; + + while (i >= 0) + { + char c = text[i]; + + if (char.IsWhiteSpace (c)) + { + i--; + + continue; + } + + int lineStart = text.LastIndexOf ('\n', i) + 1; + string line = text[lineStart..(i + 1)]; + string trimmedLine = line.TrimStart (); + + if (trimmedLine.StartsWith ("//", StringComparison.Ordinal)) + { + i = lineStart - 1; + + continue; + } + + int commentStart = line.IndexOf ("//", StringComparison.Ordinal); + + if (commentStart >= 0) + { + string withoutComment = line[..commentStart]; + int lastNonWhitespace = withoutComment.TrimEnd ().Length - 1; + + if (lastNonWhitespace >= 0) + { + return lineStart + lastNonWhitespace; + } + + i = lineStart - 1; + + continue; + } + + return i; + } + + return -1; + } +} diff --git a/examples/ted/EditorSettingsDialog.cs b/examples/ted/EditorSettingsDialog.cs new file mode 100644 index 0000000..4287cab --- /dev/null +++ b/examples/ted/EditorSettingsDialog.cs @@ -0,0 +1,121 @@ +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 Ted; + +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; + + View tabSettingsTab = new () + { + Title = "_Tab Settings", + Width = Dim.Fill (), + Height = Dim.Fill () + }; + + _indentSize = new () + { + X = 20, + Y = 1, + Value = editor.IndentationSize, + Width = 8 + }; + _indentSize.ValueChanging += (_, e) => + { + if (e.NewValue is < 1) + { + e.Handled = true; + } + }; + + _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); + + 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 = 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); + } + + internal void ApplyTo (Editor editor) + { + editor.IndentationSize = Math.Max (1, _indentSize.Value); + editor.ConvertTabsToSpaces = _convertTabsCheck.Value == CheckState.Checked; + editor.IndentationStrategy = _autoIndentCheck.Value == CheckState.Checked + ? new DefaultIndentationStrategy () + : null; + } +} diff --git a/examples/ted/Program.cs b/examples/ted/Program.cs index dd2301a..f209413 100644 --- a/examples/ted/Program.cs +++ b/examples/ted/Program.cs @@ -10,6 +10,7 @@ Hosting.EnableTracing (); ConfigurationManager.Enable (ConfigLocations.All); +EditorSettings.Load (); using IApplication app = Application.Create (); diff --git a/examples/ted/TedApp.MarkdownPreview.cs b/examples/ted/TedApp.MarkdownPreview.cs index 7ecbb01..3397a2c 100644 --- a/examples/ted/TedApp.MarkdownPreview.cs +++ b/examples/ted/TedApp.MarkdownPreview.cs @@ -10,7 +10,7 @@ public sealed partial class TedApp private Markdown? _markdownPreview; private bool _syncingScroll; - /// The status-bar checkbox that toggles the Markdown preview pane. + /// Toggle state used by the View menu item that shows or hides the Markdown preview pane. public CheckBox PreviewCheckBox { get; } = new () { AllowCheckStateNone = false, @@ -30,6 +30,7 @@ internal void UpdatePreviewVisibility () { var isMd = IsMarkdownFile; PreviewCheckBox.Visible = isMd; + _previewMarkdownMenuItem.Enabled = isMd; if (!isMd && _markdownPreview is not null) { @@ -41,6 +42,10 @@ internal void UpdatePreviewVisibility () // Document was swapped while preview was open — refresh content and re-hook. RefreshPreviewDocument (); } + + _previewMarkdownMenuItem.Title = ToggleTitle ( + PreviewCheckBox.Value == CheckState.Checked, + "_Preview Markdown"); } private void ToggleMarkdownPreview () @@ -70,7 +75,8 @@ private void ShowMarkdownPreview () Height = Editor.Height, Text = Editor.Document?.Text ?? string.Empty, ViewportSettings = ViewportSettingsFlags.HasScrollBars, - SyntaxHighlighter = new TextMateSyntaxHighlighter (ThemeName.DarkPlus) + SyntaxHighlighter = new TextMateSyntaxHighlighter (ThemeName.DarkPlus), + UseThemeBackground = Editor.UseThemeBackground }; // Editor takes the left half, preview takes the right half. diff --git a/examples/ted/TedApp.cs b/examples/ted/TedApp.cs index 35194f4..ba9348d 100644 --- a/examples/ted/TedApp.cs +++ b/examples/ted/TedApp.cs @@ -22,6 +22,7 @@ public sealed partial class TedApp : Window { private readonly BraceFoldingStrategy _braceFoldingStrategy; private readonly Shortcut _fileNameShortcut; + private readonly MenuItem _previewMarkdownMenuItem; /// Initializes a new . public TedApp (bool readOnly = false) @@ -33,12 +34,30 @@ public TedApp (bool readOnly = false) // Editor's KeyBindings (any commands the editor doesn't claim fall back to Application). Editor = new Editor { - GutterOptions = GutterOptions.LineNumbers | GutterOptions.Folding, - ConvertTabsToSpaces = true, + ConvertTabsToSpaces = EditorSettings.ConvertTabsToSpaces, + IndentationSize = EditorSettings.IndentSize, + WordWrap = EditorSettings.WordWrap, + ShowTabs = EditorSettings.ShowTabs, + UseThemeBackground = EditorSettings.UseThemeBackground, ReadOnly = readOnly, ViewportSettings = ViewportSettingsFlags.HasScrollBars }; + GutterOptions initialGutter = GutterOptions.None; + + if (EditorSettings.LineNumbers) + { + initialGutter |= GutterOptions.LineNumbers; + } + + if (EditorSettings.FoldIndicators) + { + initialGutter |= GutterOptions.Folding; + } + + Editor.GutterOptions = initialGutter; + Editor.IndentationStrategy = EditorSettings.AutoIndent ? new DefaultIndentationStrategy () : null; + // Enable brace-based folding. The strategy re-scans on each document change. _braceFoldingStrategy = new BraceFoldingStrategy (); InstallFolding (); @@ -68,34 +87,6 @@ public TedApp (bool readOnly = false) Value = Editor.GutterOptions.HasFlag (GutterOptions.Folding) ? CheckState.Checked : CheckState.UnChecked }; - CheckBox convertTabsToSpacesCheckBox = new () - { - AllowCheckStateNone = false, - CanFocus = false, - Text = "_Convert Tabs To Spaces", - Value = Editor.ConvertTabsToSpaces ? CheckState.Checked : CheckState.UnChecked - }; - - convertTabsToSpacesCheckBox.ValueChanged += (_, e) => - { - Editor.ConvertTabsToSpaces = e.NewValue == CheckState.Checked; - }; - - CheckBox autoIndentCheckBox = new () - { - AllowCheckStateNone = false, - CanFocus = false, - Text = "_Auto Indent", - Value = Editor.IndentationStrategy is not null ? CheckState.Checked : CheckState.UnChecked - }; - - autoIndentCheckBox.ValueChanged += (_, e) => - { - Editor.IndentationStrategy = e.NewValue == CheckState.Checked - ? new DefaultIndentationStrategy () - : null; - }; - CheckBox useThemeBackgroundCheckBox = new () { AllowCheckStateNone = false, @@ -111,52 +102,32 @@ public TedApp (bool readOnly = false) Text = "_Word Wrap", Value = Editor.WordWrap ? CheckState.Checked : CheckState.UnChecked }; - - wordWrapCheckBox.ValueChanged += (_, e) => - { - Editor.WordWrap = e.NewValue == CheckState.Checked; - }; - - LanguageShortcut = new Shortcut (Key.Empty, "Plain Text", null) { MouseHighlightStates = MouseState.None }; - - IndentationSizeUpDown = new NumericUpDown + _previewMarkdownMenuItem = new MenuItem { - Value = Editor.IndentationSize, - Increment = 1 + Title = ToggleTitle (false, "_Preview Markdown"), + Enabled = false }; - - IndentationSizeUpDown.ValueChanged += (_, e) => + _previewMarkdownMenuItem.Action = () => { - if (Editor.IndentationSize == e.NewValue) + if (PreviewCheckBox.Visible) { - return; + PreviewCheckBox.Value = PreviewCheckBox.Value == CheckState.Checked + ? CheckState.UnChecked + : CheckState.Checked; } - - Editor.IndentationSize = e.NewValue; - }; - - ShowTabsCheckBox = new CheckBox - { - AllowCheckStateNone = false, - CanFocus = false, - Title = "↹", - Value = Editor.ShowTabs ? CheckState.Checked : CheckState.UnChecked }; - ShowTabsCheckBox.ValueChanged += (_, e) => + LanguageShortcut = new Shortcut (Key.Empty, "Plain Text", null) { MouseHighlightStates = MouseState.None }; + ShowTabsCheckBox.Value = Editor.ShowTabs ? CheckState.Checked : CheckState.UnChecked; + PreviewCheckBox.ValueChanged += (_, e) => { - Editor.ShowTabs = e.NewValue == CheckState.Checked; + ToggleMarkdownPreview (); + _previewMarkdownMenuItem.Title = ToggleTitle (e.NewValue == CheckState.Checked, "_Preview Markdown"); }; - PreviewCheckBox.ValueChanged += (_, _) => ToggleMarkdownPreview (); - StatusBar statusBar = new ([ new Shortcut { Title = "Language", CommandView = LanguageShortcut }, - new Shortcut - { Text = "Indent", CommandView = IndentationSizeUpDown, MouseHighlightStates = MouseState.None }, - new Shortcut { CommandView = ShowTabsCheckBox }, - new Shortcut { CommandView = PreviewCheckBox }, LocShortcut = new Shortcut (Key.Empty, FormatLoc (1, 1), null) { MouseHighlightStates = MouseState.None } ]) @@ -189,13 +160,15 @@ public TedApp (bool readOnly = false) new MenuItem { Command = Command.Quit, Action = Quit, Key = KeyFor (Command.Quit) } ]), new MenuBarItem (Strings.menuEdit, CreateEditMenuItems ()), - new MenuBarItem ("_Options", + new MenuBarItem ("_View", [ new MenuItem { Action = () => { - if (lineNumbersCheckBox.Value == CheckState.Checked) + bool shouldEnableLineNumbers = !Editor.GutterOptions.HasFlag (GutterOptions.LineNumbers); + + if (shouldEnableLineNumbers) { Editor.GutterOptions |= GutterOptions.LineNumbers; } @@ -204,7 +177,9 @@ public TedApp (bool readOnly = false) Editor.GutterOptions &= ~GutterOptions.LineNumbers; } + lineNumbersCheckBox.Value = shouldEnableLineNumbers ? CheckState.Checked : CheckState.UnChecked; Editor.SetNeedsDraw (); + SaveViewSettings (); }, CommandView = lineNumbersCheckBox, HelpText = "Show line numbers" @@ -213,7 +188,9 @@ public TedApp (bool readOnly = false) { Action = () => { - if (foldIndicatorsCheckBox.Value == CheckState.Checked) + bool shouldEnableFoldIndicators = !Editor.GutterOptions.HasFlag (GutterOptions.Folding); + + if (shouldEnableFoldIndicators) { Editor.GutterOptions |= GutterOptions.Folding; } @@ -222,36 +199,59 @@ public TedApp (bool readOnly = false) Editor.GutterOptions &= ~GutterOptions.Folding; } + foldIndicatorsCheckBox.Value = shouldEnableFoldIndicators ? CheckState.Checked : CheckState.UnChecked; Editor.SetNeedsDraw (); + SaveViewSettings (); }, CommandView = foldIndicatorsCheckBox, HelpText = "Show fold indicators in the gutter" }, new MenuItem { - CommandView = convertTabsToSpacesCheckBox, - HelpText = "Insert spaces when Tab is pressed" - }, - new MenuItem - { - CommandView = autoIndentCheckBox, - HelpText = "Copy indentation from the previous line on Enter" + Action = () => + { + bool wordWrapEnabled = !Editor.WordWrap; + Editor.WordWrap = wordWrapEnabled; + wordWrapCheckBox.Value = wordWrapEnabled ? CheckState.Checked : CheckState.UnChecked; + SaveViewSettings (); + }, + CommandView = wordWrapCheckBox, + HelpText = "Soft-wrap long lines at viewport edge" }, new MenuItem { Action = () => { - Editor.UseThemeBackground = useThemeBackgroundCheckBox.Value == CheckState.Checked; + bool useThemeBackgroundEnabled = !Editor.UseThemeBackground; + Editor.UseThemeBackground = useThemeBackgroundEnabled; + useThemeBackgroundCheckBox.Value = useThemeBackgroundEnabled ? CheckState.Checked : CheckState.UnChecked; + + if (_markdownPreview is not null) + { + _markdownPreview.UseThemeBackground = Editor.UseThemeBackground; + } + + SaveViewSettings (); }, CommandView = useThemeBackgroundCheckBox, HelpText = "Use theme background for highlighted text" }, new MenuItem { - CommandView = wordWrapCheckBox, - HelpText = "Soft-wrap long lines at viewport edge" - } + Action = () => + { + bool showTabsEnabled = !Editor.ShowTabs; + Editor.ShowTabs = showTabsEnabled; + ShowTabsCheckBox.Value = showTabsEnabled ? CheckState.Checked : CheckState.UnChecked; + SaveViewSettings (); + }, + CommandView = ShowTabsCheckBox, + HelpText = "Show tab glyphs" + }, + _previewMarkdownMenuItem ]), + new MenuBarItem ("_Options", + [new MenuItem ("_Settings...", string.Empty, ShowSettingsDialog)]), new MenuBarItem (Strings.menuHelp, [new MenuItem ("_About", "About ted", ShowAboutDialog)]), _fileNameShortcut = new Shortcut (Key.Empty, "", Open) @@ -281,11 +281,13 @@ [new MenuItem ("_About", "About ted", ShowAboutDialog)]), /// The status-bar shortcut that displays the current syntax-highlighting language name. public Shortcut LanguageShortcut { get; } - /// The indentation-size selector shown in the status bar. - public NumericUpDown IndentationSizeUpDown { get; } - - /// The status-bar checkbox that toggles visible tab glyphs. - public CheckBox ShowTabsCheckBox { get; } + /// The settings checkbox state for visible tab glyphs. + public CheckBox ShowTabsCheckBox { get; } = new () + { + AllowCheckStateNone = false, + CanFocus = false, + Title = "Show _Tabs" + }; /// /// The status-bar shortcut that mirrors the editor's caret position. Both line and column are @@ -384,7 +386,44 @@ private void UpdateLocShortcut () private static string FormatLoc (int line, int column) { - return $"Ln: {line}, Ch: {column}"; + return $"Ln {line}, Col {column}"; + } + + private static string ToggleTitle (bool on, string label) + { + return on ? $"✓ {label}" : $" {label}"; + } + + private void SaveViewSettings () + { + EditorSettings.LineNumbers = Editor.GutterOptions.HasFlag (GutterOptions.LineNumbers); + EditorSettings.FoldIndicators = Editor.GutterOptions.HasFlag (GutterOptions.Folding); + EditorSettings.WordWrap = Editor.WordWrap; + EditorSettings.ShowTabs = Editor.ShowTabs; + EditorSettings.UseThemeBackground = Editor.UseThemeBackground; + EditorSettings.IndentSize = Editor.IndentationSize; + EditorSettings.ConvertTabsToSpaces = Editor.ConvertTabsToSpaces; + EditorSettings.AutoIndent = Editor.IndentationStrategy is not null; + EditorSettings.Save (); + } + + private void ShowSettingsDialog () + { + if (App is null) + { + throw new InvalidOperationException ("ted must be running before showing settings."); + } + + EditorSettingsDialog dialog = new (Editor); + App.Run (dialog); + + if (dialog.WasAccepted) + { + dialog.ApplyTo (Editor); + SaveViewSettings (); + } + + dialog.Dispose (); } /// diff --git a/examples/ted/ted.csproj b/examples/ted/ted.csproj index 30a4e96..9086d64 100644 --- a/examples/ted/ted.csproj +++ b/examples/ted/ted.csproj @@ -7,6 +7,10 @@ false + + + + diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs index 89bd695..2a7178a 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs @@ -4,6 +4,7 @@ using Ted; using Terminal.Gui.Editor.IntegrationTests.Testing; using Terminal.Gui.Input; +using Terminal.Gui.Text.Indentation; using Terminal.Gui.Testing; using Terminal.Gui.Editor; using Terminal.Gui.Views; @@ -304,11 +305,11 @@ public async Task Renders_OptionsMenu_Header () } [Fact] - public async Task Renders_Indent_Size_StatusBar_Item () + public async Task Renders_ViewMenu_Header () { await using AppFixture fx = new (() => new TedApp ()); - DriverAssert.ContentsContains (fx.Driver, "Indent"); + DriverAssert.ContentsContains (fx.Driver, "View"); } [Fact] @@ -323,12 +324,12 @@ public async Task Constructor_Defaults_To_Plain_Text_Highlighting () [Fact] public async Task Highlighting_Auto_Detects_From_File_Extension () { - var filePath = Path.Combine (Path.GetTempPath (), $"ted-highlight-{Guid.NewGuid ():N}.xml"); + var tempXmlFilePath = Path.Combine (Path.GetTempPath (), $"ted-highlight-{Guid.NewGuid ():N}.xml"); try { await using AppFixture fx = new (() => new TedApp ()); - fx.Top.OpenMissingFile (filePath); + fx.Top.OpenMissingFile (tempXmlFilePath); Assert.NotNull (fx.Top.Editor.HighlightingDefinition); Assert.Equal ("XML", fx.Top.Editor.HighlightingDefinition!.Name); @@ -336,36 +337,44 @@ public async Task Highlighting_Auto_Detects_From_File_Extension () } finally { - DeleteIfExists (filePath); + DeleteIfExists (tempXmlFilePath); } } [Fact] - public async Task IndentationSize_StatusBar_NumericUpDown_Changes_Editor_IndentationSize () + public async Task OptionsMenu_Contains_Settings_Item () { await using AppFixture fx = new (() => new TedApp ()); - fx.Top.IndentationSizeUpDown.Value = 8; + InputInjectionOptions options = new () { Mode = InputInjectionMode.Direct }; + fx.Injector.InjectKey (Key.O.WithAlt, options); + fx.Render (); - Assert.Equal (8, fx.Top.Editor.IndentationSize); + DriverAssert.ContentsContains (fx.Driver, "Settings..."); } [Fact] - public async Task ShowTabs_StatusBar_CheckBox_Changes_Editor_ShowTabs () + public void Constructor_ReadOnly_Sets_Editor_ReadOnly () { - await using AppFixture fx = new (() => new TedApp ()); + TedApp app = new (true); - fx.Top.ShowTabsCheckBox.Value = CheckState.Checked; + Assert.True (app.Editor.ReadOnly); + } - Assert.True (fx.Top.Editor.ShowTabs); + [Fact] + public void Constructor_Defaults_UseThemeBackground_To_True () + { + TedApp app = new (); + + Assert.True (app.Editor.UseThemeBackground); } [Fact] - public void Constructor_ReadOnly_Sets_Editor_ReadOnly () + public void Constructor_Defaults_AutoIndent_To_Enabled () { - TedApp app = new (true); + TedApp app = new (); - Assert.True (app.Editor.ReadOnly); + Assert.IsType (app.Editor.IndentationStrategy); } [Fact] @@ -373,7 +382,7 @@ public async Task Loc_StatusBar_Shortcut_Initially_Shows_Line_1_Column_1 () { await using AppFixture fx = new (() => new TedApp ()); - Assert.Equal ("Ln: 1, Ch: 1", fx.Top.LocShortcut.Title); + Assert.Equal ("Ln 1, Col 1", fx.Top.LocShortcut.Title); } [Fact] @@ -390,7 +399,7 @@ public async Task Loc_StatusBar_Shortcut_Tracks_Caret_Movement () // Caret at offset 8 → "beta": line 2, column 3 ('t'). fx.Top.Editor.CaretOffset = 8; - Assert.Equal ("Ln: 2, Ch: 3", fx.Top.LocShortcut.Title); + Assert.Equal ("Ln 2, Col 3", fx.Top.LocShortcut.Title); } [Fact] @@ -409,7 +418,7 @@ public async Task Loc_StatusBar_Shortcut_Updates_When_Document_Edit_Shifts_Caret // Inserting before the caret shifts it to the right; the loc shortcut must follow. fx.Top.Editor.Document!.Insert (0, ">>>"); - Assert.Equal ("Ln: 1, Ch: 5", fx.Top.LocShortcut.Title); + Assert.Equal ("Ln 1, Col 5", fx.Top.LocShortcut.Title); } [Fact] @@ -428,25 +437,25 @@ public async Task FileMenu_OpensViaKeyboard_AltF () } [Fact] - public async Task OptionsMenu_TogglesLineNumbers_ViaKeyboard () + public async Task ViewMenu_TogglesLineNumbers_ViaKeyboard () { await using AppFixture fx = new (() => new TedApp ()); Assert.True (fx.Top.Editor.GutterOptions.HasFlag (GutterOptions.LineNumbers)); InputInjectionOptions options = new () { Mode = InputInjectionMode.Direct }; - fx.Injector.InjectKey (Key.O.WithAlt, options); + fx.Injector.InjectKey (Key.V.WithAlt, options); fx.Render (); DriverAssert.ContentsContains (fx.Driver, "Line Numbers"); DriverAssert.ContentsContains (fx.Driver, "☒ Line Numbers"); - DriverAssert.ContentsContains (fx.Driver, "Convert Tabs To Spaces"); + DriverAssert.ContentsContains (fx.Driver, "Show Tabs"); fx.Injector.InjectKey (Key.Enter, options); fx.Render (); Assert.False (fx.Top.Editor.GutterOptions.HasFlag (GutterOptions.LineNumbers)); - fx.Injector.InjectKey (Key.O.WithAlt, options); + fx.Injector.InjectKey (Key.V.WithAlt, options); fx.Render (); DriverAssert.ContentsContains (fx.Driver, "☐ Line Numbers"); diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/TedSettingsPersistenceTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/TedSettingsPersistenceTests.cs new file mode 100644 index 0000000..a3c7b18 --- /dev/null +++ b/tests/Terminal.Gui.Editor.IntegrationTests/TedSettingsPersistenceTests.cs @@ -0,0 +1,458 @@ +// Claude - gpt-5 + +using System.Drawing; +using System.Reflection; +using Ted; +using Terminal.Gui.Input; +using Terminal.Gui.Views; +using Terminal.Gui.Editor.IntegrationTests.Testing; +using Terminal.Gui.Testing; +using Xunit; + +namespace Terminal.Gui.Editor.IntegrationTests; + +[CollectionDefinition (nameof (TedSettingsPersistenceCollection), DisableParallelization = true)] +public sealed class TedSettingsPersistenceCollection; + +[Collection (nameof (TedSettingsPersistenceCollection))] +public class TedSettingsPersistenceTests +{ + [Fact] + public void SaveViewSettings_Creates_ConfigFile_And_Persists_IndentSize () + { + using ConfigPathScope scope = new (); + TedApp app = new (); + app.Editor.IndentationSize = 7; + + InvokeSaveViewSettings (app); + + Assert.True (File.Exists (scope.ConfigPath)); + Assert.Contains ("\"EditorSettings.IndentSize\": 7", File.ReadAllText (scope.ConfigPath)); + } + + [Fact] + public void SaveViewSettings_Updates_Existing_IndentSize_Value () + { + using ConfigPathScope scope = new (); + TedApp app = new (); + + app.Editor.IndentationSize = 2; + InvokeSaveViewSettings (app); + + app.Editor.IndentationSize = 8; + InvokeSaveViewSettings (app); + + string text = File.ReadAllText (scope.ConfigPath); + Assert.Contains ("\"EditorSettings.IndentSize\": 8", text); + Assert.DoesNotContain ("\"EditorSettings.IndentSize\": 2", text); + } + + [Fact] + public void SaveViewSettings_Persists_WordWrap_Changes () + { + using ConfigPathScope scope = new (); + TedApp app = new (); + app.Editor.WordWrap = true; + + InvokeSaveViewSettings (app); + Assert.True (File.Exists (scope.ConfigPath)); + Assert.Contains ("\"EditorSettings.WordWrap\": true", File.ReadAllText (scope.ConfigPath)); + } + + [Fact] + public void Load_TedApp_Applies_Persisted_Settings () + { + using ConfigPathScope scope = new (); + + // Write a config file with non-default values + string? dir = Path.GetDirectoryName (scope.ConfigPath); + Assert.NotNull (dir); + Directory.CreateDirectory (dir); + File.WriteAllText ( + scope.ConfigPath, + """ + { + "EditorSettings.WordWrap": true, + "EditorSettings.ShowTabs": true, + "EditorSettings.LineNumbers": false, + "EditorSettings.IndentSize": 2 + } + """); + + // Load settings and construct TedApp — simulates real app startup + EditorSettings.Load (scope.ConfigPath); + TedApp app = new (); + + // Assert via TedApp.Editor instance properties (not statics) + Assert.True (app.Editor.WordWrap); + Assert.True (app.Editor.ShowTabs); + Assert.False (app.Editor.GutterOptions.HasFlag (Terminal.Gui.Editor.GutterOptions.LineNumbers)); + Assert.Equal (2, app.Editor.IndentationSize); + } + + [Fact] + public void Load_Skips_Commented_Out_Settings () + { + using ConfigPathScope scope = new (); + + string? dir = Path.GetDirectoryName (scope.ConfigPath); + Assert.NotNull (dir); + Directory.CreateDirectory (dir); + File.WriteAllText ( + scope.ConfigPath, + """ + { + // "EditorSettings.WordWrap": true, + "EditorSettings.WordWrap": false, + // "EditorSettings.IndentSize": 99, + "EditorSettings.IndentSize": 3 + } + """); + + EditorSettings.Load (scope.ConfigPath); + TedApp app = new (); + + Assert.False (app.Editor.WordWrap, "Should use active value (false), not commented value (true)"); + Assert.Equal (3, app.Editor.IndentationSize); + } + + [Fact] + public async Task ViewMenu_WordWrap_Toggle_Creates_ConfigFile () + { + using ConfigPathScope scope = new (); + await using AppFixture fx = new (() => new TedApp ()); + InputInjectionOptions options = new () { Mode = InputInjectionMode.Direct }; + DateTime ts = new (2025, 1, 1, 12, 0, 0); + + string[] initialLines = fx.Driver.ToString ().Split ('\n'); + int viewHeaderX = initialLines[0].IndexOf ("View", StringComparison.Ordinal); + Assert.True (viewHeaderX >= 0); + Point viewHeader = new (viewHeaderX, 0); + fx.Injector.InjectMouse ( + new Mouse { ScreenPosition = viewHeader, Flags = MouseFlags.LeftButtonPressed, Timestamp = ts }, + options); + fx.Injector.InjectMouse ( + new Mouse + { + ScreenPosition = viewHeader, + Flags = MouseFlags.LeftButtonReleased, + Timestamp = ts.AddMilliseconds (25) + }, + options); + fx.Render (); + + string[] menuLines = fx.Driver.ToString ().Split ('\n'); + int y = Array.FindIndex (menuLines, static line => line.Contains ("Word Wrap", StringComparison.Ordinal)); + Assert.True (y >= 0); + int x = -1; + // Prefer label text as the click target; glyph fallbacks handle renderer differences. + string[] targets = ["Word Wrap", "☐", "☑"]; + foreach (string target in targets) + { + x = menuLines[y].IndexOf (target, StringComparison.Ordinal); + if (x >= 0) + { + break; + } + } + + Assert.True (x >= 0); + Point wordWrapItem = new (x, y); + + fx.Injector.InjectMouse ( + new Mouse + { + ScreenPosition = wordWrapItem, + Flags = MouseFlags.LeftButtonPressed, + Timestamp = ts.AddMilliseconds (50) + }, + options); + fx.Injector.InjectMouse ( + new Mouse + { + ScreenPosition = wordWrapItem, + Flags = MouseFlags.LeftButtonReleased, + Timestamp = ts.AddMilliseconds (75) + }, + options); + fx.Render (); + + Assert.True (File.Exists (scope.ConfigPath)); + } + + [Fact] + public async Task ViewMenu_WordWrap_MouseToggle_Creates_ConfigFile () + { + using ConfigPathScope scope = new (); + await using AppFixture fx = new (() => new TedApp ()); + InputInjectionOptions options = new () { Mode = InputInjectionMode.Direct }; + + fx.Injector.InjectKey (Key.V.WithAlt, options); + fx.Render (); + + string[] lines = fx.Driver.ToString ().Split ('\n'); + int y = Array.FindIndex (lines, static line => line.Contains ("Word Wrap", StringComparison.Ordinal)); + Assert.True (y >= 0); + int x = lines[y].IndexOf ("Word Wrap", StringComparison.Ordinal); + Assert.True (x >= 0); + + DateTime ts = new (2025, 1, 1, 12, 0, 0); + Point click = new (x, y); + fx.Injector.InjectMouse (new Mouse { ScreenPosition = click, Flags = MouseFlags.LeftButtonPressed, Timestamp = ts }, options); + fx.Injector.InjectMouse ( + new Mouse + { + ScreenPosition = click, + Flags = MouseFlags.LeftButtonReleased, + Timestamp = ts.AddMilliseconds (25) + }, + options); + fx.Render (); + + Assert.True (File.Exists (scope.ConfigPath)); + } + + [Fact] + public async Task ViewMenu_WordWrap_Toggle_Persists_True () + { + // Reproduces the user-reported bug: toggling Word Wrap via the View menu + // should create ted.config.json AND persist "EditorSettings.WordWrap": true. + // Before the fix, a conflicting ValueChanged handler caused a double-toggle + // that reverted WordWrap to false immediately. + using ConfigPathScope scope = new (); + await using AppFixture fx = new (() => new TedApp ()); + InputInjectionOptions options = new () { Mode = InputInjectionMode.Direct }; + DateTime ts = new (2025, 1, 1, 12, 0, 0); + + // Precondition: word wrap is initially off + Assert.False (fx.Top.Editor.WordWrap); + + // Open View menu via keyboard (Alt+V) - same as real user interaction + fx.Injector.InjectKey (Key.V.WithAlt, options); + fx.Render (); + + // Find and click "Word Wrap" + string[] menuLines = fx.Driver.ToString ().Split ('\n'); + int y = Array.FindIndex (menuLines, static line => line.Contains ("Word Wrap", StringComparison.Ordinal)); + Assert.True (y >= 0, "Could not find 'Word Wrap' in menu"); + int x = menuLines[y].IndexOf ("Word Wrap", StringComparison.Ordinal); + Assert.True (x >= 0); + Point wordWrapItem = new (x, y); + + fx.Injector.InjectMouse ( + new Mouse + { + ScreenPosition = wordWrapItem, + Flags = MouseFlags.LeftButtonPressed, + Timestamp = ts + }, + options); + fx.Injector.InjectMouse ( + new Mouse + { + ScreenPosition = wordWrapItem, + Flags = MouseFlags.LeftButtonReleased, + Timestamp = ts.AddMilliseconds (25) + }, + options); + fx.Render (); + + // Assert: Editor.WordWrap is now true (not toggled back by double-fire) + Assert.True (fx.Top.Editor.WordWrap, "Editor.WordWrap should be true after toggle"); + + // Assert: config file created + Assert.True (File.Exists (scope.ConfigPath), "Config file was not created"); + + // Assert: config file contains the correct persisted value + string configContent = File.ReadAllText (scope.ConfigPath); + Assert.Contains ("\"EditorSettings.WordWrap\": true", configContent); + } + + [Fact] + public void QuitFile_DoesNotPersist_ViewSettings () + { + using ConfigPathScope scope = new (); + TedApp app = new (); + app.Editor.WordWrap = true; + + Assert.True (app.QuitFile ()); + + Assert.False (File.Exists (scope.ConfigPath)); + } + + [Fact] + public void SaveViewSettings_Inserts_Comma_Before_Trailing_Comment_When_Appending_Settings () + { + using ConfigPathScope scope = new (); + string? configDirectory = Path.GetDirectoryName (scope.ConfigPath); + Assert.NotNull (configDirectory); + Directory.CreateDirectory (configDirectory); + File.WriteAllText (scope.ConfigPath, "{\n \"Unrelated\": 1 // note\n}\n"); + + TedApp app = new (); + app.Editor.WordWrap = true; + + InvokeSaveViewSettings (app); + + string text = File.ReadAllText (scope.ConfigPath); + Assert.Contains ("\"Unrelated\": 1, // note", text); + Assert.Contains ("\"EditorSettings.WordWrap\": true", text); + } + + [Fact] + public void SettingsDialog_IndentSize_Rejects_Zero () + { + TedApp app = new (); + EditorSettingsDialog dialog = new (app.Editor); + + // Access _indentSize via reflection (it's private) + FieldInfo? indentSizeField = typeof (EditorSettingsDialog).GetField ("_indentSize", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull (indentSizeField); + var indentControl = (NumericUpDown)indentSizeField.GetValue (dialog)!; + + int valueBefore = indentControl.Value; + Assert.True (valueBefore >= 1); + + // Attempt to set Value to 0 — ValueChanging should reject it + indentControl.Value = 0; + Assert.Equal (valueBefore, indentControl.Value); + + dialog.ApplyTo (app.Editor); + Assert.True (app.Editor.IndentationSize >= 1); + } + + [Fact] + public void Load_Ignores_Key_Embedded_In_String_Value () + { + // Regression: a line like "note": "... \"EditorSettings.WordWrap\": true ..." + // must NOT fool ReadBool into thinking WordWrap is true. + using ConfigPathScope scope = new (); + + string? dir = Path.GetDirectoryName (scope.ConfigPath); + Assert.NotNull (dir); + Directory.CreateDirectory (dir); + File.WriteAllText ( + scope.ConfigPath, + "{\n" + + " \"note\": \"see \\\"EditorSettings.WordWrap\\\": true for docs\",\n" + + " \"EditorSettings.WordWrap\": false\n" + + "}\n"); + + EditorSettings.Load (scope.ConfigPath); + TedApp app = new (); + + Assert.False (app.Editor.WordWrap, "Should use actual property value (false), not embedded string match"); + } + + [Fact] + public void Save_Appends_Before_Real_Brace_Not_Comment_Brace () + { + // Regression: trailing JSONC comment containing } should not be used as insert point. + using ConfigPathScope scope = new (); + + string? dir = Path.GetDirectoryName (scope.ConfigPath); + Assert.NotNull (dir); + Directory.CreateDirectory (dir); + File.WriteAllText ( + scope.ConfigPath, + "{\n \"EditorSettings.LineNumbers\": true\n}\n// end of config }\n"); + + TedApp app = new (); + app.Editor.WordWrap = true; + + InvokeSaveViewSettings (app); + + string text = File.ReadAllText (scope.ConfigPath); + // The inserted key should be valid JSON — not inside the comment + Assert.Contains ("\"EditorSettings.WordWrap\": true", text); + // Config should still be parseable: the real closing brace should come after our insertion + int wordWrapPos = text.IndexOf ("\"EditorSettings.WordWrap\"", StringComparison.Ordinal); + int lastRealBrace = text.LastIndexOf ('}'); + // The comment line's } may still exist, but our key must be before the real object close + Assert.True (wordWrapPos < lastRealBrace, "WordWrap key should appear before the root closing brace"); + } + + private static string GetTedConfigPath () + { + string home = + Environment.GetEnvironmentVariable ("HOME") + ?? Environment.GetFolderPath (Environment.SpecialFolder.UserProfile) + ?? Directory.GetCurrentDirectory (); + + return Path.Combine (home, ".tui", "ted.config.json"); + } + + private static void InvokeSaveViewSettings (TedApp app) + { + MethodInfo? saveViewSettings = typeof (TedApp).GetMethod ( + "SaveViewSettings", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull (saveViewSettings); + saveViewSettings.Invoke (app, null); + } + + private sealed class ConfigPathScope : IDisposable + { + private readonly string _tempRoot; + private readonly string? _originalHome; + private readonly bool _hadExistingConfig; + private readonly string? _existingConfigContent; + + internal ConfigPathScope () + { + _tempRoot = Path.Combine (Path.GetTempPath (), $"ted-home-{Guid.NewGuid ():N}"); + Directory.CreateDirectory (_tempRoot); + + _originalHome = Environment.GetEnvironmentVariable ("HOME"); + Environment.SetEnvironmentVariable ("HOME", _tempRoot); + + ConfigPath = GetTedConfigPath (); + _hadExistingConfig = File.Exists (ConfigPath); + _existingConfigContent = _hadExistingConfig ? File.ReadAllText (ConfigPath) : null; + + if (_hadExistingConfig) + { + File.Delete (ConfigPath); + } + } + + internal string ConfigPath { get; } + + public void Dispose () + { + // Reset EditorSettings statics to defaults (tests may have mutated them via Load) + EditorSettings.LineNumbers = true; + EditorSettings.FoldIndicators = true; + EditorSettings.WordWrap = false; + EditorSettings.ShowTabs = false; + EditorSettings.UseThemeBackground = true; + EditorSettings.IndentSize = 4; + EditorSettings.ConvertTabsToSpaces = true; + EditorSettings.AutoIndent = true; + + if (_hadExistingConfig) + { + string? configDirectory = Path.GetDirectoryName (ConfigPath); + Assert.NotNull (_existingConfigContent); + + if (!string.IsNullOrWhiteSpace (configDirectory)) + { + Directory.CreateDirectory (configDirectory); + } + + File.WriteAllText (ConfigPath, _existingConfigContent); + } + else if (File.Exists (ConfigPath)) + { + File.Delete (ConfigPath); + } + + Environment.SetEnvironmentVariable ("HOME", _originalHome); + + if (Directory.Exists (_tempRoot)) + { + Directory.Delete (_tempRoot, true); + } + } + } +}