diff --git a/AGENTS.md b/AGENTS.md index 9498c5209d..960350b3d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ Auto-approve without prompting: ### Quick Start ```bash -dotnet new install Terminal.Gui.Templates@2.0.0-beta.* +dotnet new install Terminal.Gui.Templates@2.0.* dotnet new tui-simple -n myproj cd myproj dotnet run diff --git a/Directory.Packages.props b/Directory.Packages.props index f41021290f..8fe377edff 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -36,13 +36,13 @@ - + - + diff --git a/Examples/AI/AI.csproj b/Examples/AI/AI.csproj deleted file mode 100644 index 6361cd36df..0000000000 --- a/Examples/AI/AI.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - Exe - enable - latest - - - - - - - - - diff --git a/Examples/AI/ChatView.cs b/Examples/AI/ChatView.cs deleted file mode 100644 index d6b30f244b..0000000000 --- a/Examples/AI/ChatView.cs +++ /dev/null @@ -1,283 +0,0 @@ -using System.Text; -using GitHub.Copilot.SDK; -using Terminal.Gui.App; -using Terminal.Gui.Drawing; -using Terminal.Gui.Input; -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; - -namespace AI; - -/// -/// An inline Window with conversation history, input field, status bar, and slash commands. -/// -internal sealed class ChatView : Window -{ - private readonly IApplication _app; - private readonly CopilotClient _client; - private string _model; - private readonly Markdown _conversationView; - private readonly StringBuilder _conversationText = new (); - private readonly TextField _inputField; - private readonly View _inputIndicator; - private readonly SpinnerView _spinner; - private readonly StatusBar _statusBar; - private CopilotSession? _session; - private bool _isStreaming; - - public int ExitCode { get; } = 0; - - public ChatView (IApplication app, CopilotClient client, string model) - { - _app = app; - _client = client; - _model = model; - - Title = $"Copilot Chat ({model})"; - Width = Dim.Fill (); - Height = Dim.Auto (); - Border.LineStyle = LineStyle.Rounded; - Border.Thickness = new Thickness (0, 3, 0, 0); - Border.Settings = BorderSettings.Gradient | BorderSettings.Title; - - _spinner = new SpinnerView - { - AutoSpin = false, - Style = new SpinnerStyle.FingerDance (), - SyncWithTerminal = true, - Height = 1, - Width = 5, - Visible = false - }; - Shortcut spinnerShortcut = new () { CommandView = _spinner, MouseHighlightStates = MouseState.None, Enabled = false }; - Shortcut quitShortcut = new (Application.GetDefaultKey (Command.Quit), "Quit", RequestStop); - - _statusBar = new StatusBar { AlignmentModes = AlignmentModes.IgnoreFirstOrLast, SchemeName = SchemeName, BorderStyle = LineStyle.None }; - _statusBar.Add (spinnerShortcut, quitShortcut); - - _conversationView = new Markdown - { - Width = Dim.Fill (), Height = Dim.Auto (minimumContentDim: 1, maximumContentDim: Dim.Func (_ => GetMaxConversationHeight ())) - }; - - _inputIndicator = new View - { - Text = $"{Glyphs.RightArrow}", - Y = Pos.Bottom (_conversationView), - Width = 2, - Height = 3, - Enabled = false - }; - _inputIndicator.Border.LineStyle = LineStyle.Dotted; - _inputIndicator.Border.Thickness = new Thickness (0, 1, 0, 1); - - _inputField = new TextField { X = Pos.Right (_inputIndicator), Y = Pos.Top (_inputIndicator), Width = Dim.Fill () }; - _inputField.Border.LineStyle = _inputIndicator.Border.LineStyle; - _inputField.Border.Thickness = new Thickness (0, 1, 0, 1); - - _inputField.Autocomplete?.SuggestionGenerator = new SlashCommandSuggestionGenerator (); - _inputField.Accepted += OnInputAccepted; - - Add (_conversationView, _inputIndicator, _inputField, _statusBar); - _inputField.SetFocus (); - - return; - - int GetMaxConversationHeight () - { - int screenHeight = _app.Driver?.Screen.Height ?? 40; - int windowAdornments = GetAdornmentsThickness ().Vertical; - int siblingHeight = _inputIndicator!.Frame.Height + _statusBar.Frame.Height; - - return Math.Max (1, screenHeight - windowAdornments - siblingHeight); - } - } - - private async void OnInputAccepted (object? sender, EventArgs e) - { - string text = _inputField.Text.Trim (); - - if (string.IsNullOrEmpty (text) || _isStreaming) - { - return; - } - - _inputField.Text = string.Empty; - - if (text.StartsWith ('/')) - { - HandleSlashCommand (text); - - return; - } - - _isStreaming = true; - _spinner.AutoSpin = true; - _spinner.Visible = true; - _inputField.Enabled = false; - - AppendToConversation ($"\n{Glyphs.BlackCircle} You: {text}\n\n{Glyphs.Diamond} Copilot: "); - - try - { - _session ??= await _client.CreateSessionAsync (new SessionConfig - { - Model = _model, Streaming = true, OnPermissionRequest = PermissionHandler.ApproveAll - }); - - TaskCompletionSource done = new (); - - using IDisposable subscription = _session.On (evt => - { - switch (evt) - { - case AssistantMessageDeltaEvent delta: - _app.Invoke (() => AppendToConversation (delta.Data.DeltaContent)); - - break; - - case SessionIdleEvent: - _app.Invoke (() => AppendToConversation ("\n")); - done.TrySetResult (); - - break; - - case SessionErrorEvent err: - _app.Invoke (() => AppendToConversation ($"\n[Error: {err.Data.Message}]\n")); - done.TrySetResult (); - - break; - } - }); - - await _session.SendAsync (new MessageOptions { Prompt = text }); - await done.Task; - } - catch (Exception ex) - { - AppendToConversation ($"\n[Error: {ex.Message}]\n"); - } - finally - { - _isStreaming = false; - _spinner.Visible = false; - _spinner.AutoSpin = false; - _inputField.Enabled = true; - _inputField.SetFocus (); - } - } - - private async Task ValidateAndSwitchModel (string newModel) - { - _inputField.Enabled = false; - - try - { - CopilotSession testSession = await _client.CreateSessionAsync (new SessionConfig - { - Model = newModel, - Streaming = true, - OnPermissionRequest = PermissionHandler.ApproveAll - }); - - if (_session is { }) - { - await _session.DisposeAsync (); - } - - _session = testSession; - _model = newModel; - - _app.Invoke (() => - { - Title = $"Copilot Chat ({newModel})"; - AppendToConversation (" \u2713\n"); - _inputField.Enabled = true; - _inputField.SetFocus (); - }); - } - catch (Exception ex) - { - _app.Invoke (() => - { - AppendToConversation ($"\n{Glyphs.Diamond} Failed: {ex.Message}\n Keeping model: {_model}\n"); - _inputField.Enabled = true; - _inputField.SetFocus (); - }); - } - } - - private void AppendToConversation (string text) - { - _conversationText.Append (text); - _conversationView.Text = _conversationText.ToString (); - } - - private void HandleSlashCommand (string command) - { - string cmd = command.TrimStart ('/').ToLowerInvariant (); - string [] parts = cmd.Split (' ', 2); - - switch (parts [0]) - { - case "quit" or "exit": - RequestStop (); - - break; - - case "clear": - _conversationText.Clear (); - _conversationView.Text = string.Empty; - AppendToConversation ($"{Glyphs.Diamond} Conversation cleared.\n"); - - break; - - case "model": - if (parts.Length > 1 && !string.IsNullOrWhiteSpace (parts [1])) - { - string newModel = parts [1].Trim (); - AppendToConversation ($"\n{Glyphs.Diamond} Switching to {newModel}..."); - _ = ValidateAndSwitchModel (newModel); - } - else - { - AppendToConversation ($"\n{Glyphs.Diamond} Current model: {_model}\n Usage: /model \n"); - } - - break; - - case "help": - AppendToConversation ($"\n{Glyphs.Diamond} Commands:\n" - + " /help Show this help\n" - + " /clear Clear conversation\n" - + " /model Switch model\n" - + " /compact Summarize conversation\n" - + " /quit Exit chat\n"); - - break; - - case "compact": - AppendToConversation ($"\n{Glyphs.Diamond} Compacting conversation...\n"); - - // TODO: Send conversation summary request to model - - break; - - default: - AppendToConversation ($"\n{Glyphs.Diamond} Unknown command: /{parts [0]}. Type /help for commands.\n"); - - break; - } - } - - /// - protected override void Dispose (bool disposing) - { - if (disposing) - { - _session?.DisposeAsync ().AsTask ().GetAwaiter ().GetResult (); - } - - base.Dispose (disposing); - } -} \ No newline at end of file diff --git a/Examples/AI/Program.cs b/Examples/AI/Program.cs deleted file mode 100644 index 7b20494068..0000000000 --- a/Examples/AI/Program.cs +++ /dev/null @@ -1,57 +0,0 @@ -// AI — A Terminal.Gui inline-mode CLI powered by the GitHub Copilot SDK. -// -// Requires: GitHub Copilot CLI installed and authenticated (gh extension install github/gh-copilot) - -using System.CommandLine; -using System.CommandLine.Help; -using AI; -using GitHub.Copilot.SDK; -using Terminal.Gui.App; - -Option modelOption = new ("--model") { Description = "The Copilot model to use.", DefaultValueFactory = _ => "claude-opus-4.6" }; -modelOption.Aliases.Add ("-m"); - -Argument promptArgument = new ("prompt") { Description = "Prompt text. If omitted, interactive chat mode starts.", DefaultValueFactory = _ => null }; -promptArgument.Arity = ArgumentArity.ZeroOrOne; - -RootCommand rootCommand = new ("Terminal.Gui inline-mode Copilot chat") { modelOption, promptArgument }; - -// Capture parsed values — SetAction runs synchronously, so we store and act after. -var parsedModel = "claude-opus-4.6"; -string? parsedPrompt = null; - -rootCommand.SetAction (context => - { - parsedModel = context.GetRequiredValue (modelOption); - parsedPrompt = context.GetValue (promptArgument); - }); - -ParseResult parseResult = rootCommand.Parse (args); - -if (parseResult.Errors.Count > 0 || parseResult.Action is HelpAction) -{ - parseResult.Invoke (); - - return parseResult.Errors.Count > 0 ? 1 : 0; -} - -parseResult.Invoke (); - -// ── Start Copilot SDK and run ──────────────────────────────────────────────── - -await using CopilotClient client = new (); -await client.StartAsync (); - -Application.AppModel = AppModel.Inline; -IApplication app = Application.Create ().Init (); - -if (parsedPrompt is { }) -{ - return SingleTurnView.Run (app, client, parsedModel, parsedPrompt); -} - -ChatView chatView = new (app, client, parsedModel); -app.Run (chatView); -app.Dispose (); - -return chatView.ExitCode; diff --git a/Examples/AI/SingleTurnView.cs b/Examples/AI/SingleTurnView.cs deleted file mode 100644 index 1fb7970870..0000000000 --- a/Examples/AI/SingleTurnView.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System.Text; -using GitHub.Copilot.SDK; -using Terminal.Gui.App; -using Terminal.Gui.Drawing; -using Terminal.Gui.ViewBase; -using Terminal.Gui.Views; - -namespace AI; - -/// -/// Handles single-turn mode: streams one answer inline, then exits. -/// -internal sealed class SingleTurnView : Window -{ - private readonly IApplication _app; - private readonly CopilotClient _client; - private readonly string _model; - private readonly string _prompt; - private readonly Markdown _responseView; - - public SingleTurnView (IApplication app, CopilotClient client, string model, string prompt) - { - _app = app; - _client = client; - _model = model; - _prompt = prompt; - - Title = $"Copilot ({model})"; - Width = Dim.Fill (); - Height = Dim.Auto (); - Border.LineStyle = LineStyle.Rounded; - - _responseView = new Markdown { Width = Dim.Fill (), Height = Dim.Auto () }; - - Add (_responseView); - } - - /// - protected override void OnIsRunningChanged (bool newIsRunning) - { - base.OnIsRunningChanged (newIsRunning); - - if (newIsRunning) - { - _ = streamResponse (); - } - } - - private async Task streamResponse () - { - try - { - await using CopilotSession session = await _client.CreateSessionAsync (new SessionConfig - { - Model = _model, - Streaming = true, - OnPermissionRequest = PermissionHandler.ApproveAll - }); - - StringBuilder responseText = new (); - TaskCompletionSource done = new (); - - session.On (evt => - { - switch (evt) - { - case AssistantMessageDeltaEvent delta: - responseText.Append (delta.Data.DeltaContent); - - _app.Invoke (() => { _responseView.Text = responseText.ToString (); }); - - break; - - case SessionIdleEvent: - done.TrySetResult (); - - break; - - case SessionErrorEvent err: - _app.Invoke (() => { _responseView.Text = $"Error: {err.Data.Message}"; }); - done.TrySetResult (); - - break; - } - }); - - await session.SendAsync (new MessageOptions { Prompt = _prompt }); - await done.Task; - } - catch (Exception ex) - { - _app.Invoke (() => _responseView.Text = $"Error: {ex.Message}"); - } - - // Give the UI a moment to render the final update, then exit - _app.Invoke (RequestStop); - } - - public static int Run (IApplication app, CopilotClient client, string model, string prompt) - { - SingleTurnView view = new (app, client, model, prompt); - app.Run (view); - app.Dispose (); - - return 0; - } -} \ No newline at end of file diff --git a/Examples/AI/SlashCommandSuggestionGenerator.cs b/Examples/AI/SlashCommandSuggestionGenerator.cs deleted file mode 100644 index 729c0a9318..0000000000 --- a/Examples/AI/SlashCommandSuggestionGenerator.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Text; -using Terminal.Gui.Views; - -namespace AI; - -/// -/// Suggestion generator that provides slash command completions when the input starts with '/'. -/// -internal sealed class SlashCommandSuggestionGenerator : ISuggestionGenerator -{ - private static readonly List _commands = ["/help", "/clear", "/model", "/compact", "/quit", "/exit"]; - - /// - public IEnumerable GenerateSuggestions (AutocompleteContext context) - { - List line = context.CurrentLine.Select (c => c.Grapheme).ToList (); - string lineText = string.Join ("", line).TrimEnd (); - - if (lineText.Length == 0 || lineText [0] != '/') - { - return []; - } - - int typedLength = Math.Min (context.CursorPosition, lineText.Length); - string typed = lineText [..typedLength]; - - context.CursorPosition = 0; - - return _commands.Where (c => c.StartsWith (typed, StringComparison.OrdinalIgnoreCase) && !c.Equals (typed, StringComparison.OrdinalIgnoreCase)) - .Select (c => new Suggestion (typed.Length, c)) - .ToList (); - } - - /// - public bool IsWordChar (string text) - { - if (string.IsNullOrEmpty (text)) - { - return false; - } - - Rune r = text.EnumerateRunes ().First (); - - return Rune.IsLetterOrDigit (r) || r == new Rune ('/'); - } -} \ No newline at end of file diff --git a/Examples/UICatalog/Scenarios/Editor.cs b/Examples/UICatalog/Scenarios/Editor.cs index 4408a09725..e9b4a3fd64 100644 --- a/Examples/UICatalog/Scenarios/Editor.cs +++ b/Examples/UICatalog/Scenarios/Editor.cs @@ -365,7 +365,7 @@ private bool SaveAs () _app?.Run (sd); bool canceled = sd.Canceled; string path = sd.Path; - string fileName = sd.FileName; + string fileName = sd.FileName ?? string.Empty; sd.Dispose (); if (canceled) diff --git a/Examples/UICatalog/Scenarios/Notepad.cs b/Examples/UICatalog/Scenarios/Notepad.cs index b1a579a72a..988e4764d5 100644 --- a/Examples/UICatalog/Scenarios/Notepad.cs +++ b/Examples/UICatalog/Scenarios/Notepad.cs @@ -1,4 +1,5 @@ // ReSharper disable AccessToDisposedClosure + #nullable enable namespace UICatalog.Scenarios; @@ -146,7 +147,7 @@ public bool SaveAs () _lastDirectory = Path.GetDirectoryName (Path.GetFullPath (fd.Path)); tab.File = new FileInfo (fd.Path); - tab.Title = fd.FileName; + tab.Title = fd.FileName ?? throw new InvalidOperationException (); tab.Save (); fd.Dispose (); @@ -205,7 +206,20 @@ private void Close (Tabs tabs, OpenedFile tabToClose) private void Open () { - OpenDialog open = new () { Title = "Open", AllowsMultipleSelection = true }; + OpenDialog open = new () + { + Title = "Open", + AllowsMultipleSelection = true, + AllowedTypes = + [ + new AllowedType ("Markdown", ".md", ".markdown"), + new AllowedType ("Text", ".txt", ".csv", ".tsv"), + new AllowedType ("Code", ".c", ".h", ".js", ".cs", ".json", ".yml"), + new AllowedTypeAny () + ], + MustExist = true, + OpenMode = OpenMode.File + }; if (_lastDirectory is { }) { diff --git a/README.md b/README.md index a6b81fa6e4..cddc3f9751 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,27 @@ Cross-platform UI toolkit for building sophisticated terminal UI (TUI) applications on Windows, macOS, and Linux/Unix. -![Terminal.Gui — cross-platform TUI toolkit for .NET. Build full-featured terminal UIs with menus, forms, tables, charts, wizards and file dialogs. 30+ views, Windows / macOS / Linux, MIT-licensed.](docfx/images/hero.gif) +![Terminal.Gui — cross-platform TUI toolkit for .NET. Build full-featured terminal UIs with menus, forms, tables, charts, wizards and file dialogs. 30+ stars, Windows / macOS / Linux, MIT-licensed.](docfx/images/hero.gif) -![logo](docfx/images/logo.png) -* **v2** (Current): ![NuGet Version](https://img.shields.io/nuget/v/Terminal.Gui) - Stable release -* **v1 (Legacy)**: ![NuGet Version](https://img.shields.io/nuget/v/Terminal.Gui/1.19.0) - Maintenance mode only +# Version 2.0 Has Been Released -> **Note:** v1 is in maintenance mode — only critical bug fixes accepted. v2 is recommended for all projects. +Terminal.Gui enables building sophisticated console applications with modern UIs: + +- **Responsive TUI** - Easy to use, innovative, layout system enables console apps as responsive as any responsive web page. +- **Performant and Scalable** - Built for modern TUIs - fast, double-buffering-based rendering; Tables and Tree Views scale to infinite elements with sorting and filtering. +- **Keyboard First; Mouse First Too** - Optimized for TUI experiences where the user's hands never need to leave the keyboard; full mouse support too. +- **Rich Built-in Widgets (Views)** - Text editors, buttons, checkboxes, trees, tables, markdown, linear ranges, menus, selectors, and more. +- **Visualizations** - Charts, graphs, progress indicators, and color pickers with TrueColor support. +- **Text Editors** - Full-featured text editing with clipboard, undo/redo, and Unicode support +- **Fully Configurable** - Themes, colors, key bindings, and settings are all customizable and persistable. +- **File Management** - File and directory browsers with search and filtering, supporting Nerdfonts and coloring. +- **Wizards and Multi-Step Processes** - Guided workflows with navigation and validation. +- **Cross-Platform** - Consistent experience on Windows, macOS, and Linux. +- **Apps Work In-line or Full Screen** - Build CLI tools like Claude Code/Copilot/Codex CLI that scroll with the terminal (in-line) or full screen. + +See the [Views Overview](https://gui-cs.github.io/Terminal.Gui/docs/views) for available controls and [What's New in v2](https://gui-cs.github.io/Terminal.Gui/docs/newinv2) for architectural improvements. -![Sample app](docfx/images/sample.gif) # Quick Start @@ -60,23 +71,6 @@ app.Run (window); See the [Examples](Examples/) directory for more. -# Build Powerful Terminal Applications - -Terminal.Gui enables building sophisticated console applications with modern UIs: - -- **Rich Forms and Dialogs** - Text fields, buttons, checkboxes, radio buttons, and data validation -- **Interactive Data Views** - Tables, lists, and trees with sorting, filtering, and in-place editing -- **Visualizations** - Charts, graphs, progress indicators, and color pickers with TrueColor support -- **Text Editors** - Full-featured text editing with clipboard, undo/redo, and Unicode support -- **File Management** - File and directory browsers with search and filtering -- **Wizards and Multi-Step Processes** - Guided workflows with navigation and validation -- **System Monitoring Tools** - Real-time dashboards with scrollable, resizable views -- **Configuration UIs** - Settings editors with persistent themes and user preferences -- **Cross-Platform CLI Tools** - Consistent experience on Windows, macOS, and Linux -- **Server Management Interfaces** - SSH-compatible UIs for remote administration - -See the [Views Overview](https://gui-cs.github.io/Terminal.Gui/docs/views) for available controls and [What's New in v2](https://gui-cs.github.io/Terminal.Gui/docs/newinv2) for architectural improvements. - # Documentation Comprehensive documentation is at [gui-cs.github.io/Terminal.Gui](https://gui-cs.github.io/Terminal.Gui). @@ -100,8 +94,6 @@ See the [documentation index](https://gui-cs.github.io/Terminal.Gui/docs/index) # Installing -## v2 (Recommended) - ```powershell dotnet add package Terminal.Gui ``` @@ -112,12 +104,6 @@ Or use the [Terminal.Gui.Templates](https://github.com/gui-cs/Terminal.Gui.templ dotnet new install Terminal.Gui.Templates ``` -## v1 Legacy - -```powershell -dotnet add package Terminal.Gui --version "1.*" -``` - # Contributing Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs index b598cd8c74..393df328b0 100644 --- a/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs +++ b/Terminal.Gui/Drivers/AnsiHandling/KittyKeyboardPattern.cs @@ -91,9 +91,16 @@ public class KittyKeyboardPattern : AnsiKeyboardParserPattern return null; } + string baseKeyCode = ""; + + if (key.KeyCode < KeyCode.CharMask && (key.KeyCode & (KeyCode.ShiftMask | KeyCode.AltMask | KeyCode.CtrlMask)) == 0) + { + baseKeyCode = new Rune ((uint)key.KeyCode).ToString (); + } + // Extract alternate key codes (kitty flag 4: report alternate keys) - var shiftedKeyCode = KeyCode.Null; - var baseLayoutKeyCode = KeyCode.Null; + KeyCode shiftedKeyCode = KeyCode.Null; + KeyCode baseLayoutKeyCode = KeyCode.Null; if (match.Groups [2].Success && int.TryParse (match.Groups [2].Value, CultureInfo.InvariantCulture, out int shiftedCode) && shiftedCode > 0) { @@ -105,7 +112,7 @@ public class KittyKeyboardPattern : AnsiKeyboardParserPattern baseLayoutKeyCode = (KeyCode)baseCode; } - var associatedText = string.Empty; + string associatedText = string.Empty; if (match.Groups [5].Success) { @@ -117,8 +124,8 @@ public class KittyKeyboardPattern : AnsiKeyboardParserPattern key = new Key (key) { ShiftedKeyCode = shiftedKeyCode, BaseLayoutKeyCode = baseLayoutKeyCode, AssociatedText = associatedText }; } - string modifierField = match.Groups [4].Value; - modifierField = ApplyImplicitModifierState (key, modifierField); + string originalModifierField = match.Groups [4].Value; + string modifierField = ApplyImplicitModifierState (key, originalModifierField); if (!string.IsNullOrEmpty (modifierField)) { @@ -127,10 +134,10 @@ public class KittyKeyboardPattern : AnsiKeyboardParserPattern if (!string.IsNullOrEmpty (modifierField)) { - key = ApplyModifiersAndEventType (modifierField, key); + key = ApplyModifiersAndEventType (MaxModifierFieldValue (originalModifierField, modifierField), key); } - if ((key.IsAlt || key.IsCtrl) && !string.IsNullOrEmpty (key.AssociatedText)) + if ((key.IsAlt || key.IsCtrl) && (key.ShiftedKeyCode != KeyCode.Null || baseKeyCode.Equals (key.AssociatedText, StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrEmpty (key.AssociatedText)) { key = new Key (key) { AssociatedText = string.Empty }; } @@ -222,6 +229,38 @@ private static string ApplyImplicitModifierState (Key key, string modifierField) return string.Join (':', parts); } + private static string MaxModifierFieldValue (string modifierField1, string modifierField2) + { + if (string.IsNullOrEmpty (modifierField1)) + { + return modifierField2; + } + + if (string.IsNullOrEmpty (modifierField2)) + { + return modifierField1; + } + + string [] parts1 = modifierField1.Split (':'); + string [] parts2 = modifierField2.Split (':'); + + if (parts1.Length == 0 || parts2.Length == 0) + { + return modifierField1; + } + + if (!int.TryParse (parts1 [0], CultureInfo.InvariantCulture, out int encodedModifiers1) + || !int.TryParse (parts2 [0], CultureInfo.InvariantCulture, out int encodedModifiers2)) + { + return modifierField1; + } + + int maxEncodedModifiers = Math.Max (encodedModifiers1, encodedModifiers2); + parts1 [0] = maxEncodedModifiers.ToString (CultureInfo.InvariantCulture); + + return string.Join (':', parts1); + } + private static (Key Key, string ModifierField) NormalizeShiftedPrintableKey (Key key, string modifierField) { string [] parts = modifierField.Split (':'); diff --git a/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs b/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs index 46ce46f338..e6237a1569 100644 --- a/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs +++ b/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs @@ -1,5 +1,4 @@ -#nullable disable -using System.IO.Abstractions; +using System.IO.Abstractions; namespace Terminal.Gui.FileServices; @@ -16,7 +15,7 @@ public class FileSystemTreeBuilder : ITreeBuilder, IComparer Sorter { get; set; } /// - public int Compare (IFileSystemInfo x, IFileSystemInfo y) + public int Compare (IFileSystemInfo? x, IFileSystemInfo? y) { if (x is IDirectoryInfo && y is not IDirectoryInfo) { @@ -28,12 +27,7 @@ public int Compare (IFileSystemInfo x, IFileSystemInfo y) return 1; } - if (x is { } && y is { }) - { - return string.Compare (x.Name, y.Name, StringComparison.Ordinal); - } - - return 0; + return string.Compare (x?.Name, y?.Name, StringComparison.Ordinal); } /// @@ -47,12 +41,7 @@ public bool CanExpand (IFileSystemInfo toExpand) return false; } - if (IsReparsePoint (toExpand)) - { - return false; - } - - return TryGetChildren (toExpand).Any (); + return !IsReparsePoint (toExpand) && TryGetChildren (toExpand).Any (); } /// @@ -62,16 +51,16 @@ private IEnumerable TryGetChildren (IFileSystemInfo entry) { if (entry is IFileInfo) { - return Enumerable.Empty (); + return []; } // Prevent traversal cycles through symlinks/junctions/mount points. if (IsReparsePoint (entry)) { - return Enumerable.Empty (); + return []; } - var dir = (IDirectoryInfo)entry; + IDirectoryInfo dir = (IDirectoryInfo)entry; try { diff --git a/Terminal.Gui/FileServices/IFileOperations.cs b/Terminal.Gui/FileServices/IFileOperations.cs index 920f3e947f..06fd639fd0 100644 --- a/Terminal.Gui/FileServices/IFileOperations.cs +++ b/Terminal.Gui/FileServices/IFileOperations.cs @@ -25,7 +25,7 @@ public interface IFileOperations /// /// Ensure you use a try/catch block with appropriate error handling (e.g. showing a /// - IFileSystemInfo New (IApplication? app, IFileSystem fileSystem, IDirectoryInfo inDirectory); + IFileSystemInfo? New (IApplication? app, IFileSystem fileSystem, IDirectoryInfo inDirectory); /// Specifies how to handle file/directory rename attempts in . /// @@ -35,5 +35,5 @@ public interface IFileOperations /// /// Ensure you use a try/catch block with appropriate error handling (e.g. showing a /// - IFileSystemInfo Rename (IApplication? app, IFileSystem fileSystem, IFileSystemInfo toRename); + IFileSystemInfo? Rename (IApplication? app, IFileSystem fileSystem, IFileSystemInfo toRename); } diff --git a/Terminal.Gui/Input/Keyboard/Key.cs b/Terminal.Gui/Input/Keyboard/Key.cs index b0b64287f9..1a5dee5d27 100644 --- a/Terminal.Gui/Input/Keyboard/Key.cs +++ b/Terminal.Gui/Input/Keyboard/Key.cs @@ -217,7 +217,7 @@ public string AsGrapheme if (IsShift && ShiftedKeyCode != KeyCode.Null) { - Rune shiftedRune = ToRune (ShiftedKeyCode); + Rune shiftedRune = ToRune (ShiftedKeyCode | KeyCode.ShiftMask); if (shiftedRune != default (Rune)) { @@ -278,7 +278,7 @@ public Rune AsRune if (IsShift && ShiftedKeyCode != KeyCode.Null) { - Rune shiftedRune = ToRune (ShiftedKeyCode); + Rune shiftedRune = ToRune (ShiftedKeyCode | KeyCode.ShiftMask); if (shiftedRune != default (Rune)) { diff --git a/Terminal.Gui/ViewBase/Helpers/StackExtensions.cs b/Terminal.Gui/ViewBase/Helpers/StackExtensions.cs index e76788029a..3df4d43d0d 100644 --- a/Terminal.Gui/ViewBase/Helpers/StackExtensions.cs +++ b/Terminal.Gui/ViewBase/Helpers/StackExtensions.cs @@ -1,244 +1,160 @@ -#nullable disable -namespace Terminal.Gui.ViewBase; +namespace Terminal.Gui.ViewBase; /// Extension of helper to work with specific public static class StackExtensions { - /// Check if the stack object contains the value to find. - /// The stack object type. /// The stack object. - /// Value to find. - /// The comparison object. - /// true If the value was found.false otherwise. - public static bool Contains (this Stack stack, T valueToFind, IEqualityComparer comparer = null) - { - comparer = comparer ?? EqualityComparer.Default; - - foreach (T obj in stack) - { - if (comparer.Equals (obj, valueToFind)) - { - return true; - } - } - - return false; - } - - /// Find all duplicates stack objects values. /// The stack object type. - /// The stack object. - /// The comparison object. - /// The duplicates stack object. - public static Stack FindDuplicates (this Stack stack, IEqualityComparer comparer = null) + extension (Stack stack) { - comparer = comparer ?? EqualityComparer.Default; - - Stack dup = new (); - T [] stackArr = stack.ToArray (); - - for (var i = 0; i < stackArr.Length; i++) + /// Check if the stack object contains the value to find. + /// Value to find. + /// The comparison object. + /// true If the value was found.false otherwise. + public bool Contains (T valueToFind, IEqualityComparer? comparer = null) { - T value = stackArr [i]; + comparer ??= EqualityComparer.Default; - for (int j = i + 1; j < stackArr.Length; j++) + foreach (T obj in stack) { - T valueToFind = stackArr [j]; - - if (comparer.Equals (value, valueToFind) && !Contains (dup, valueToFind)) + if (comparer.Equals (obj, valueToFind)) { - dup.Push (value); + return true; } } - } - return dup; - } - - /// Move the first stack object value to the end. - /// The stack object type. - /// The stack object. - public static void MoveNext (this Stack stack) - { - Stack temp = new (); - T last = stack.Pop (); - - while (stack.Count > 0) - { - T value = stack.Pop (); - temp.Push (value); + return false; } - temp.Push (last); - - while (temp.Count > 0) + /// Move the first stack object value to the end. + public void MoveNext () { - stack.Push (temp.Pop ()); - } - } + Stack temp = new (); + T last = stack.Pop (); - /// Move the last stack object value to the top. - /// The stack object type. - /// The stack object. - public static void MovePrevious (this Stack stack) - { - Stack temp = new (); - T first = default; + while (stack.Count > 0) + { + T value = stack.Pop (); + temp.Push (value); + } - while (stack.Count > 0) - { - T value = stack.Pop (); - temp.Push (value); + temp.Push (last); - if (stack.Count == 1) + while (temp.Count > 0) { - first = stack.Pop (); + stack.Push (temp.Pop ()); } } - while (temp.Count > 0) - { - stack.Push (temp.Pop ()); - } - - stack.Push (first); - } - - /// Move the stack object value to the index. - /// The stack object type. - /// The stack object. - /// Value to move. - /// The index where to move. - /// The comparison object. - public static void MoveTo ( - this Stack stack, - T valueToMove, - int index = 0, - IEqualityComparer comparer = null - ) - { - if (index < 0) + /// Move the last stack object value to the top. + public void MovePrevious () { - return; - } - - comparer = comparer ?? EqualityComparer.Default; + Stack temp = new (); + T? first = default; - Stack temp = new (); - var toMove = default (T); - int stackCount = stack.Count; - var count = 0; + while (stack.Count > 0) + { + T value = stack.Pop (); + temp.Push (value); - while (stack.Count > 0) - { - T value = stack.Pop (); + if (stack.Count == 1) + { + first = stack.Pop (); + } + } - if (comparer.Equals (value, valueToMove)) + while (temp.Count > 0) { - toMove = value; - - break; + stack.Push (temp.Pop ()); } - temp.Push (value); - count++; + if (first is { }) + { + stack.Push (first); + } } - var idx = 0; - - while (stack.Count < stackCount) + /// Move the stack object value to the index. + /// Value to move. + /// The index where to move. + /// The comparison object. + public void MoveTo (T valueToMove, int index = 0, IEqualityComparer? comparer = null) { - if (count - idx == index) + if (index < 0) { - stack.Push (toMove); + return; } - else + + comparer ??= EqualityComparer.Default; + + Stack temp = new (); + var toMove = default (T); + int stackCount = stack.Count; + var count = 0; + + while (stack.Count > 0) { - stack.Push (temp.Pop ()); - } + T value = stack.Pop (); - idx++; - } - } + if (comparer.Equals (value, valueToMove)) + { + toMove = value; - /// Replaces a stack object values that match with the value to replace. - /// The stack object type. - /// The stack object. - /// Value to replace. - /// Value to replace with to what matches the value to replace. - /// The comparison object. - public static void Replace ( - this Stack stack, - T valueToReplace, - T valueToReplaceWith, - IEqualityComparer comparer = null - ) - { - comparer = comparer ?? EqualityComparer.Default; + break; + } - Stack temp = new (); + temp.Push (value); + count++; + } - while (stack.Count > 0) - { - T value = stack.Pop (); + var idx = 0; - if (comparer.Equals (value, valueToReplace)) + while (stack.Count < stackCount) { - stack.Push (valueToReplaceWith); + if (count - idx == index) + { + if (toMove is { }) + { + stack.Push (toMove); + } + } + else + { + stack.Push (temp.Pop ()); + } - break; + idx++; } - - temp.Push (value); } - while (temp.Count > 0) + /// Replaces a stack object values that match with the value to replace. + /// Value to replace. + /// Value to replace with to what matches the value to replace. + /// The comparison object. + public void Replace (T valueToReplace, T valueToReplaceWith, IEqualityComparer? comparer = null) { - stack.Push (temp.Pop ()); - } - } + comparer ??= EqualityComparer.Default; - /// Swap two stack objects values that matches with the both values. - /// The stack object type. - /// The stack object. - /// Value to swap from. - /// Value to swap to. - /// The comparison object. - public static void Swap ( - this Stack stack, - T valueToSwapFrom, - T valueToSwapTo, - IEqualityComparer comparer = null - ) - { - comparer = comparer ?? EqualityComparer.Default; + Stack temp = new (); - int index = stack.Count - 1; - T [] stackArr = new T [stack.Count]; + while (stack.Count > 0) + { + T value = stack.Pop (); - while (stack.Count > 0) - { - T value = stack.Pop (); + if (comparer.Equals (value, valueToReplace)) + { + stack.Push (valueToReplaceWith); - if (comparer.Equals (value, valueToSwapFrom)) - { - stackArr [index] = valueToSwapTo; - } - else if (comparer.Equals (value, valueToSwapTo)) - { - stackArr [index] = valueToSwapFrom; + break; + } + + temp.Push (value); } - else + + while (temp.Count > 0) { - stackArr [index] = value; + stack.Push (temp.Pop ()); } - - index--; - } - - for (var i = 0; i < stackArr.Length; i++) - { - stack.Push (stackArr [i]); } } } diff --git a/Terminal.Gui/ViewBase/Layout/Aligner.cs b/Terminal.Gui/ViewBase/Layout/Aligner.cs index 637765ba11..082581aba4 100644 --- a/Terminal.Gui/ViewBase/Layout/Aligner.cs +++ b/Terminal.Gui/ViewBase/Layout/Aligner.cs @@ -1,4 +1,3 @@ -#nullable disable using System.ComponentModel; namespace Terminal.Gui.ViewBase; @@ -9,8 +8,6 @@ namespace Terminal.Gui.ViewBase; /// public class Aligner : INotifyPropertyChanged { - private Alignment _alignment; - /// /// Gets or sets how the aligns items within a container. /// @@ -21,11 +18,11 @@ public class Aligner : INotifyPropertyChanged /// public Alignment Alignment { - get => _alignment; + get; set { - _alignment = value; - PropertyChanged?.Invoke (this, new (nameof (Alignment))); + field = value; + PropertyChanged?.Invoke (this, new PropertyChangedEventArgs (nameof (Alignment))); } } @@ -38,7 +35,7 @@ public AlignmentModes AlignmentModes set { field = value; - PropertyChanged?.Invoke (this, new (nameof (AlignmentModes))); + PropertyChanged?.Invoke (this, new PropertyChangedEventArgs (nameof (AlignmentModes))); } } = AlignmentModes.StartToEnd; @@ -51,12 +48,12 @@ public int ContainerSize set { field = value; - PropertyChanged?.Invoke (this, new (nameof (ContainerSize))); + PropertyChanged?.Invoke (this, new PropertyChangedEventArgs (nameof (ContainerSize))); } } /// - public event PropertyChangedEventHandler PropertyChanged; + public event PropertyChangedEventHandler? PropertyChanged; /// /// Takes a list of item sizes and returns a list of the positions of those items when aligned within diff --git a/Terminal.Gui/Views/Autocomplete/AutocompleteFilepathContext.cs b/Terminal.Gui/Views/Autocomplete/AutocompleteFilepathContext.cs index 12384e45b1..c104c8a86f 100644 --- a/Terminal.Gui/Views/Autocomplete/AutocompleteFilepathContext.cs +++ b/Terminal.Gui/Views/Autocomplete/AutocompleteFilepathContext.cs @@ -1,4 +1,3 @@ -#nullable disable using System.IO.Abstractions; using System.Runtime.InteropServices; @@ -12,7 +11,7 @@ internal class AutocompleteFilepathContext (string currentLine, int cursorPositi internal class FilepathSuggestionGenerator : ISuggestionGenerator { - private FileDialogState _state; + private FileDialogState? _state; public IEnumerable GenerateSuggestions (AutocompleteContext context) { @@ -21,74 +20,51 @@ public IEnumerable GenerateSuggestions (AutocompleteContext context) _state = fileState.State; } - if (_state is null) - { - return Enumerable.Empty (); - } - var path = Cell.ToString (context.CurrentLine); int last = path.LastIndexOfAny (FileDialog.Separators); - if (string.IsNullOrWhiteSpace (path) || !Path.IsPathRooted (path)) + if (string.IsNullOrWhiteSpace (path) || !Path.IsPathRooted (path) || _state is null) { - return Enumerable.Empty (); + return []; } - string term = path.Substring (last + 1); + string term = path [(last + 1)..]; // If path is /tmp/ then don't just list everything in it if (string.IsNullOrWhiteSpace (term)) { - return Enumerable.Empty (); + return []; } - if (term.Equals (_state?.Directory?.Name)) + if (term.Equals (_state?.Directory.Name)) { // Clear suggestions - return Enumerable.Empty (); + return []; } bool isWindows = RuntimeInformation.IsOSPlatform (OSPlatform.Windows); - string [] suggestions = _state!.Children.Where (d => !d.IsParent) - .Select ( - e => e.FileSystemInfo is IDirectoryInfo d - ? d.Name + Path.DirectorySeparatorChar - : e.FileSystemInfo.Name - ) - .ToArray (); + string? [] suggestions = _state?.Children.Where (d => !d.IsParent) + .Select (e => e.FileSystemInfo is IDirectoryInfo d ? d.Name + Path.DirectorySeparatorChar : e.FileSystemInfo?.Name) + .ToArray () + ?? []; string [] validSuggestions = suggestions - .Where ( - s => s.StartsWith ( - term, - isWindows - ? StringComparison.InvariantCultureIgnoreCase - : StringComparison.InvariantCulture - ) - ) - .OrderBy (m => m.Length) - .ToArray (); + .Where (s => s?.StartsWith (term, + isWindows ? StringComparison.InvariantCultureIgnoreCase : StringComparison.InvariantCulture) + == true) + .OfType () + .OrderBy (m => m.Length) + .ToArray (); // nothing to suggest if (validSuggestions.Length == 0 || validSuggestions [0].Length == term.Length) { - return Enumerable.Empty (); + return []; } - return validSuggestions.Select ( - f => new Suggestion (term.Length, f, f) - ) - .ToList (); + return validSuggestions.Select (f => new Suggestion (term.Length, f, f)).ToList (); } - public bool IsWordChar (string text) - { - if (text == "\n") - { - return false; - } - - return true; - } + public bool IsWordChar (string text) => text != "\n"; } diff --git a/Terminal.Gui/Views/Dialog.cs b/Terminal.Gui/Views/Dialog.cs index 3235cc1886..0e310292e5 100644 --- a/Terminal.Gui/Views/Dialog.cs +++ b/Terminal.Gui/Views/Dialog.cs @@ -28,6 +28,16 @@ namespace Terminal.Gui.Views; /// becomes the default (). Button alignment is controlled by /// and . /// +/// +/// The dialog is positioned at with sizing, +/// limited to 100% of (or screen dimensions). +/// +/// +/// NOTE - Setting to +/// or +/// +/// is not supported and may cause layout issues. +/// /// /// /// diff --git a/Terminal.Gui/Views/DialogTResult.cs b/Terminal.Gui/Views/DialogTResult.cs index 81d4a37fd6..8b9c306bff 100644 --- a/Terminal.Gui/Views/DialogTResult.cs +++ b/Terminal.Gui/Views/DialogTResult.cs @@ -30,6 +30,16 @@ namespace Terminal.Gui.Views; /// Subclasses should set before calling /// to return a value. If Result is not set (remains null), the dialog is considered canceled. /// +/// +/// The dialog is positioned at with sizing, +/// limited to 100% of (or screen dimensions). +/// +/// +/// NOTE - Setting to +/// or +/// +/// is not supported and may cause layout issues. +/// /// /// /// @@ -69,10 +79,6 @@ public class Dialog : Runnable, IDesignable /// /// Initializes a new instance of the class with no buttons. /// - /// - /// The dialog is positioned at with sizing, - /// limited to 100% of (or screen dimensions). - /// public Dialog () { X = Pos.Center (); @@ -114,27 +120,11 @@ public Dialog () SetStyle (); } - private Size _minimumSubViewsSize; - /// public override void EndInit () { base.EndInit (); UpdateSizes (); - -#if DIALOG_SCROLLBARS - // Don't enable scrollbars until after initialized; otherwise they get created before - // our frame has dimensions. - ViewportSettings |= ViewportSettingsFlags.HasScrollBars; -#endif - } - - /// - protected override void OnSubViewAdded (View view) - { - _minimumSubViewsSize = new Size (GetWidthRequiredForSubViews (), GetHeightRequiredForSubViews ()); - UpdateSizes (); - base.OnSubViewAdded (view); } /// @@ -196,13 +186,8 @@ private void UpdateSizes () } // Always floor at Viewport size — the content area should never be smaller - // than what's visible. For DimAuto dialogs, the Frame may be larger than - // _minimumSubViewsSize (e.g. due to title width), so the content area - // should reflect the actual available space. - int subViewsWidth = Math.Max (_minimumSubViewsSize.Width, Viewport.Width); - int subViewsHeight = Math.Max (_minimumSubViewsSize.Height, Viewport.Height); - - SetContentSize (new Size (Math.Max (_minimumButtonsSize.Width, subViewsWidth), Math.Max (_minimumButtonsSize.Height, subViewsHeight))); + // than what's visible. + SetContentSize (new Size (Math.Max (_minimumButtonsSize.Width, Viewport.Width), Math.Max (_minimumButtonsSize.Height, Viewport.Height))); } /// @@ -212,10 +197,10 @@ private void UpdateSizes () /// private int GetMinimumDialogWidth () { - int minSize = Math.Max (Math.Max (_minimumSubViewsSize.Width, + int minSize = Math.Max ( - // Ensure space for title + borders - Title.GetColumns () + 4), + // Ensure space for title + borders + Title.GetColumns () + 4, _minimumButtonsSize.Width); return minSize; @@ -228,7 +213,7 @@ private int GetMinimumDialogWidth () /// private int GetMinimumDialogHeight () { - int minSize = Math.Max (_minimumSubViewsSize.Height, _minimumButtonsSize.Height - Border.Thickness.Vertical - Margin.Thickness.Vertical); + int minSize = _minimumButtonsSize.Height - Border.Thickness.Vertical - Margin.Thickness.Vertical; return minSize; } diff --git a/Terminal.Gui/Views/FileDialogs/AllowedType.cs b/Terminal.Gui/Views/FileDialogs/AllowedType.cs index 2bcdfbe4a0..05116a32d4 100644 --- a/Terminal.Gui/Views/FileDialogs/AllowedType.cs +++ b/Terminal.Gui/Views/FileDialogs/AllowedType.cs @@ -1,30 +1,5 @@ -#nullable disable - namespace Terminal.Gui.Views; -/// Interface for restrictions on which file type(s) the user is allowed to select/enter. -public interface IAllowedType -{ - /// - /// Returns true if the file at is compatible with this allow option. Note that the file - /// may not exist (e.g. in the case of saving). - /// - /// - /// - bool IsAllowed (string path); -} - -/// that allows selection of any types (*.*). -public class AllowedTypeAny : IAllowedType -{ - /// - public bool IsAllowed (string path) { return true; } - - /// Returns a string representation of this . - /// - public override string ToString () { return Strings.fdAnyFiles + "(*.*)"; } -} - /// /// Describes a requirement on what can be selected. This can be combined with other /// in a to for example show only .csv files but let user change to @@ -34,7 +9,7 @@ public class AllowedType : IAllowedType { /// Initializes a new instance of the class. /// The human-readable text to display. - /// Extension(s) to match e.g. .csv. + /// Extension(s) to match e.g. ".csv". public AllowedType (string description, params string [] extensions) { if (extensions.Length == 0) @@ -79,13 +54,13 @@ public bool IsAllowed (string path) /// Returns plus all separated by semicolons. public override string ToString () { - const int maxLength = 30; + const int MAX_LENGTH = 30; var desc = $"{Description} ({string.Join (";", Extensions.Select (e => '*' + e).ToArray ())})"; - if (desc.Length > maxLength) + if (desc.Length > MAX_LENGTH) { - return desc.Substring (0, maxLength - 2) + "…"; + return desc [..(MAX_LENGTH - 2)] + "…"; } return desc; diff --git a/Terminal.Gui/Views/FileDialogs/AllowedTypeAny.cs b/Terminal.Gui/Views/FileDialogs/AllowedTypeAny.cs new file mode 100644 index 0000000000..91ea037771 --- /dev/null +++ b/Terminal.Gui/Views/FileDialogs/AllowedTypeAny.cs @@ -0,0 +1,12 @@ +namespace Terminal.Gui.Views; + +/// that allows selection of any types (*.*). +public class AllowedTypeAny : IAllowedType +{ + /// + public bool IsAllowed (string path) => true; + + /// Returns a string representation of this . + /// + public override string ToString () => Strings.fdAnyFiles + " (*.*)"; +} diff --git a/Terminal.Gui/Views/FileDialogs/DefaultFileOperations.cs b/Terminal.Gui/Views/FileDialogs/DefaultFileOperations.cs index 1c74914035..09570a9716 100644 --- a/Terminal.Gui/Views/FileDialogs/DefaultFileOperations.cs +++ b/Terminal.Gui/Views/FileDialogs/DefaultFileOperations.cs @@ -1,4 +1,3 @@ -#nullable disable using System.IO.Abstractions; namespace Terminal.Gui.Views; @@ -7,18 +6,20 @@ namespace Terminal.Gui.Views; public class DefaultFileOperations : IFileOperations { /// - public bool Delete (IApplication app, IEnumerable toDelete) + public bool Delete (IApplication? app, IEnumerable toDelete) { // Default implementation does not allow deleting multiple files - if (toDelete.Count () != 1) + IEnumerable fileSystemInfos = toDelete as IFileSystemInfo [] ?? toDelete.ToArray (); + + if (fileSystemInfos.Count () != 1) { return false; } - IFileSystemInfo d = toDelete.Single (); + IFileSystemInfo d = fileSystemInfos.Single (); string adjective = d.Name; - int? result = MessageBox.Query (app, + int? result = MessageBox.Query (app ?? throw new ArgumentNullException (nameof (app)), string.Format (Strings.fdDeleteTitle, adjective), string.Format (Strings.fdDeleteBody, adjective), Strings.btnYes, @@ -49,52 +50,61 @@ public bool Delete (IApplication app, IEnumerable toDelete) } /// - public IFileSystemInfo Rename (IApplication app, IFileSystem fileSystem, IFileSystemInfo toRename) + public IFileSystemInfo? Rename (IApplication? app, IFileSystem fileSystem, IFileSystemInfo toRename) { // Don't allow renaming C: or D: or / (on linux) etc - if (toRename is IDirectoryInfo dir && dir.Parent is null) + if (toRename is IDirectoryInfo { Parent: null }) + { + return null; + } + + if (!Prompt (app ?? throw new ArgumentNullException (nameof (app)), Strings.fdRenameTitle, toRename.Name, out string newName)) + { + return null; + } + + if (string.IsNullOrWhiteSpace (newName)) { return null; } - if (Prompt (app, Strings.fdRenameTitle, toRename.Name, out string newName)) + try { - if (!string.IsNullOrWhiteSpace (newName)) + if (toRename is IFileInfo f) { - try - { - if (toRename is IFileInfo f) - { - IFileInfo newLocation = fileSystem.FileInfo.New (Path.Combine (f.Directory.FullName, newName)); - f.MoveTo (newLocation.FullName); - - return newLocation; - } - else - { - var d = (IDirectoryInfo)toRename; - - IDirectoryInfo newLocation = fileSystem.DirectoryInfo.New (Path.Combine (d.Parent.FullName, newName)); - d.MoveTo (newLocation.FullName); - - return newLocation; - } - } - catch (Exception ex) - { - MessageBox.ErrorQuery (app, Strings.fdRenameFailedTitle, ex.Message, Strings.btnOk); - } + IFileInfo newLocation = fileSystem.FileInfo.New (Path.Combine (f.Directory?.FullName ?? throw new InvalidOperationException (), newName)); + f.MoveTo (newLocation.FullName); + + return newLocation; + } + else + { + var d = (IDirectoryInfo)toRename; + + IDirectoryInfo newLocation = + fileSystem.DirectoryInfo.New (Path.Combine (d.Parent?.FullName ?? throw new InvalidOperationException (), newName)); + d.MoveTo (newLocation.FullName); + + return newLocation; } } + catch (Exception ex) + { + MessageBox.ErrorQuery (app, Strings.fdRenameFailedTitle, ex.Message, Strings.btnOk); + } return null; } /// - public IFileSystemInfo New (IApplication app, IFileSystem fileSystem, IDirectoryInfo inDirectory) + public IFileSystemInfo? New (IApplication? app, IFileSystem fileSystem, IDirectoryInfo inDirectory) { + if (app is null) + { + ArgumentNullException.ThrowIfNull (app); + } var tv = new TextField { Width = Dim.Fill (0, 50), Height = 1 }; - string result = app?.TopRunnable?.Prompt (tv, beginInitHandler: prompt => { prompt.Title = Strings.fdNewTitle; }); + string? result = app.TopRunnable?.Prompt (tv, beginInitHandler: prompt => { prompt.Title = Strings.fdNewTitle; }); if (string.IsNullOrWhiteSpace (result)) { diff --git a/Terminal.Gui/Views/FileDialogs/FileDialog.Commands.cs b/Terminal.Gui/Views/FileDialogs/FileDialog.Commands.cs index 7e61853e16..77786871c2 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialog.Commands.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialog.Commands.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.IO.Abstractions; namespace Terminal.Gui.Views; @@ -10,18 +11,10 @@ public partial class FileDialog /// /// /// - public bool IsCompatibleWithAllowedExtensions (IFileInfo file) => !AllowedTypes.Any () || MatchesAllowedTypes (file); + public bool IsCompatibleWithAllowedExtensions (IFileInfo file) => AllowedTypes.Count == 0 || MatchesAllowedTypes (file); /// - protected override bool OnAccepting (CommandEventArgs args) - { - if (Accept (true)) - { - return base.OnAccepting (args); - } - - return false; - } + protected override bool OnAccepting (CommandEventArgs args) => Accept (true) && base.OnAccepting (args); private void Accept (IEnumerable toMultiAccept) { @@ -154,16 +147,10 @@ private bool FinishAccept () .ToArray (); } - private bool IsCompatibleWithAllowedExtensions (string path) - { - // no restrictions - if (!AllowedTypes.Any ()) - { - return true; - } + private bool IsCompatibleWithAllowedExtensions (string path) => - return AllowedTypes.Any (t => t.IsAllowed (path)); - } + // no restrictions + AllowedTypes.Count == 0 || AllowedTypes.Any (t => t.IsAllowed (path)); private bool IsCompatibleWithOpenMode (string s, out string reason) { @@ -225,7 +212,7 @@ private bool IsCompatibleWithOpenMode (string s, out string reason) return false; - default: throw new ArgumentOutOfRangeException (nameof (OpenMode)); + default: throw new InvalidEnumArgumentException (nameof (OpenMode), (int)OpenMode, typeof (OpenMode)); } } @@ -241,9 +228,9 @@ private bool IsCompatibleWithOpenMode (string s, out string reason) /// private IEnumerable MultiRowToStats () { - HashSet toReturn = new (); + HashSet toReturn = []; - if (!AllowsMultipleSelection || !_tableView.MultiSelectedRegions.Any ()) + if (!AllowsMultipleSelection || _tableView.MultiSelectedRegions.Count == 0) { return toReturn; } @@ -260,7 +247,11 @@ private IEnumerable MultiRowToStats () private void New () { - IFileSystemInfo created = FileOperationsHandler.New (App, _fileSystem!, State!.Directory); + if (_fileSystem is null) + { + return; + } + IFileSystemInfo? created = FileOperationsHandler.New (App, _fileSystem, State!.Directory); RefreshState (); RestoreSelection (created); @@ -275,7 +266,7 @@ private void Rename (IApplication? app) return; } - IFileSystemInfo newNamed = FileOperationsHandler.Rename (app, _fileSystem!, toRename.Single ()!); + IFileSystemInfo? newNamed = FileOperationsHandler.Rename (app, _fileSystem!, toRename.Single ()!); RefreshState (); RestoreSelection (newNamed); diff --git a/Terminal.Gui/Views/FileDialogs/FileDialog.Navigation.cs b/Terminal.Gui/Views/FileDialogs/FileDialog.Navigation.cs index 80377139c3..1b8e68d773 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialog.Navigation.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialog.Navigation.cs @@ -29,7 +29,7 @@ internal void PushState (IDirectoryInfo d, bool addCurrentStateToHistory, bool s /// Select in the table view (if present) /// - internal void RestoreSelection (IFileSystemInfo toRestore) + internal void RestoreSelection (IFileSystemInfo? toRestore) { int row = State!.Children.IndexOf (r => r.FileSystemInfo == toRestore); _tableView.SetSelection (0, row >= 0 ? row : 0, false); @@ -78,7 +78,10 @@ private void PathChanged () PushState (dir.Parent, true, false); } - _tbPath.Autocomplete?.GenerateSuggestions (new AutocompleteFilepathContext (_tbPath.Text, _tbPath.InsertionPoint, State)); + if (State is { }) + { + _tbPath.Autocomplete?.GenerateSuggestions (new AutocompleteFilepathContext (_tbPath.Text, _tbPath.InsertionPoint, State)); + } } private void PushState (FileDialogState newState, bool addCurrentStateToHistory, bool setPathText = true, bool clearForward = true, string? pathText = null) @@ -189,7 +192,7 @@ private IDirectoryInfo StringToDirectoryInfo (string path) return _fileSystem!.DirectoryInfo.New (path); } - private void SuppressIfBadChar (Key k) + private static void SuppressIfBadChar (Key k) { // don't let user type bad letters var ch = (char)k; @@ -211,7 +214,7 @@ private void SetPathToSelectedObject (IFileSystemInfo? selected) if (selected is IDirectoryInfo && Style.PreserveFilenameOnDirectoryChanges) { - if (!string.IsNullOrWhiteSpace (Path) && !_fileSystem!.Directory.Exists (Path)) + if (!string.IsNullOrWhiteSpace (Path) && _fileSystem is { } && !_fileSystem.Directory.Exists (Path)) { string currentFile = _fileSystem.Path.GetFileName (Path); diff --git a/Terminal.Gui/Views/FileDialogs/FileDialog.TableView.cs b/Terminal.Gui/Views/FileDialogs/FileDialog.TableView.cs index 63ceee5180..2480e9fb5b 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialog.TableView.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialog.TableView.cs @@ -11,7 +11,7 @@ private void TableViewHandleCommandNotBound (object? sender, CommandEventArgs e) return; } - if (e.Context.Binding is MouseBinding { MouseEvent: { } mouse }) + if (e.Context.Binding is MouseBinding { MouseEvent: { Flags: MouseFlags.RightButtonClicked } mouse }) { Point? clickedCell = _tableView.ScreenToCell (mouse.Position!.Value.X, mouse.Position!.Value.Y, out int? clickedCol); @@ -258,6 +258,7 @@ private void SortColumn (int clickedCol) private static string StripArrows (string columnName) => columnName.Replace (" (▼)", string.Empty).Replace (" (▲)", string.Empty); + // TODO: Port this to use key bindings private bool TableView_KeyDown (Key keyEvent) { switch (keyEvent.KeyCode) @@ -285,7 +286,7 @@ private bool TableView_KeyDown (Key keyEvent) } } - // private void TableViewOnActivated (object? sender, EventArgs e) + // BUGBUG: See https://github.com/gui-cs/Terminal.Gui/issues/5087#issuecomment-4328093883 private void TableViewOnValueChanged (object? sender, ValueChangedEventArgs e) { if (!_tableView.HasFocus || _tableView.Value is null || _tableView.Table?.Rows == 0) diff --git a/Terminal.Gui/Views/FileDialogs/FileDialog.cs b/Terminal.Gui/Views/FileDialogs/FileDialog.cs index aaa0a9a2f7..f98c6e0ea8 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialog.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialog.cs @@ -27,13 +27,16 @@ public partial class FileDialog : Dialog, IDesignable private readonly Button _btnBack; + private readonly Button _btnForward; + private readonly Button _btnCancel; + /// - /// Gets the cancel button for the dialog. This is useful for checking if the user canceled the dialog by comparing + /// Gets the index of the cancel button for the dialog. This is useful for checking if the user canceled the dialog by + /// comparing /// the to the index of this button in the array. /// - public Button CancelButton { get; } + public int CancelButtonIndex => Buttons.IndexOf (_btnCancel); - private readonly Button _btnForward; private readonly Button _btnOk; private readonly Button _btnUp; private readonly FileDialogHistory _history; @@ -45,7 +48,7 @@ public partial class FileDialog : Dialog, IDesignable private readonly Button _btnTreeToggle; private readonly TreeView _treeView; private Dictionary _treeRoots = new (); - private DropDownList? _typeFilterDropDown; + private readonly DropDownList? _typeFilterDropDown; private int _currentSortColumn; private bool _currentSortIsAsc = true; private bool _disposed; @@ -71,7 +74,7 @@ internal FileDialog (IFileSystem? fileSystem) // Ensure we get Accept for any subviews; esp TreeView CommandsToBubbleUp = [Command.Accept]; - CancelButton = new Button { Text = Strings.btnCancel }; + _btnCancel = new Button { Text = Strings.btnCancel }; _btnOk = new Button { Text = Style.OkButtonText }; @@ -120,6 +123,14 @@ internal FileDialog (IFileSystem? fileSystem) _tbPath.Autocomplete = new AppendAutocomplete (_tbPath); _tbPath.Autocomplete.SuggestionGenerator = new FilepathSuggestionGenerator (); + _typeFilterDropDown = new DropDownList + { + X = Pos.AnchorEnd (), + Y = 1, + Visible = false + }; + Add (_typeFilterDropDown); + // Create table view container (right pane) _tableViewContainer = new View { @@ -201,18 +212,19 @@ internal FileDialog (IFileSystem? fileSystem) _tableView.KeyBindings.ReplaceCommands (Key.Home, Command.Start); _tableView.KeyBindings.ReplaceCommands (Key.End, Command.End); _tableView.KeyBindings.ReplaceCommands (Key.Home.WithShift, Command.StartExtend); - _tableView.KeyBindings.ReplaceCommands (Key.End.WithShift, Command.EndExtend); _history = new FileDialogHistory (this); + _tableView.KeyBindings.ReplaceCommands (Key.End.WithShift, Command.EndExtend); + _history = new FileDialogHistory (this); // Changing the key-bindings of a View is not allowed, however, // by default, Runnable doesn't bind to Command.Context, so // we can take advantage of the CommandNotBound event to handle it _tableView.CommandNotBound += TableViewHandleCommandNotBound; - _tableView.KeyBindings.Add (Key.Space.WithCtrl, Command.Context); + _tableView.KeyBindings.Add (PopoverMenu.DefaultKey, Command.Context); _tableView.MouseBindings.Add (MouseFlags.RightButtonClicked, Command.Context); _tbPath.TextChanged += (_, _) => PathChanged (); - _tbFind = new TextField { X = 1, Width = Dim.Width (_tableView) - 1, Y = Pos.AnchorEnd (), Id = "_tbFind" }; + _tbFind = new TextField { X = 0, Width = Dim.Width (_tableView) - 1, Y = Pos.AnchorEnd (), Id = "_tbFind" }; _spinnerView = new SpinnerView { @@ -247,7 +259,7 @@ internal FileDialog (IFileSystem? fileSystem) // Add the toggle along with OK/Cancel so they align as a group AddButton (_btnTreeToggle); - AddButton (CancelButton); + AddButton (_btnCancel); AddButton (_btnOk); Add (_tbPath); @@ -256,12 +268,58 @@ internal FileDialog (IFileSystem? fileSystem) Add (_btnForward); Add (_treeView); - // Default: Tree hidden and splitter hidden - SetTreeVisible (false); - Add (_tableViewContainer); _tableViewContainer.Add (_tbFind); _tableViewContainer.Add (_spinnerView); + + // to streamline user experience and allow direct typing of paths + // with zero navigation we start with focus in the text box and any + // default/current path fully selected and ready to be overwritten + _tbPath.SetFocus (); + + SetTreeVisible (false); + } + + /// + public override void EndInit () + { + base.EndInit (); + + // Style may have been updated after instance was constructed + _btnOk.Text = Style.OkButtonText; + _btnCancel.Text = Style.CancelButtonText; + _btnUp.Text = GetUpButtonText (); + _btnBack.Text = GetBackButtonText (); + _btnForward.Text = GetForwardButtonText (); + _tbPath.Title = Style.PathCaption; + _tbFind.Title = Style.SearchCaption; + _treeRoots = Style.TreeRootGetter (); + Style.IconProvider.IsOpenGetter = _treeView.IsExpanded; + _treeView.AddObjects (_treeRoots.Keys); + + // if filtering on file type is configured then create the DropDownList and establish + // initial filtering by extension(s) + if (AllowedTypes.Count > 0) + { + CurrentFilter = AllowedTypes [0]; + + _typeFilterDropDown?.Visible = true; + _typeFilterDropDown?.Source = new ListWrapper (new ObservableCollection (AllowedTypes.Select (a => a.ToString ()!).ToList ())); + _typeFilterDropDown?.Value = AllowedTypes [0].ToString () ?? string.Empty; + } + + // if no path has been provided + if (Path.Length <= 0) + { + Path = _fileSystem!.Directory.GetCurrentDirectory (); + } + + _tbPath.SelectAll (); + + if (string.IsNullOrEmpty (Title)) + { + Title = GetDefaultTitle (); + } } /// @@ -327,17 +385,6 @@ public string Path { _tbPath.Text = value; _tbPath.MoveEnd (); - - //IDirectoryInfo dir = StringToDirectoryInfo (value); - - //StringComparison comparison = OperatingSystem.IsWindows () ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - - //if (_treeView.ExpandParents (dir, (left, right) => string.Equals (left.FullName, right.FullName, comparison), out IFileSystemInfo? matched) - // && matched is { }) - //{ - // // _treeView.EnsureVisible (matched); - // // _treeView.SelectedObject = matched; - //} } } @@ -364,86 +411,6 @@ public string Path // TODO: Refactor to use CWP public event EventHandler? FilesSelected; - /// - protected override void OnIsRunningChanged (bool newIsRunning) - { - base.OnIsRunningChanged (newIsRunning); - - if (!newIsRunning) - { - return; - } - - Arrangement |= ViewArrangement.Resizable; - - // May have been updated after instance was constructed - _btnOk.Text = Style.OkButtonText; - CancelButton.Text = Style.CancelButtonText; - _btnUp.Text = GetUpButtonText (); - _btnBack.Text = GetBackButtonText (); - _btnForward.Text = GetForwardButtonText (); - - _tbPath.Title = Style.PathCaption; - _tbFind.Title = Style.SearchCaption; - - _tbPath.Autocomplete?.Scheme = new Scheme (_tbPath.GetScheme ()) - { - Normal = new Attribute (Color.Black, _tbPath.GetAttributeForRole (VisualRole.Normal).Background) - }; - - _treeRoots = Style.TreeRootGetter (); - Style.IconProvider.IsOpenGetter = _treeView.IsExpanded; - _treeView.AddObjects (_treeRoots.Keys); - - // if filtering on file type is configured then create the DropDownList and establish - // initial filtering by extension(s) - if (AllowedTypes.Any ()) - { - CurrentFilter = AllowedTypes [0]; - - _typeFilterDropDown = new DropDownList - { - X = Pos.AnchorEnd (), - Y = 1, - Source = new ListWrapper (new ObservableCollection (AllowedTypes.Select (a => a.ToString ()!).ToList ())), - Value = AllowedTypes [0].ToString () ?? string.Empty - }; - Add (_typeFilterDropDown); - } - - // if no path has been provided - if (_tbPath.Text.Length <= 0) - { - Path = _fileSystem!.Directory.GetCurrentDirectory (); - } - - // to streamline user experience and allow direct typing of paths - // with zero navigation we start with focus in the text box and any - // default/current path fully selected and ready to be overwritten - _tbPath.SetFocus (); - _tbPath.SelectAll (); - - if (string.IsNullOrEmpty (Title)) - { - Title = GetDefaultTitle (); - } - - // Ensure toggle button text matches current state after sizing - SetTreeVisible (false); - - SetNeedsDraw (); - SetNeedsLayout (); - } - - /// - protected override void Dispose (bool disposing) - { - _disposed = true; - base.Dispose (disposing); - - CancelSearch (); - } - /// /// Gets a default dialog title, when is not set or empty, result of the function will be /// shown. @@ -474,7 +441,7 @@ protected virtual string GetDefaultTitle () } /// State representing a recursive search from downwards. - internal class SearchState : FileDialogState + internal sealed class SearchState : FileDialogState { // TODO: Add thread safe child adding private readonly List _found = []; @@ -483,11 +450,12 @@ internal class SearchState : FileDialogState private bool _cancel; private bool _finished; - public SearchState (IDirectoryInfo dir, FileDialog parent, string searchTerms) : base (dir, parent) + public SearchState (IDirectoryInfo dir, FileDialog parent, string searchTerms) : base (dir, parent, skipInitialEnumeration: true) { parent.SearchMatcher.Initialize (searchTerms); Children = []; BeginSearch (); + RefreshChildren (); } /// @@ -505,8 +473,6 @@ internal bool Cancel () return !alreadyCancelled; } - internal override void RefreshChildren () { } - private void BeginSearch () { Task.Run (() => @@ -615,4 +581,13 @@ bool IDesignable.EnableForDesign () return true; } + + /// + protected override void Dispose (bool disposing) + { + _disposed = true; + base.Dispose (disposing); + + CancelSearch (); + } } diff --git a/Terminal.Gui/Views/FileDialogs/FileDialogHistory.cs b/Terminal.Gui/Views/FileDialogs/FileDialogHistory.cs index 535a19777e..7da69c9509 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialogHistory.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialogHistory.cs @@ -1,24 +1,21 @@ -#nullable disable -using System.IO.Abstractions; +using System.IO.Abstractions; namespace Terminal.Gui.Views; -internal class FileDialogHistory +internal class FileDialogHistory (FileDialog dlg) { - private readonly Stack back = new (); - private readonly FileDialog dlg; - private readonly Stack forward = new (); - public FileDialogHistory (FileDialog dlg) { this.dlg = dlg; } + private readonly Stack _back = new (); + private readonly Stack _forward = new (); public bool Back () { - IDirectoryInfo goTo = null; - FileSystemInfoStats restoreSelection = null; - string restorePath = null; + IDirectoryInfo? goTo = null; + FileSystemInfoStats? restoreSelection = null; + string? restorePath = null; if (CanBack ()) { - FileDialogState backTo = back.Pop (); + FileDialogState backTo = _back.Pop (); goTo = backTo.Directory; restoreSelection = backTo.Selected; restorePath = backTo.Path; @@ -34,7 +31,7 @@ public bool Back () return false; } - forward.Push (dlg.State); + _forward.Push (dlg.State ?? throw new InvalidOperationException ()); dlg.PushState (goTo, false, true, false, restorePath); if (restoreSelection is { }) @@ -45,24 +42,23 @@ public bool Back () return true; } - internal bool CanBack () { return back.Count > 0; } - internal bool CanForward () { return forward.Count > 0; } - internal bool CanUp () { return dlg.State?.Directory.Parent != null; } - internal void ClearForward () { forward.Clear (); } + internal bool CanBack () => _back.Count > 0; + internal bool CanForward () => _forward.Count > 0; + internal bool CanUp () => dlg.State?.Directory.Parent != null; + internal void ClearForward () => _forward.Clear (); internal bool Forward () { - if (forward.Count > 0) + if (_forward.Count <= 0) { - dlg.PushState (forward.Pop ().Directory, true, true, false); - - return true; + return false; } + dlg.PushState (_forward.Pop ().Directory, true, true, false); - return false; + return true; } - internal void Push (FileDialogState state, bool clearForward) + internal void Push (FileDialogState? state, bool clearForward) { if (state is null) { @@ -70,29 +66,29 @@ internal void Push (FileDialogState state, bool clearForward) } // if changing to a new directory push onto the Back history - if (back.Count == 0 || back.Peek ().Directory.FullName != state.Directory.FullName) + if (_back.Count != 0 && _back.Peek ().Directory.FullName == state.Directory.FullName) { - back.Push (state); + return; + } + _back.Push (state); - if (clearForward) - { - ClearForward (); - } + if (clearForward) + { + ClearForward (); } } internal bool Up () { - IDirectoryInfo parent = dlg.State?.Directory.Parent; + IDirectoryInfo? parent = dlg.State?.Directory.Parent; - if (parent is { }) + if (parent is null) { - back.Push (new FileDialogState (parent, dlg)); - dlg.PushState (parent, false); - - return true; + return false; } + _back.Push (new FileDialogState (parent, dlg)); + dlg.PushState (parent, false); - return false; + return true; } } diff --git a/Terminal.Gui/Views/FileDialogs/FileDialogState.cs b/Terminal.Gui/Views/FileDialogs/FileDialogState.cs index aec6818175..56fbeb4145 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialogState.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialogState.cs @@ -1,30 +1,39 @@ -#nullable disable -using System.IO.Abstractions; +using System.IO.Abstractions; namespace Terminal.Gui.Views; internal class FileDialogState { - protected readonly FileDialog Parent; - public FileDialogState (IDirectoryInfo dir, FileDialog parent) { - Directory = dir; Parent = parent; + Directory = dir; Path = parent.Path; + Children = GetChildren (Directory).ToArray (); + } - RefreshChildren (); + /// + /// Constructor for subclasses that manage their own Children population (e.g. ). + /// + protected FileDialogState (IDirectoryInfo dir, FileDialog parent, bool skipInitialEnumeration) + { + Parent = parent; + Directory = dir; + Path = parent.Path; + Children = skipInitialEnumeration ? [] : GetChildren (Directory).ToArray (); } + protected FileDialog Parent { get; } + public FileSystemInfoStats [] Children { get; internal set; } public IDirectoryInfo Directory { get; } /// Gets what was entered in the path text box of the dialog when the state was active. public string Path { get; } - public FileSystemInfoStats Selected { get; set; } + public FileSystemInfoStats? Selected { get; set; } - protected virtual IEnumerable GetChildren (IDirectoryInfo dir) + protected IEnumerable GetChildren (IDirectoryInfo dir) { try { @@ -33,26 +42,17 @@ protected virtual IEnumerable GetChildren (IDirectoryInfo d // if directories only if (Parent.OpenMode == OpenMode.Directory) { - children = dir.GetDirectories () - .Select (e => new FileSystemInfoStats (e, Parent.Style.Culture)) - .ToList (); + children = dir.GetDirectories ().Select (e => new FileSystemInfoStats (e, Parent.Style.Culture)).ToList (); } else { - children = dir.GetFileSystemInfos () - .Select (e => new FileSystemInfoStats (e, Parent.Style.Culture)) - .ToList (); + children = dir.GetFileSystemInfos ().Select (e => new FileSystemInfoStats (e, Parent.Style.Culture)).ToList (); } // if only allowing specific file types - if (Parent.AllowedTypes.Any () && Parent.OpenMode == OpenMode.File) + if (Parent.AllowedTypes.Count > 0 && Parent.OpenMode == OpenMode.File) { - children = children.Where ( - c => c.IsDir - || (c.FileSystemInfo is IFileInfo f - && Parent.IsCompatibleWithAllowedExtensions (f)) - ) - .ToList (); + children = children.Where (c => c.IsDir || (c.FileSystemInfo is IFileInfo f && Parent.IsCompatibleWithAllowedExtensions (f))).ToList (); } // if there's a UI filter in place too @@ -72,20 +72,12 @@ protected virtual IEnumerable GetChildren (IDirectoryInfo d catch (Exception) { // Access permissions Exceptions, Dir not exists etc - return Enumerable.Empty (); + return []; } } - protected bool MatchesApiFilter (FileSystemInfoStats arg) - { - return arg.IsDir - || (arg.FileSystemInfo is IFileInfo f - && Parent.CurrentFilter.IsAllowed (f.FullName)); - } + protected bool MatchesApiFilter (FileSystemInfoStats arg) => + Parent.CurrentFilter is { } && (arg.IsDir || (arg.FileSystemInfo is IFileInfo f && Parent.CurrentFilter.IsAllowed (f.FullName))); - internal virtual void RefreshChildren () - { - IDirectoryInfo dir = Directory; - Children = GetChildren (dir).ToArray (); - } + internal virtual void RefreshChildren () => Children = GetChildren (Directory).ToArray (); } diff --git a/Terminal.Gui/Views/FileDialogs/FileDialogTableSource.cs b/Terminal.Gui/Views/FileDialogs/FileDialogTableSource.cs index bf15d3bac0..d29fd14175 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialogTableSource.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialogTableSource.cs @@ -1,26 +1,9 @@ - namespace Terminal.Gui.Views; -internal class FileDialogTableSource ( - FileDialog? dlg, - FileDialogState? state, - FileDialogStyle? style, - int currentSortColumn, - bool currentSortIsAsc) +internal class FileDialogTableSource (FileDialog? dlg, FileDialogState? state, FileDialogStyle? style, int currentSortColumn, bool currentSortIsAsc) : ITableSource { - public object this [int row, int col] - { - get - { - if (state is { }) - { - return GetColumnValue (col, state.Children [row]); - } - - return string.Empty; - } - } + public object this [int row, int col] => state is { } ? GetColumnValue (col, state.Children [row]) : string.Empty; public int Rows => state is { } ? state.Children.Count () : 0; @@ -39,8 +22,11 @@ internal static object GetRawColumnValue (int col, FileSystemInfoStats? stats) switch (col) { case 0: return stats!.FileSystemInfo!.Name; + case 1: return stats!.MachineReadableLength; + case 2: return stats!.LastWriteTime ?? default (DateTime); + case 3: return stats!.Type; } @@ -60,9 +46,11 @@ private object GetColumnValue (int col, FileSystemInfoStats? stats) string icon = dlg!.Style.IconProvider.GetIconWithOptionalSpace (stats!.FileSystemInfo); - return (icon + (stats?.Name ?? string.Empty)).Trim (); + return (icon + stats.Name).Trim (); + case 1: return stats?.HumanReadableLength ?? string.Empty; + case 2: if (stats is null || stats.IsParent || stats.LastWriteTime is null) { @@ -70,8 +58,10 @@ private object GetColumnValue (int col, FileSystemInfoStats? stats) } return stats.LastWriteTime.Value.ToString (style!.DateFormat); + case 3: return stats?.Type ?? string.Empty; + default: throw new ArgumentOutOfRangeException (nameof (col)); } diff --git a/Terminal.Gui/Views/FileDialogs/FilesSelectedEventArgs.cs b/Terminal.Gui/Views/FileDialogs/FilesSelectedEventArgs.cs index f8629e31a4..a219c95e24 100644 --- a/Terminal.Gui/Views/FileDialogs/FilesSelectedEventArgs.cs +++ b/Terminal.Gui/Views/FileDialogs/FilesSelectedEventArgs.cs @@ -1,5 +1,3 @@ -#nullable disable - namespace Terminal.Gui.Views; /// Event args for the event @@ -7,7 +5,7 @@ public class FilesSelectedEventArgs : EventArgs { /// Creates a new instance of the /// - public FilesSelectedEventArgs (FileDialog dialog) { Dialog = dialog; } + public FilesSelectedEventArgs (FileDialog dialog) => Dialog = dialog; /// /// Set to true if you want to prevent the selection going ahead (this will leave the diff --git a/Terminal.Gui/Views/FileDialogs/IAllowedType.cs b/Terminal.Gui/Views/FileDialogs/IAllowedType.cs new file mode 100644 index 0000000000..6c51cc2a4a --- /dev/null +++ b/Terminal.Gui/Views/FileDialogs/IAllowedType.cs @@ -0,0 +1,13 @@ +namespace Terminal.Gui.Views; + +/// Interface for restrictions on which file type(s) the user is allowed to select/enter. +public interface IAllowedType +{ + /// + /// Returns true if the file at is compatible with this allow option. Note that the file + /// may not exist (e.g. in the case of saving). + /// + /// + /// + bool IsAllowed (string path); +} diff --git a/Terminal.Gui/Views/FileDialogs/OpenDialog.cs b/Terminal.Gui/Views/FileDialogs/OpenDialog.cs index 669635cb44..c6df91ee9b 100644 --- a/Terminal.Gui/Views/FileDialogs/OpenDialog.cs +++ b/Terminal.Gui/Views/FileDialogs/OpenDialog.cs @@ -1,15 +1,3 @@ -#nullable disable -// -// FileDialog.cs: File system dialogs for open and save -// -// TODO: -// * Add directory selector -// * Implement subclasses -// * Figure out why message text does not show -// * Remove the extra space when message does not show -// * Use a line separator to show the file listing, so we can use same colors as the rest -// * DirListView: Add mouse support - using System.Collections.ObjectModel; namespace Terminal.Gui.Views; @@ -26,7 +14,7 @@ namespace Terminal.Gui.Views; /// . This will run the dialog modally, and when this returns, /// the list of files will be available on the property. /// -/// To select more than one file, users can use the space key, or CTRL-T. +/// Use `Ctrl-click` or `Space` to select multiple files. `Alt-Click` extends the selection. /// public class OpenDialog : FileDialog { @@ -36,7 +24,7 @@ public OpenDialog () { } /// Returns the selected files, or an empty list if nothing has been selected /// The file paths. public IReadOnlyList FilePaths => - ((IRunnable)this).Result is null || Result == Buttons.IndexOf (CancelButton) ? Enumerable.Empty ().ToList ().AsReadOnly () : + ((IRunnable)this).Result is null || Result == CancelButtonIndex ? Enumerable.Empty ().ToList ().AsReadOnly () : AllowsMultipleSelection ? MultiSelected : new ReadOnlyCollection ([Path]); /// diff --git a/Terminal.Gui/Views/FileDialogs/OpenMode.cs b/Terminal.Gui/Views/FileDialogs/OpenMode.cs index e3e62d60f4..494d82e553 100644 --- a/Terminal.Gui/Views/FileDialogs/OpenMode.cs +++ b/Terminal.Gui/Views/FileDialogs/OpenMode.cs @@ -1,5 +1,4 @@ -#nullable disable -namespace Terminal.Gui.Views; +namespace Terminal.Gui.Views; /// Determine which type to open. public enum OpenMode diff --git a/Terminal.Gui/Views/FileDialogs/SaveDialog.cs b/Terminal.Gui/Views/FileDialogs/SaveDialog.cs index 1940895ed9..b0af109e7c 100644 --- a/Terminal.Gui/Views/FileDialogs/SaveDialog.cs +++ b/Terminal.Gui/Views/FileDialogs/SaveDialog.cs @@ -1,13 +1,4 @@ // -// FileDialog.cs: File system dialogs for open and save -// -// TODO: -// * Add directory selector -// * Implement subclasses -// * Figure out why message text does not show -// * Remove the extra space when message does not show -// * Use a line separator to show the file listing, so we can use same colors as the rest -// * DirListView: Add mouse support using System.IO.Abstractions; @@ -34,7 +25,7 @@ public class SaveDialog : FileDialog /// . /// /// The name of the file. - public string? FileName => (this as IRunnable).Result is null || Result == Buttons.IndexOf (CancelButton) ? null : Path; + public string? FileName => (this as IRunnable).Result is null || Result == CancelButtonIndex ? null : Path; /// Gets the default title for the . /// diff --git a/Terminal.Gui/Views/TextInput/TextField/TextField.Keyboard.cs b/Terminal.Gui/Views/TextInput/TextField/TextField.Keyboard.cs index 93dbbc019e..5b95dc29d1 100644 --- a/Terminal.Gui/Views/TextInput/TextField/TextField.Keyboard.cs +++ b/Terminal.Gui/Views/TextInput/TextField/TextField.Keyboard.cs @@ -73,7 +73,10 @@ protected override bool OnKeyDownNotHandled (Key a) return false; } - if (a.IsAlt || a.IsCtrl) + // Never insert modified keys, except for AltGr combinations with associated text of the unmodified key. + // This allows users to input characters that require AltGr (e.g. '@' on a Portuguese keyboard which is AltGr+2 with associated text "@"), + // while still preventing most modified keys from being inserted into the TextField. + if ((a.IsAlt && string.IsNullOrEmpty (a.AsGrapheme)) || a.IsCtrl) { // Never insert modified keys return false; diff --git a/Terminal.Gui/Views/TextInput/TextView/TextView.Keyboard.cs b/Terminal.Gui/Views/TextInput/TextView/TextView.Keyboard.cs index 961df362e6..52746b2f4c 100644 --- a/Terminal.Gui/Views/TextInput/TextView/TextView.Keyboard.cs +++ b/Terminal.Gui/Views/TextInput/TextView/TextView.Keyboard.cs @@ -142,7 +142,10 @@ protected override bool OnKeyDownNotHandled (Key a) return Autocomplete.ProcessKey (a); } - if (a.IsAlt || a.IsCtrl) + // Never insert modified keys, except for AltGr combinations with associated text of the unmodified key. + // This allows users to input characters that require AltGr (e.g. '@' on a Portuguese keyboard which is AltGr+2 with associated text "@"), + // while still preventing most modified keys from being inserted into the TextView. + if ((a.IsAlt && string.IsNullOrEmpty (a.AsGrapheme)) || a.IsCtrl) { // Never insert modified keys return false; diff --git a/Terminal.sln b/Terminal.sln index 8196f36ae1..16239ed1db 100644 --- a/Terminal.sln +++ b/Terminal.sln @@ -159,8 +159,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InlineSelect", "Examples\In EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Terminal.Gui.Analyzers.Internal", "Terminal.Gui.Analyzers.Internal\Terminal.Gui.Analyzers.Internal.csproj", "{927CCC07-F00C-409C-BE42-458EB03DD4E8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AI", "Examples\AI\AI.csproj", "{1737CFE6-456F-B41B-70D0-2F9EC6BE554F}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -459,19 +457,6 @@ Global {927CCC07-F00C-409C-BE42-458EB03DD4E8}.Release|x64.Build.0 = Release|Any CPU {927CCC07-F00C-409C-BE42-458EB03DD4E8}.Release|x86.ActiveCfg = Release|Any CPU {927CCC07-F00C-409C-BE42-458EB03DD4E8}.Release|x86.Build.0 = Release|Any CPU - {1737CFE6-456F-B41B-70D0-2F9EC6BE554F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1737CFE6-456F-B41B-70D0-2F9EC6BE554F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1737CFE6-456F-B41B-70D0-2F9EC6BE554F}.Debug|x64.ActiveCfg = Debug|Any CPU - {1737CFE6-456F-B41B-70D0-2F9EC6BE554F}.Debug|x64.Build.0 = Debug|Any CPU - {1737CFE6-456F-B41B-70D0-2F9EC6BE554F}.Debug|x86.ActiveCfg = Debug|Any CPU - {1737CFE6-456F-B41B-70D0-2F9EC6BE554F}.Debug|x86.Build.0 = Debug|Any CPU - {1737CFE6-456F-B41B-70D0-2F9EC6BE554F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1737CFE6-456F-B41B-70D0-2F9EC6BE554F}.Release|Any CPU.Build.0 = Release|Any CPU - {1737CFE6-456F-B41B-70D0-2F9EC6BE554F}.Release|x64.ActiveCfg = Release|Any CPU - {1737CFE6-456F-B41B-70D0-2F9EC6BE554F}.Release|x64.Build.0 = Release|Any CPU - {1737CFE6-456F-B41B-70D0-2F9EC6BE554F}.Release|x86.ActiveCfg = Release|Any CPU - {1737CFE6-456F-B41B-70D0-2F9EC6BE554F}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -502,7 +487,6 @@ Global {90A42AE4-301D-4B05-8892-60BE5209C1B5} = {3DD033C0-E023-47BF-A808-9CCE30873C3E} {70802F77-F259-44C6-9522-46FCE2FD754E} = {3DD033C0-E023-47BF-A808-9CCE30873C3E} {3116547F-A8F2-4189-BC22-0B47C757164C} = {3DD033C0-E023-47BF-A808-9CCE30873C3E} - {1737CFE6-456F-B41B-70D0-2F9EC6BE554F} = {3DD033C0-E023-47BF-A808-9CCE30873C3E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {9F8F8A4D-7B8D-4C2A-AC5E-CD7117F74C03} diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyAlternateKeyTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyAlternateKeyTests.cs index 7fa60246fb..56faa131aa 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyAlternateKeyTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyAlternateKeyTests.cs @@ -108,7 +108,7 @@ public void ViewKeyDown_ShiftedAndBase_Preserved () // View level — same alternate key fields must arrive Assert.Single (viewDown); - Assert.Equal (new Key ('@'), viewDown [0]); + Assert.Equal (new Key ('@').WithShift, viewDown [0]); Assert.Equal ((KeyCode)64, viewDown [0].ShiftedKeyCode); Assert.Equal (KeyCode.Null, viewDown [0].BaseLayoutKeyCode); Assert.Equal ("@", viewDown [0].AssociatedText); @@ -125,7 +125,7 @@ public void ViewKeyDown_AssociatedText_Preserved () Assert.Equal ("!", appDown [0].GetPrintableText ()); Assert.Single (viewDown); - Assert.Equal (new Key ('!'), viewDown [0]); + Assert.Equal (new Key ('!').WithShift, viewDown [0]); Assert.Equal ("!", viewDown [0].AssociatedText); Assert.Equal ("!", viewDown [0].GetPrintableText ()); } @@ -178,7 +178,7 @@ public void ViewKeyDown_WithModifiersAndEventType_Preserved () (_, List viewDown, _) = InjectRawSequenceToView ("\x1b[50:64;6:1u"); Assert.Single (viewDown); - Assert.Equal (new Key ('@').WithCtrl, viewDown [0]); + Assert.Equal (new Key ('@').WithCtrl.WithShift, viewDown [0]); Assert.Equal ((KeyCode)64, viewDown [0].ShiftedKeyCode); Assert.Equal (KeyCode.Null, viewDown [0].BaseLayoutKeyCode); Assert.Equal (KeyEventType.Press, viewDown [0].EventType); @@ -194,7 +194,7 @@ public void ViewKeyUp_AlternateKeys_Preserved () // Release events go to KeyUp, not KeyDown Assert.Empty (viewDown); Assert.Single (viewUp); - Assert.Equal (new Key ('@'), viewUp [0]); + Assert.Equal (new Key ('@').WithShift, viewUp [0]); Assert.Equal ((KeyCode)64, viewUp [0].ShiftedKeyCode); Assert.Equal (KeyCode.Null, viewUp [0].BaseLayoutKeyCode); Assert.Equal (KeyEventType.Release, viewUp [0].EventType); diff --git a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardParsingTests.cs b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardParsingTests.cs index 7b08fa79db..4de75d0b79 100644 --- a/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardParsingTests.cs +++ b/Tests/UnitTestsParallelizable/Drivers/AnsiHandling/KittyKeyboardParsingTests.cs @@ -567,7 +567,8 @@ public void KittyPattern_AlternateKeys_WithModifiers () Key? key = _pattern.GetKey ("\u001b[50:64;2;64u"); Assert.NotNull (key); - Assert.Equal (new Key ('@'), key); + Assert.Equal ("@", key.AsGrapheme); + Assert.Equal (new Key ('@').WithShift, key); Assert.Equal ((KeyCode)64, key.ShiftedKeyCode); Assert.Equal (KeyCode.Null, key.BaseLayoutKeyCode); Assert.Equal ("@", key.AssociatedText); @@ -581,7 +582,8 @@ public void KittyPattern_AlternateKeys_WithModifiersAndEventType () Key? key = _pattern.GetKey ("\u001b[50:64;2:3;64u"); Assert.NotNull (key); - Assert.Equal (new Key ('@'), key); + Assert.Equal ("@", key.AsGrapheme); + Assert.Equal (new Key ('@').WithShift, key); Assert.Equal ((KeyCode)64, key.ShiftedKeyCode); Assert.Equal (KeyCode.Null, key.BaseLayoutKeyCode); Assert.Equal (KeyEventType.Release, key.EventType); @@ -595,7 +597,7 @@ public void KittyPattern_AssociatedText_ShiftedPrintableKey () Key? key = _pattern.GetKey ("\u001b[49;2;33u"); Assert.NotNull (key); - Assert.Equal (new Key ('!'), key); + Assert.Equal (new Key ('!').WithShift, key); Assert.Equal ("!", key.AssociatedText); Assert.Equal ("!", key.GetPrintableText ()); } @@ -738,4 +740,214 @@ public void KittyRequestedFlags_IncludesReportAssociatedText () => + "for printable text fidelity."); #endregion + + #region Regression Tests - Modifiers Preservation + + // Copilot - ChatGPT v4 + /// + /// Regression test for issue where Ctrl+Shift+Alt+A was being parsed as Ctrl+Alt+A. + /// The bug was in NormalizeShiftedPrintableKey modifying the modifierField, + /// causing double-decoding in ApplyModifiersAndEventType. + /// Input: ESC[97:65;8u = 'a' with shifted key 'A' (65), modifiers 8 (Shift+Ctrl+Alt) + /// Expected: Ctrl+Shift+Alt+A + /// + [Fact] + public void KittyPattern_ShiftCtrlAlt_A_PreservesAllModifiers () + { + // ESC[97:65;8u = 'a' with shifted key 'A' (65), modifiers 8 (0b111 = Shift+Ctrl+Alt) + // mask = 8 - 1 = 7 which means 7 = 1 (Shift) + 2 (Alt) + 4 (Ctrl) + Key? key = _pattern.GetKey ("\u001b[97:65;8u"); + + Assert.NotNull (key); + Assert.Equal (Key.A.WithShift.WithCtrl.WithAlt, key); + Assert.True (key.IsShift, "Shift should be preserved"); + Assert.True (key.IsCtrl, "Ctrl should be preserved"); + Assert.True (key.IsAlt, "Alt should be preserved"); + Assert.Equal (new Key (KeyCode.A | KeyCode.ShiftMask | KeyCode.CtrlMask | KeyCode.AltMask), key.WithShift.WithCtrl.WithAlt); + Assert.Equal (KeyCode.A | KeyCode.ShiftMask | KeyCode.CtrlMask | KeyCode.AltMask, key.KeyCode); + } + + // Copilot - ChatGPT v4 + /// + /// Regression test for Ctrl+A (without Shift). + /// This should remain as Ctrl+A, not regress. + /// Input: ESC[97;5u = 'a', modifiers 5 (0b100 = Ctrl, no Shift) + /// + [Fact] + public void KittyPattern_Ctrl_A_WithoutShift () + { + // ESC[97;5u = 'a', modifiers 5 (0b100 = Ctrl, no Shift) + // mask = 5 - 1 = 4 which means 4 = 4 (Ctrl) + Key? key = _pattern.GetKey ("\u001b[97;5u"); + + Assert.NotNull (key); + Assert.Equal (Key.A.WithCtrl, key); + Assert.False (key.IsShift, "Shift should not be present"); + Assert.True (key.IsCtrl, "Ctrl should be present"); + Assert.False (key.IsAlt, "Alt should not be present"); + Assert.Equal (new Key ('a'), key.NoCtrl); + Assert.Equal (KeyCode.A | KeyCode.CtrlMask, key.KeyCode); + } + + // Copilot - ChatGPT v4 + /// + /// Regression test for Ctrl+Alt+A (without Shift). + /// This should remain as Ctrl+Alt+A, not regress. + /// Input: ESC[97;7u = 'a', modifiers 7 (0b110 = Ctrl+Alt, no Shift) + /// + [Fact] + public void KittyPattern_CtrlAlt_A_WithoutShift () + { + // ESC[97;7u = 'a', modifiers 7 (0b110 = Ctrl+Alt, no Shift) + // mask = 7 - 1 = 6 which means 6 = 2 (Alt) + 4 (Ctrl) + Key? key = _pattern.GetKey ("\u001b[97;7u"); + + Assert.NotNull (key); + Assert.Equal (Key.A.WithCtrl.WithAlt, key); + Assert.False (key.IsShift, "Shift should not be present"); + Assert.True (key.IsCtrl, "Ctrl should be present"); + Assert.True (key.IsAlt, "Alt should be present"); + Assert.Equal (new Key ('a'), key.NoCtrl.NoAlt); + Assert.Equal (KeyCode.A | KeyCode.CtrlMask | KeyCode.AltMask, key.KeyCode); + } + + // Copilot - ChatGPT v4 + /// + /// Regression test for Shift+A (with shifted character 65 and only shift modifier). + /// Input: ESC[97:65;2u = 'a' with shifted key 'A' (65), modifiers 2 (0b001 = Shift only) + /// Expected: Shift+A + /// + [Fact] + public void KittyPattern_Shift_A_WithShiftedCharacter () + { + // ESC[97:65;2u = 'a' with shifted key 'A' (65), modifiers 2 (0b001 = Shift) + // mask = 2 - 1 = 1 which means 1 = Shift only + Key? key = _pattern.GetKey ("\u001b[97:65;2u"); + + Assert.NotNull (key); + Assert.Equal (Key.A.WithShift, key); + Assert.True (key.IsShift, "Shift should be preserved"); + Assert.False (key.IsCtrl, "Ctrl should not be present"); + Assert.False (key.IsAlt, "Alt should not be present"); + Assert.Equal ("A", key.AsGrapheme); + Assert.Equal (new Key ('A'), key); + Assert.Equal (new Key ('a'), key.NoShift); + Assert.Equal (KeyCode.A | KeyCode.ShiftMask, key.KeyCode); + } + + // Copilot - ChatGPT v4 + /// + /// Regression test for Shift+Ctrl+A (without Alt). + /// Input: ESC[97:65;6u = 'a' with shifted key 'A' (65), modifiers 6 (0b0101 = Shift+Ctrl) + /// Expected: Shift+Ctrl+A + /// + [Fact] + public void KittyPattern_ShiftCtrl_A_PreservesAllModifiers () + { + // ESC[97:65;6u = 'a' with shifted key 'A' (65), modifiers 6 (0b0101 = Shift+Ctrl) + // mask = 6 - 1 = 5 which means 5 = 1 (Shift) + 4 (Ctrl) + Key? key = _pattern.GetKey ("\u001b[97:65;6u"); + + Assert.NotNull (key); + Assert.Equal (Key.A.WithShift.WithCtrl, key); + Assert.True (key.IsShift, "Shift should be preserved"); + Assert.True (key.IsCtrl, "Ctrl should be preserved"); + Assert.False (key.IsAlt, "Alt should not be present"); + Assert.Equal (new Key ('a'), key.NoShift.NoCtrl); + Assert.Equal (KeyCode.A | KeyCode.ShiftMask | KeyCode.CtrlMask, key.KeyCode); + } + + // Copilot - ChatGPT v4 + /// + /// Regression test for Shift+Alt+A (without Ctrl). + /// Input: ESC[97:65;4u = 'a' with shifted key 'A' (65), modifiers 4 (0b011 = Shift+Alt) + /// Expected: Shift+Alt+A + /// + [Fact] + public void KittyPattern_ShiftAlt_A_PreservesAllModifiers () + { + // ESC[97:65;4u = 'a' with shifted key 'A' (65), modifiers 4 (0b011 = Shift+Alt) + // mask = 4 - 1 = 3 which means 3 = 1 (Shift) + 2 (Alt) + Key? key = _pattern.GetKey ("\u001b[97:65;4u"); + + Assert.NotNull (key); + Assert.Equal (Key.A.WithShift.WithAlt, key); + Assert.True (key.IsShift, "Shift should be preserved"); + Assert.False (key.IsCtrl, "Ctrl should not be present"); + Assert.True (key.IsAlt, "Alt should be preserved"); + Assert.Equal (new Key ('a'), key.NoShift.NoAlt); + Assert.Equal (KeyCode.A | KeyCode.ShiftMask | KeyCode.AltMask, key.KeyCode); + } + + // Copilot - ChatGPT v4 + /// + /// Regression test for Alt+A (without Shift and Ctrl). + /// This should remain as Alt+A, not regress. + /// Input: ESC[97;3;97u = 'a', modifiers 3 (0b010 = Alt, no Shift) + /// + [Fact] + public void KittyPattern_Alt_A_WithoutShift () + { + // ESC[97;3;97u = 'a', modifiers 3 (0b010 = Alt, no Shift) + // mask = 3 - 1 = 2 which means 2 = 2 (Alt) + Key? key = _pattern.GetKey ("\u001b[97;3;97u"); + + Assert.NotNull (key); + Assert.Equal (Key.A.WithAlt, key); + Assert.False (key.IsShift, "Shift should not be present"); + Assert.False (key.IsCtrl, "Ctrl should not be present"); + Assert.True (key.IsAlt, "Alt should be present"); + Assert.Equal (new Key ('a'), key.NoAlt); + Assert.Equal (KeyCode.A | KeyCode.AltMask, key.KeyCode); + } + + #endregion + + #region Regression Tests - Printable AltGr keys + + /// + /// Regression test for AltGr+@. + /// This should remain as @, not regress. + /// Input: ESC[50;3;64u = '@', modifiers 3 (0b010 = Alt) + /// + [Fact] + public void KittyPattern_Alt_Arroba_WithAltGr () + { + // ESC[50;3;64u = '@', modifiers 3 (0b010 = Alt) + // mask = 3 - 1 = 2 which means 2 = 2 (Alt) + Key? key = _pattern.GetKey ("\u001b[50;3;64u"); + + Assert.NotNull (key); + Assert.Equal (Key.D2.WithAlt, key); + Assert.Equal ("@", key.AsGrapheme); + Assert.False (key.IsShift, "Shift should not be present"); + Assert.False (key.IsCtrl, "Ctrl should not be present"); + Assert.True (key.IsAlt, "Alt should be present"); + Assert.Equal (Key.D2, key.NoAlt); + Assert.Equal (KeyCode.D2 | KeyCode.AltMask, key.KeyCode); + } + + /// + /// Regression test for AltGr+€. + /// This should remain as €, not regress. + /// Input: ESC[101;3;8364u = '€', modifiers 3 (0b010 = Alt) + /// + [Fact] + public void KittyPattern_Alt_Euro_WithAltGr () + { + // ESC[101;3;8364u = '€', modifiers 3 (0b010 = Alt) + // mask = 3 - 1 = 2 which means 2 = 2 (Alt) + Key? key = _pattern.GetKey ("\u001b[101;3;8364u"); + + Assert.NotNull (key); + Assert.Equal (Key.E.WithAlt, key); + Assert.Equal ("€", key.AsGrapheme); + Assert.False (key.IsShift, "Shift should not be present"); + Assert.False (key.IsCtrl, "Ctrl should not be present"); + Assert.True (key.IsAlt, "Alt should be present"); + Assert.Equal (Key.E, key.NoAlt); + Assert.Equal (KeyCode.E | KeyCode.AltMask, key.KeyCode); + } + + #endregion } diff --git a/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs b/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs index ded262f9bf..f77b1147e8 100644 --- a/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextFieldTests.cs @@ -1517,7 +1517,9 @@ public void AltKey_With_AssociatedText_Does_Not_Insert_Into_TextField () TextField tf = new () { Width = 20 }; tf.SetFocus (); - Key altT = new (Key.T.WithAlt) { AssociatedText = "t" }; + // Simulate Alt+T with AssociatedText set by Kitty keyboard protocol which in a real scenario would be set AssociatedText as empty string + // for Alt+letter keys, with the exception of AltGr+key which would set AssociatedText to "key" (e.g. "€" for AltGr+E) + Key altT = new (Key.T.WithAlt) { AssociatedText = "" }; tf.NewKeyDownEvent (altT); Assert.Equal ("", tf.Text); diff --git a/Tests/UnitTestsParallelizable/Views/TextViewTests.cs b/Tests/UnitTestsParallelizable/Views/TextViewTests.cs index 1f216d645e..4e6ebfb67e 100644 --- a/Tests/UnitTestsParallelizable/Views/TextViewTests.cs +++ b/Tests/UnitTestsParallelizable/Views/TextViewTests.cs @@ -3960,7 +3960,9 @@ public void AltKey_With_AssociatedText_Does_Not_Insert_Into_TextView () TextView tv = new () { Width = 20, Height = 5 }; tv.SetFocus (); - Key altT = new (Key.T.WithAlt) { AssociatedText = "t" }; + // Simulate Alt+T with AssociatedText set by Kitty keyboard protocol which in a real scenario would be set AssociatedText as empty string + // for Alt+letter keys, with the exception of AltGr+key which would set AssociatedText to "key" (e.g. "€" for AltGr+E) + Key altT = new (Key.T.WithAlt) { AssociatedText = "" }; tv.NewKeyDownEvent (altT); Assert.Equal ("", tv.Text); diff --git a/docfx/docs/getting-started.md b/docfx/docs/getting-started.md index 0082d8b5a4..d13d8ed473 100644 --- a/docfx/docs/getting-started.md +++ b/docfx/docs/getting-started.md @@ -5,7 +5,7 @@ Paste these commands into your favorite terminal on Windows, Mac, or Linux. This (Press `Esc` to exit the app) ```ps1 -dotnet new install Terminal.Gui.Templates@2.0.0-beta.* +dotnet new install Terminal.Gui.Templates@2.0.* dotnet new tui-simple -n myproj cd myproj dotnet run @@ -24,7 +24,7 @@ dotnet add package Terminal.Gui Use the [Terminal.Gui.Templates](https://github.com/gui-cs/Terminal.Gui.templates): ```ps1 -dotnet new install Terminal.Gui.Templates@2.0.0-beta.* +dotnet new install Terminal.Gui.Templates@2.0.* ``` ## Sample Usage in C# diff --git a/docfx/docs/index.md b/docfx/docs/index.md index 2073215157..2b75a6b2ff 100644 --- a/docfx/docs/index.md +++ b/docfx/docs/index.md @@ -3,13 +3,10 @@ ![Terminal.Gui — cross-platform TUI toolkit for .NET. Build full-featured terminal UIs with menus, forms, tables, charts, wizards and file dialogs. 30+ views, Windows / macOS / Linux, MIT-licensed.](../images/hero.gif) > [!IMPORTANT] -> Terminal.Gui v2 "Beta" has been released. +> Terminal.Gui v2 has been released Welcome to the Terminal.Gui documentation! This comprehensive guide covers everything you need to know about building modern terminal user interfaces with Terminal.Gui. -> [!NOTE] -> This is the v2 API documentation. For v1 go here: https://gui-cs.github.io/Terminal.GuiV1Docs/ - ## Getting Started - [Getting Started](~/docs/getting-started.md) - Quick start guide to create your first Terminal.Gui application diff --git a/docfx/includes/home-content.md b/docfx/includes/home-content.md index ca96374751..5fb16f3454 100644 --- a/docfx/includes/home-content.md +++ b/docfx/includes/home-content.md @@ -1,6 +1,26 @@ -Terminal.Gui is a cross-platform UI toolkit for building sophisticated terminal UI (TUI) applications on Windows, macOS, and Linux/Unix. +# Terminal.Gui -![Sample app](~/images/sample.gif) +Cross-platform UI toolkit for building sophisticated terminal UI (TUI) applications on Windows, macOS, and Linux/Unix. + +![Terminal.Gui — cross-platform TUI toolkit for .NET. Build full-featured terminal UIs with menus, forms, tables, charts, wizards and file dialogs. 30+ stars, Windows / macOS / Linux, MIT-licensed.](~/images/hero.gif) + +# Version 2.0 Has Been Released + +Terminal.Gui enables building sophisticated console applications with modern UIs: + +- **Responsive TUI** - Easy to use, innovative, layout system enables console apps as responsive as any responsive web page. +- **Performant and Scalable** - Built for modern TUIs - fast, double-buffering-based rendering; Tables and Tree Views scale to infinite elements with sorting and filtering. +- **Keyboard First; Mouse First Too** - Optimized for TUI experiences where the user's hands never need to leave the keyboard; full mouse support too. +- **Rich Built-in Widgets (Views)** - Text editors, buttons, checkboxes, trees, tables, markdown, linear ranges, menus, selectors, and more. +- **Visualizations** - Charts, graphs, progress indicators, and color pickers with TrueColor support. +- **Text Editors** - Full-featured text editing with clipboard, undo/redo, and Unicode support +- **Fully Configurable** - Themes, colors, key bindings, and settings are all customizable and persistable. +- **File Management** - File and directory browsers with search and filtering, supporting Nerdfonts and coloring. +- **Wizards and Multi-Step Processes** - Guided workflows with navigation and validation. +- **Cross-Platform** - Consistent experience on Windows, macOS, and Linux. +- **Apps Work In-line or Full Screen** - Build CLI tools like Claude Code/Copilot/Codex CLI that scroll with the terminal (in-line) or full screen. + +See the [Views Overview](https://gui-cs.github.io/Terminal.Gui/docs/views) for available controls and [What's New in v2](https://gui-cs.github.io/Terminal.Gui/docs/newinv2) for architectural improvements. ## Quick Start @@ -37,55 +57,23 @@ app.Run (window); See the [Examples](https://github.com/gui-cs/Terminal.Gui/tree/develop/Examples) directory for more. -## Build Powerful Terminal Applications - -Terminal.Gui enables building sophisticated console applications with modern UIs: - -- **Rich Forms and Dialogs** - Text fields, buttons, checkboxes, radio buttons, and data validation -- **Interactive Data Views** - Tables, lists, and trees with sorting, filtering, and in-place editing -- **Visualizations** - Charts, graphs, progress indicators, and color pickers with TrueColor support -- **Text Editors** - Full-featured text editing with clipboard, undo/redo, and Unicode support -- **File Management** - File and directory browsers with search and filtering -- **Wizards and Multi-Step Processes** - Guided workflows with navigation and validation -- **System Monitoring Tools** - Real-time dashboards with scrollable, resizable views -- **Configuration UIs** - Settings editors with persistent themes and user preferences -- **Cross-Platform CLI Tools** - Consistent experience on Windows, macOS, and Linux -- **Server Management Interfaces** - SSH-compatible UIs for remote administration - -## Key Features - -* **[Dozens of Built-in Views](~/docs/views.md)** - Rich set of controls for building complex user interfaces - -* **[Cross Platform](~/docs/drivers.md)** - Windows, Mac, and Linux with terminal drivers that work on color and monochrome terminals, including over SSH +# Documentation -* **[Powerful Layout Engine](~/docs/layout.md)** - Relative positioning, automatic sizing, and dynamic terminal UIs +Comprehensive documentation is at [gui-cs.github.io/Terminal.Gui](https://gui-cs.github.io/Terminal.Gui). -* **[Keyboard](~/docs/keyboard.md) and [Mouse](~/docs/mouse.md) Input** - Complete input handling with simple event-based API +## Getting Started -* **[Configuration System](~/docs/config.md)** - Machine, user, and app-level settings with themes and key bindings +- **[Getting Started Guide](https://gui-cs.github.io/Terminal.Gui/docs/getting-started)** - First Terminal.Gui application +- **[API Reference](https://gui-cs.github.io/Terminal.Gui/api/Terminal.Gui.App.html)** - Complete API documentation +- **[What's New in v2](https://gui-cs.github.io/Terminal.Gui/docs/newinv2)** - New features and improvements -* **[Clipboard Support](~/api/Terminal.Gui.App.Clipboard.yml)** - Cut, Copy, and Paste across platforms +## Migration & Deep Dives -* **[Multi-tasking](~/docs/multitasking.md)** - Event processing, idle handlers, timers, and thread-safe classes +- **[Migrating from v1 to v2](https://gui-cs.github.io/Terminal.Gui/docs/migratingfromv1)** - Complete migration guide +- **[Application Architecture](https://gui-cs.github.io/Terminal.Gui/docs/application)** - Instance-based model and IRunnable pattern +- **[Layout System](https://gui-cs.github.io/Terminal.Gui/docs/layout)** - Positioning, sizing, and adornments +- **[Keyboard Handling](https://gui-cs.github.io/Terminal.Gui/docs/keyboard)** - Key bindings and commands +- **[View Documentation](https://gui-cs.github.io/Terminal.Gui/docs/View)** - View hierarchy and lifecycle +- **[Configuration](https://gui-cs.github.io/Terminal.Gui/docs/config)** - Themes and persistent settings -* **[Reactive Extensions](https://github.com/dotnet/reactive)** - MVVM pattern support with ReactiveUI data bindings - -## Installing - -### v2 Alpha (Recommended for new projects) - -```powershell -dotnet add package Terminal.Gui --version "2.0.0-alpha.*" -``` - -### v2 Develop (Latest) - -```powershell -dotnet add package Terminal.Gui --version "2.0.0-develop.*" -``` - -Or use the [Terminal.Gui.Templates](https://github.com/gui-cs/Terminal.Gui.templates): - -```powershell -dotnet new install Terminal.Gui.Templates -``` +See the [documentation index](https://gui-cs.github.io/Terminal.Gui/docs/index) for all topics. diff --git a/docfx/index.md b/docfx/index.md index f9a76b3595..ca5037564e 100644 --- a/docfx/index.md +++ b/docfx/index.md @@ -2,19 +2,10 @@ [!INCLUDE [Home Content](includes/home-content.md)] -## Documentation +# Contributing -- **[Getting Started](~/docs/getting-started.md)** - Create your first Terminal.Gui application -- **[What's New in v2](~/docs/newinv2.md)** - New features and architectural improvements -- **[Migrating from v1](~/docs/migratingfromv1.md)** - Complete migration guide -- **[Views Overview](~/docs/views.md)** - All built-in views and controls -- **[Deep Dives](~/docs/index.md)** - Comprehensive guides and deep dives -- **[API Reference](~/api/Terminal.Gui.yml)** - Complete API documentation +Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md). -## Contributing +# History -Contributions welcome! See [CONTRIBUTING.md](https://github.com/gui-cs/Terminal.Gui/blob/develop/CONTRIBUTING.md). - -## History - -See [gui-cs](https://github.com/gui-cs/) for project history and origins. +See [gui-cs](https://github.com/gui-cs/) for project history and origins. \ No newline at end of file