diff --git a/examples/ted/InlineProgress.cs b/examples/ted/InlineProgress.cs new file mode 100644 index 0000000..b2f5326 --- /dev/null +++ b/examples/ted/InlineProgress.cs @@ -0,0 +1,20 @@ +namespace Ted; + +/// +/// Reports progress synchronously for non-hosted app scenarios where +/// would queue callbacks to the thread pool instead of an application UI thread. +/// +internal sealed class InlineProgress : IProgress +{ + private readonly Action _handler; + + public InlineProgress (Action handler) + { + _handler = handler ?? throw new ArgumentNullException (nameof (handler)); + } + + public void Report (T value) + { + _handler (value); + } +} diff --git a/examples/ted/Program.cs b/examples/ted/Program.cs index f9f12be..434c03b 100644 --- a/examples/ted/Program.cs +++ b/examples/ted/Program.cs @@ -23,13 +23,15 @@ if (!string.IsNullOrWhiteSpace (requestedPath)) { + // Defer onto the app loop so the window renders first, then the file streams in progressively + // instead of blocking the UI until the whole file is read. if (File.Exists (requestedPath)) { - ted.SetDocument (File.ReadAllText (requestedPath), requestedPath); + app.Invoke (() => ted.BeginOpenFile (requestedPath)); } else { - ted.OpenMissingFile (requestedPath); + app.Invoke (() => ted.OpenMissingFile (requestedPath)); } } diff --git a/examples/ted/TedApp.FileOperations.cs b/examples/ted/TedApp.FileOperations.cs index 54ca4e5..65e2600 100644 --- a/examples/ted/TedApp.FileOperations.cs +++ b/examples/ted/TedApp.FileOperations.cs @@ -1,3 +1,4 @@ +using System.Text; using Terminal.Gui.Document; using Terminal.Gui.Highlighting; using Terminal.Gui.Resources; @@ -7,6 +8,17 @@ namespace Ted; public sealed partial class TedApp { + /// Minimum byte/character delta between queued streaming status updates. + private const long StreamingStatusInterval = 256 * 1024; + + /// Minimum elapsed milliseconds between queued streaming status updates. + private const int StreamingStatusMilliseconds = 100; + + private readonly Lock _streamingStatusLock = new (); + private long _lastStreamingStatusUnits; + private DateTime _lastStreamingStatusUpdate = DateTime.MinValue; + private long _streamingStatusOperationId; + /// The path currently associated with , or for an untitled buffer. public string? CurrentFilePath { get; private set; } @@ -19,14 +31,77 @@ public sealed partial class TedApp /// Dialog hook used by . Tests can replace it to avoid interactive UI. public Func ShowSaveChangesDialog { get; set; } - /// File read hook used by . Tests can replace it with an in-memory fake. - public Func ReadAllText { get; set; } = File.ReadAllText; + private bool _openReadWasSet; + + private Func _readAllText = File.ReadAllText; /// - /// File write hook used by and . Tests can replace it with an - /// in-memory fake. + /// File read hook retained for source compatibility. Replacing this hook adapts opens by buffering the + /// returned text as UTF-8; prefer for streaming large files. /// - public Action WriteAllText { get; set; } = File.WriteAllText; + public Func ReadAllText + { + get => _readAllText; + set + { + _readAllText = value ?? throw new ArgumentNullException (nameof (value)); + + if (!_openReadWasSet) + { + _openRead = OpenReadFromReadAllText; + } + } + } + + private Func _openRead = File.OpenRead; + + /// File stream hook used by . Tests can replace it with an in-memory fake. + public Func OpenRead + { + get => _openRead; + set + { + _openRead = value ?? throw new ArgumentNullException (nameof (value)); + _openReadWasSet = true; + } + } + + private bool _createWriteWasSet; + + private Action _writeAllText = File.WriteAllText; + + /// + /// File write hook retained for source compatibility. Streaming saves use . + /// + public Action WriteAllText + { + get => _writeAllText; + set + { + _writeAllText = value ?? throw new ArgumentNullException (nameof (value)); + + if (!_createWriteWasSet) + { + _createWrite = CreateWriteFromWriteAllText; + } + } + } + + private Func _createWrite = path => File.Create (path); + + /// File stream hook used by and . + public Func CreateWrite + { + get => _createWrite; + set + { + _createWrite = value ?? throw new ArgumentNullException (nameof (value)); + _createWriteWasSet = true; + } + } + + /// The currently running background load, if any. + public Task? CurrentLoadTask { get; private set; } /// Gets whether the current editor document has unsaved changes. public bool IsDocumentModified => Editor.Document?.UndoStack.IsOriginalFile == false; @@ -42,14 +117,26 @@ public bool OpenFile () { var filePath = ShowOpenDialog (); + return !string.IsNullOrWhiteSpace (filePath) && OpenFileAsync (filePath).GetAwaiter ().GetResult (); + } + + /// Prompts for a file path, then asynchronously streams that file into the editor. + public async Task OpenFileAsync (CancellationToken cancellationToken = default) + { + var filePath = ShowOpenDialog (); + if (string.IsNullOrWhiteSpace (filePath)) { return false; } - SetDocument (ReadAllText (filePath), filePath); + return await OpenFileAsync (filePath, false, cancellationToken); + } - return true; + /// Asynchronously streams the specified file into the editor. + public Task OpenFileAsync (string filePath, CancellationToken cancellationToken = default) + { + return OpenFileAsync (filePath, false, cancellationToken); } /// Opens a CLI-requested missing file path as an empty, modified document bound to that path. @@ -69,14 +156,35 @@ public void OpenMissingFile (string filePath) /// Saves the editor text to the current file, or prompts for a path if the buffer is untitled. public bool SaveFile () + { + return CurrentFilePath is null ? SaveFileAs () : SaveFileAsync ().GetAwaiter ().GetResult (); + } + + /// Asynchronously streams the editor text to the current file, or prompts for a path if untitled. + public Task SaveFileAsync (CancellationToken cancellationToken = default) + { + return SaveFileAsync (false, cancellationToken); + } + + private async Task SaveFileAsync (bool marshalToApp, CancellationToken cancellationToken = default) { if (CurrentFilePath is null) { - return SaveFileAs (); + return await SaveFileAsAsync (marshalToApp, cancellationToken); } - WriteAllText (CurrentFilePath, GetEditorText ()); - Editor.Document!.UndoStack.MarkAsOriginalFile (); + try + { + await SaveFileToAsync (CurrentFilePath, marshalToApp, cancellationToken); + } + catch (OperationCanceledException) + { + return false; + } + catch (Exception ex) when (IsFileOperationException (ex)) + { + return false; + } return true; } @@ -86,18 +194,25 @@ public bool SaveFileAs () { var filePath = ShowSaveDialog (); + return !string.IsNullOrWhiteSpace (filePath) && SaveFileAsAsync (filePath).GetAwaiter ().GetResult (); + } + + /// Prompts for a file path, then asynchronously streams the editor text to that path. + public Task SaveFileAsAsync (CancellationToken cancellationToken = default) + { + return SaveFileAsAsync (false, cancellationToken); + } + + private async Task SaveFileAsAsync (bool marshalToApp, CancellationToken cancellationToken = default) + { + var filePath = ShowSaveDialog (); + if (string.IsNullOrWhiteSpace (filePath)) { return false; } - CurrentFilePath = filePath; - WriteAllText (filePath, GetEditorText ()); - Editor.Document!.UndoStack.MarkAsOriginalFile (); - UpdateFileNameShortcut (); - UpdatePreviewVisibility (); - - return true; + return await SaveFileAsAsync (filePath, marshalToApp, cancellationToken); } /// Quits ted, prompting to save first when the current document has unsaved changes. @@ -120,26 +235,7 @@ internal void SetDocument (string text, string? filePath) Editor.CaretOffset = 0; CurrentFilePath = filePath; - // Auto-detect highlighting from file extension. - IHighlightingDefinition? def = null; - - if (filePath is not null) - { - var ext = Path.GetExtension (filePath); - - if (!string.IsNullOrEmpty (ext)) - { - def = HighlightingManager.Instance.GetDefinitionByExtension (ext); - } - } - - Editor.HighlightingDefinition = def; - LanguageShortcut.Title = def?.Name ?? "Plain Text"; - - UpdateFileNameShortcut (); - UpdatePreviewVisibility (); - InstallFolding (); - Editor.SetNeedsDraw (); + ApplyFileMetadata (filePath); } private string? ShowDefaultOpenDialog () @@ -198,13 +294,6 @@ private SaveChangesChoice ShowDefaultSaveChangesDialog () }; } - private string GetEditorText () - { - return Editor.Document is null - ? throw new InvalidOperationException ("ted cannot save because the editor has no document.") - : Editor.Document.Text; - } - private void New () { if (!ConfirmSaveChanges ()) @@ -222,12 +311,25 @@ private void Open () return; } - OpenFile (); + var filePath = ShowOpenDialog (); + + if (string.IsNullOrWhiteSpace (filePath)) + { + return; + } + + CurrentLoadTask = OpenFileAsync (filePath, true); } - private void Save () { SaveFile (); } + private void Save () + { + _ = SaveFileAsync (true); + } - private void SaveAs () { SaveFileAs (); } + private void SaveAs () + { + _ = SaveFileAsAsync (true); + } private void Quit () { @@ -248,4 +350,490 @@ private bool ConfirmSaveChanges () _ => false }; } + + private async Task OpenFileAsync ( + string filePath, + bool marshalToApp, + CancellationToken cancellationToken = default) + { + long? statusOperationId = null; + + try + { + await using Stream stream = OpenRead (filePath); + var fileSize = GetStreamLength (stream); + var startedStatusOperationId = BeginStreamingStatus (FormatStartingProgress ("Loading", fileSize)); + statusOperationId = startedStatusOperationId; + + IProgress progress = + CreateStreamingProgress (progress => ReportLoadProgress (startedStatusOperationId, progress)); + + // When there is a running app loop, hand Editor.LoadAsync a UI-thread marshal so it can read on a + // background thread and append each chunk on the UI thread — the editor paints an empty buffer + // immediately and fills in progressively instead of blocking until the whole file is read. + Func? marshal = marshalToApp ? InvokeOnAppAsync : null; + + await Editor.LoadAsync ( + stream, + encoding: null, + progress: progress, + cancellationToken: cancellationToken, + marshal: marshal); + + await RunOnApp ( + marshalToApp, + () => + { + CurrentFilePath = filePath; + ApplyFileMetadata (filePath); + CompleteStreamingStatus ( + startedStatusOperationId, + FormatCompletedProgress ("Loaded", fileSize)); + }); + + // Non-marshalled (sync / test) path: post-load work above ran on a background continuation thread and + // re-claimed TextDocument ownership. Release it as the final step so the caller's thread can use the + // document. The marshalled path keeps UI-thread ownership and must not do this. + if (!marshalToApp) + { + Editor.Document?.SetOwnerThread (null); + } + + return true; + } + catch (OperationCanceledException) + { + if (statusOperationId is { } startedStatusOperationId) + { + CompleteStreamingStatus (startedStatusOperationId, "Load canceled"); + } + else + { + CompleteStreamingStatus ("Load canceled"); + } + + return false; + } + catch (Exception ex) when (IsFileOperationException (ex)) + { + if (statusOperationId is { } startedStatusOperationId) + { + CompleteStreamingStatus (startedStatusOperationId, "Load failed"); + } + else + { + CompleteStreamingStatus ("Load failed"); + } + + return false; + } + } + + /// + /// Begins a progressive, UI-marshalled load of against the running app loop. + /// Used by the CLI path so the window appears before the file finishes loading. + /// + public void BeginOpenFile (string filePath) + { + CurrentLoadTask = OpenFileAsync (filePath, true); + } + + private Task RunOnApp (bool marshalToApp, Action action) + { + if (marshalToApp) + { + return InvokeOnAppAsync (action); + } + + action (); + + return Task.CompletedTask; + } + + private async Task SaveFileAsAsync ( + string filePath, + bool marshalToApp = false, + CancellationToken cancellationToken = default) + { + try + { + await SaveFileToAsync (filePath, marshalToApp, cancellationToken); + } + catch (OperationCanceledException) + { + return false; + } + catch (Exception ex) when (IsFileOperationException (ex)) + { + return false; + } + + CurrentFilePath = filePath; + UpdateFileNameShortcut (); + UpdatePreviewVisibility (); + + return true; + } + + private async Task SaveFileToAsync (string filePath, bool marshalToApp, CancellationToken cancellationToken) + { + long? statusOperationId = null; + + try + { + await using Stream stream = CreateWrite (filePath); + var startedStatusOperationId = BeginStreamingStatus (FormatStartingProgress ("Saving", null)); + statusOperationId = startedStatusOperationId; + + IProgress progress = + CreateStreamingProgress (progress => ReportSaveProgress (startedStatusOperationId, progress)); + await Editor.SaveAsync (stream, progress, cancellationToken); + var fileSize = GetStreamLength (stream); + + void MarkSaved () + { + Editor.Document!.UndoStack.MarkAsOriginalFile (); + CompleteStreamingStatus (startedStatusOperationId, FormatCompletedProgress ("Saved", fileSize)); + } + + if (marshalToApp) + { + await InvokeOnAppAsync (MarkSaved); + } + else + { + MarkSaved (); + } + } + catch (OperationCanceledException) + { + if (statusOperationId is { } startedStatusOperationId) + { + CompleteStreamingStatus (startedStatusOperationId, "Save canceled"); + } + else + { + CompleteStreamingStatus ("Save canceled"); + } + + throw; + } + catch (Exception ex) when (IsFileOperationException (ex)) + { + if (statusOperationId is { } startedStatusOperationId) + { + CompleteStreamingStatus (startedStatusOperationId, "Save failed"); + } + else + { + CompleteStreamingStatus ("Save failed"); + } + + throw; + } + } + + private Stream OpenReadFromReadAllText (string path) + { + return new MemoryStream (Encoding.UTF8.GetBytes (_readAllText (path))); + } + + private Stream CreateWriteFromWriteAllText (string path) + { + return new WriteAllTextStream (path, _writeAllText); + } + + private static bool IsFileOperationException (Exception ex) + { + return ex is IOException or UnauthorizedAccessException; + } + + private void ApplyFileMetadata (string? filePath) + { + IHighlightingDefinition? def = null; + + if (filePath is not null) + { + var ext = Path.GetExtension (filePath); + + if (!string.IsNullOrEmpty (ext)) + { + def = HighlightingManager.Instance.GetDefinitionByExtension (ext); + } + } + + Editor.HighlightingDefinition = def; + LanguageShortcut.Title = def?.Name ?? "Plain Text"; + + UpdateFileNameShortcut (); + UpdatePreviewVisibility (); + InstallFolding (); + Editor.SetNeedsDraw (); + } + + private void ReportLoadProgress (long statusOperationId, TextDocumentProgress progress) + { + if (!ShouldReportStreamingProgress (statusOperationId, progress)) + { + return; + } + + SetLoadStatus (FormatProgress ("Loading", progress), true, statusOperationId); + } + + private void ReportSaveProgress (long statusOperationId, TextDocumentProgress progress) + { + if (!ShouldReportStreamingProgress (statusOperationId, progress)) + { + return; + } + + SetLoadStatus (FormatProgress ("Saving", progress), true, statusOperationId); + } + + private IProgress CreateStreamingProgress (Action handler) + { + return App is null + ? new InlineProgress (handler) + : new Progress (handler); + } + + private long BeginStreamingStatus (string status) + { + var statusOperationId = Interlocked.Increment (ref _streamingStatusOperationId); + + ResetStreamingStatusThrottle (); + SetLoadStatus (status, true, statusOperationId); + + return statusOperationId; + } + + private void CompleteStreamingStatus (long statusOperationId, string status) + { + var completionOperationId = statusOperationId + 1; + + // A newer operation owns the status item; stale completions must not overwrite it. + if (Interlocked.CompareExchange ( + ref _streamingStatusOperationId, + completionOperationId, + statusOperationId) + != statusOperationId) + { + return; + } + + SetLoadStatus (status, false, completionOperationId); + } + + private void CompleteStreamingStatus (string status) + { + var completionOperationId = Interlocked.Increment (ref _streamingStatusOperationId); + SetLoadStatus (status, false, completionOperationId); + } + + private void SetLoadStatus (string status, bool showSpinner, long statusOperationId) + { + void Update () + { + if (Interlocked.Read (ref _streamingStatusOperationId) != statusOperationId) + { + return; + } + + // The status spinner is visible only while it is actively spinning. + LoadStatusSpinner.Visible = showSpinner; + LoadStatusSpinner.AutoSpin = showSpinner; + LoadSpinnerShortcut.Title = status; + LoadSpinnerShortcut.HelpText = status; + LoadStatusSpinner.SetNeedsDraw (); + LoadSpinnerShortcut.SetNeedsDraw (); + } + + if (App is null) + { + Update (); + + return; + } + + App.Invoke (Update); + } + + private void ResetStreamingStatusThrottle () + { + lock (_streamingStatusLock) + { + _lastStreamingStatusUpdate = DateTime.MinValue; + _lastStreamingStatusUnits = 0; + } + } + + private bool ShouldReportStreamingProgress (long statusOperationId, TextDocumentProgress progress) + { + if (Interlocked.Read (ref _streamingStatusOperationId) != statusOperationId) + { + return false; + } + + var processedUnits = progress.BytesProcessed ?? progress.CharactersProcessed; + var totalUnits = progress.TotalBytes ?? progress.TotalCharacters; + + if (totalUnits == processedUnits) + { + return true; + } + + lock (_streamingStatusLock) + { + DateTime now = DateTime.UtcNow; + + if (processedUnits - _lastStreamingStatusUnits < StreamingStatusInterval + && now - _lastStreamingStatusUpdate < TimeSpan.FromMilliseconds (StreamingStatusMilliseconds)) + { + return false; + } + + _lastStreamingStatusUnits = processedUnits; + _lastStreamingStatusUpdate = now; + } + + return true; + } + + private Task InvokeOnAppAsync (Action action) + { + if (App is null) + { + action (); + + return Task.CompletedTask; + } + + TaskCompletionSource completion = new (); + App.Invoke (() => + { + try + { + action (); + completion.SetResult (); + } + catch (Exception ex) + { + completion.SetException (ex); + } + }); + + return completion.Task; + } + + private static string FormatProgress (string verb, TextDocumentProgress progress) + { + var processed = progress.BytesProcessed is { } bytesProcessed + ? FormatByteCount (bytesProcessed) + : $"{progress.CharactersProcessed:N0} chars"; + + var total = progress.TotalBytes is { } totalBytes + ? FormatByteCount (totalBytes) + : progress.TotalCharacters is { } totalCharacters + ? $"{totalCharacters:N0} chars" + : null; + + if (total is null) + { + return $"{verb} {processed}"; + } + + if (progress.Fraction is { } fraction) + { + return $"{verb} {processed} of {total} ({fraction:P0})"; + } + + return $"{verb} {processed} of {total}"; + } + + private static string FormatStartingProgress (string verb, long? totalBytes) + { + return totalBytes is { } bytes + ? $"{verb} 0 B of {FormatByteCount (bytes)}" + : $"{verb} 0 B"; + } + + private static string FormatCompletedProgress (string verb, long? totalBytes) + { + return totalBytes is { } bytes + ? $"{verb} {FormatByteCount (bytes)}" + : verb; + } + + private static long? GetStreamLength (Stream stream) + { + if (!stream.CanSeek) + { + return null; + } + + return stream.Length; + } + + private static string FormatByteCount (long bytes) + { + string[] units = ["B", "KiB", "MiB", "GiB", "TiB"]; + double value = bytes; + var unitIndex = 0; + + while (value >= 1024 && unitIndex < units.Length - 1) + { + value /= 1024; + unitIndex++; + } + + // Whole bytes read cleaner without decimals; larger units need one decimal for useful precision. + var format = unitIndex == 0 ? "N0" : "N1"; + + return $"{value.ToString (format)} {units[unitIndex]}"; + } + + /// + /// Adapts streamed saves to the legacy hook by buffering bytes in memory and + /// writing the final UTF-8 text on disposal. + /// + private sealed class WriteAllTextStream : MemoryStream + { + private readonly string _path; + private readonly Action _writeAllText; + private bool _hasWritten; + + public WriteAllTextStream (string path, Action writeAllText) + { + _path = path; + _writeAllText = writeAllText; + } + + protected override void Dispose (bool disposing) + { + if (disposing) + { + WriteOnce (); + } + + base.Dispose (disposing); + } + + public override async ValueTask DisposeAsync () + { + WriteOnce (); + await base.DisposeAsync (); + } + + private void WriteOnce () + { + if (_hasWritten) + { + return; + } + + _hasWritten = true; + _writeAllText (_path, Encoding.UTF8.GetString (ToArray ())); + } + } } diff --git a/examples/ted/TedApp.cs b/examples/ted/TedApp.cs index a6dd3c5..c1c78af 100644 --- a/examples/ted/TedApp.cs +++ b/examples/ted/TedApp.cs @@ -21,13 +21,26 @@ namespace Ted; /// public sealed partial class TedApp : Window { + private const int MaximumAutomaticFoldingDocumentLength = 1_000_000; + private readonly BraceFoldingStrategy _braceFoldingStrategy; private readonly Shortcut _fileNameShortcut; private readonly MenuItem _previewMarkdownMenuItem; + // Per-instance config path. Defaults to the real ~/.tui location; tests inject a temp path so they + // never touch the developer's real config (and stay parallel-safe — no env/static mutation). + private readonly string _configPath; + /// Initializes a new . - public TedApp (bool readOnly = false) + /// Opens the editor read-only. + /// + /// Overrides where view settings persist. uses + /// (the real ~/.tui/ted.config.json). + /// + public TedApp (bool readOnly = false, string? configPath = null) { + _configPath = configPath ?? EditorSettings.GetConfigPath (); + Title = "ted — Terminal.Gui.Editor demo"; BorderStyle = LineStyle.None; @@ -134,11 +147,24 @@ public TedApp (bool readOnly = false) ToggleMarkdownPreview (); _previewMarkdownMenuItem.Title = ToggleTitle (e.NewValue == CheckState.Checked, "_Preview Markdown"); }; + LoadStatusSpinner = new SpinnerView + { + Style = new SpinnerStyle.Aesthetic (), + Width = 8, + AutoSpin = false, + Visible = false + }; StatusBar statusBar = new ([ new Shortcut { Title = "Language", CommandView = LanguageShortcut }, new Shortcut { Title = "Theme", CommandView = ThemeDropDown }, + LoadSpinnerShortcut = new Shortcut + { + CommandView = LoadStatusSpinner, + Title = string.Empty, + MouseHighlightStates = MouseState.None + }, OverwriteShortcut = new Shortcut (Key.Empty, "INS", null) { MouseHighlightStates = MouseState.None }, LocShortcut = new Shortcut (Key.Empty, FormatLoc (1, 1), null) @@ -265,6 +291,12 @@ [new MenuItem ("_About", "About ted", ShowAboutDialog)]), /// The status-bar dropdown that selects . public DropDownList ThemeDropDown { get; } + /// The spinner view shown while streaming file load/save is running. + public SpinnerView LoadStatusSpinner { get; } + + /// The status-bar shortcut that hosts . + public Shortcut LoadSpinnerShortcut { get; } + /// The settings checkbox state for visible tab glyphs. public CheckBox ShowTabsCheckBox { get; } = new () { @@ -400,7 +432,7 @@ private void SaveViewSettings () EditorSettings.IndentSize = Editor.IndentationSize; EditorSettings.ConvertTabsToSpaces = Editor.ConvertTabsToSpaces; EditorSettings.AutoIndent = Editor.IndentationStrategy is not null; - EditorSettings.Save (); + EditorSettings.Save (_configPath); } private void ShowSettingsDialog () @@ -433,6 +465,13 @@ private void InstallFolding () return; } + if (Editor.Document.TextLength > MaximumAutomaticFoldingDocumentLength) + { + Editor.FoldingManager = null; + + return; + } + FoldingManager fm = new (Editor.Document); Editor.FoldingManager = fm; _braceFoldingStrategy.UpdateFoldings (fm, Editor.Document); @@ -441,9 +480,18 @@ private void InstallFolding () private void UpdateFoldings () { - if (Editor.FoldingManager is not null && Editor.Document is not null) + if (Editor.FoldingManager is null || Editor.Document is null) + { + return; + } + + if (Editor.Document.TextLength > MaximumAutomaticFoldingDocumentLength) { - _braceFoldingStrategy.UpdateFoldings (Editor.FoldingManager, Editor.Document); + Editor.FoldingManager = null; + + return; } + + _braceFoldingStrategy.UpdateFoldings (Editor.FoldingManager, Editor.Document); } } diff --git a/specs/decisions.md b/specs/decisions.md index 79b601f..894bff6 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -36,6 +36,20 @@ Decisions are recorded here when an open question from the plan is resolved. Eac --- +### DEC-009: Streaming file I/O placement (resolves former OPEN-003) + +**Decision**: Streaming `LoadAsync (Stream)` / `SaveAsync (Stream)` lives on `TextDocument`, with `Editor` +delegating to the document layer for control-level convenience. + +**Rationale**: The rope-backed document is the streaming seam: it can append decoded chunks without +materializing the whole file as one `string`, preserve detected encoding/BOM metadata, and write snapshots +back out in chunks. `Editor` still exposes `LoadAsync` / `SaveAsync` so control consumers and `ted` do not +need to know the document construction details. + +**Date**: 2026-05-17 + +--- + ### DEC-003: Tab handling architecture **Decision**: Tab handling (tab-handling) requires the visual-line pipeline (rendering-pipeline). The codex branch implemented both together. `TabElement` renders tabs through the pipeline, not via inline char-by-char expansion. @@ -74,14 +88,6 @@ Decisions are recorded here when an open question from the plan is resolved. Eac --- -### OPEN-003: Async I/O placement - -**Question**: `LoadAsync (Stream)` / `SaveAsync` on `Editor` vs. on the document? - -**Affected features**: File I/O, large-file performance. - ---- - ### OPEN-004: Read-only ranges **Question**: Lift `TextSegmentReadOnlySectionProvider`, or YAGNI? diff --git a/specs/file-io/spec.md b/specs/file-io/spec.md new file mode 100644 index 0000000..7eb7e30 --- /dev/null +++ b/specs/file-io/spec.md @@ -0,0 +1,48 @@ +# Feature Specification: Streaming File I/O + +**Feature Branch**: `copilot/resolve-open-003-large-file-support` +**Created**: 2026-05-17 +**Status**: Implemented +**Input**: Issue #150, TextView parity Gap 6, DEC-001, DEC-009 + +## User Scenarios & Testing + +### Streaming document load + +As an editor consumer, I can load a multi-megabyte stream into `TextDocument` without first converting the +entire file to one `string`. + +**Acceptance**: + +- `TextDocument.LoadAsync (Stream, ...)` decodes in chunks into the rope. +- BOM detection uses `StreamReader` and records the detected `Encoding` on the document. +- Progress reports character count and, when the stream can seek, byte position and total bytes. +- Cancellation is observed between chunks. + +### Streaming document save + +As an editor consumer, I can save a document to a stream without materializing `Document.Text`. + +**Acceptance**: + +- `TextDocument.SaveAsync (Stream, ...)` writes a snapshot in chunks using `TextDocument.Encoding`. +- DEC-001 holds: mixed line endings are not normalized, so unedited content round-trips byte-identical for + the detected encoding/BOM. +- Progress reports characters written and total characters. +- Cancellation is observed between chunks. + +### Control-level and ted usage + +As a `Terminal.Gui.Editor.Editor` / `ted` user, I can use the streaming path without knowing document internals. + +**Acceptance**: + +- `Editor.LoadAsync` and `Editor.SaveAsync` delegate to the document APIs. +- `ted` uses stream hooks (`OpenRead`, `CreateWrite`) for File → Open/Save. +- The ted status bar exposes load/save progress and reports completion. +- Menu-triggered opens run asynchronously so the UI can render progress before the full file is loaded. + +## API + +See [`../public-api.md`](../public-api.md) for the public surface and [`../decisions.md`](../decisions.md) +DEC-009 for placement. diff --git a/specs/public-api.md b/specs/public-api.md index f54ba07..b00cf8d 100644 --- a/specs/public-api.md +++ b/specs/public-api.md @@ -12,6 +12,15 @@ public class Editor : View // --- Document --- public TextDocument Document { get; set; } // exists public event EventHandler? DocumentChanged; // exists + public Task LoadAsync ( + Stream stream, + Encoding? encoding = null, + IProgress? progress = null, + CancellationToken cancellationToken = default); // file-io + public Task SaveAsync ( + Stream stream, + IProgress? progress = null, + CancellationToken cancellationToken = default); // file-io // --- Caret --- public int CaretOffset { get; set; } // exists; backed by TextAnchor (caret-anchors ✅) @@ -108,6 +117,35 @@ public interface IOverlayRenderer } ``` +## Document File I/O (file-io) + +```csharp +namespace Terminal.Gui.Document; + +public sealed class TextDocument +{ + public Encoding Encoding { get; set; } + public static Task LoadAsync ( + Stream stream, + Encoding? encoding = null, + IProgress? progress = null, + CancellationToken cancellationToken = default); + public Task SaveAsync ( + Stream stream, + IProgress? progress = null, + CancellationToken cancellationToken = default); +} + +public readonly record struct TextDocumentProgress ( + long CharactersProcessed, + long? TotalCharacters = null, + long? BytesProcessed = null, + long? TotalBytes = null) +{ + public double? Fraction { get; } +} +``` + ## Change Log | Date | Change | Feature | @@ -119,4 +157,5 @@ public interface IOverlayRenderer | 2026-05-11 | ReadOnly property landed on Editor | read-only | | 2026-05-12 | `ISearchStrategy?` `SearchStrategy { get; set; }` landed on Editor; string-based FindNext/FindPrevious/ReplaceNext/ReplaceAll overloads retained as convenience wrappers | find-and-replace | | 2026-05-16 | Vertical multi-caret keybindings (`Ctrl+Alt+CursorUp/Down`, `Alt+Drag`) added via `Editor.DefaultKeyBindings`; no new public Editor API (R8) | vertical-multi-caret | +| 2026-05-17 | Streaming `TextDocument.LoadAsync` / `TextDocument.SaveAsync`, `TextDocumentProgress`, `TextDocument.Encoding`, and delegating `Editor.LoadAsync` / `Editor.SaveAsync` landed | file-io | | 2026-05-17 | `Editor` implements `IDesignable`; `EnableForDesign()` seeds C# sample code with syntax highlighting and line numbers | design-time | diff --git a/src/Terminal.Gui.Editor/AnsiInputProcessorState.cs b/src/Terminal.Gui.Editor/AnsiInputProcessorState.cs index 5b74953..3497d7f 100644 --- a/src/Terminal.Gui.Editor/AnsiInputProcessorState.cs +++ b/src/Terminal.Gui.Editor/AnsiInputProcessorState.cs @@ -15,7 +15,7 @@ public static void ClearPendingPrintableSuppression (IApplication? app) return; } - // Terminal.Gui 2.1.1-develop.98 suppresses the next printable fallback key after parsing + // Terminal.Gui suppresses the next printable fallback key after parsing // ANSI Shift+Tab (ESC [ Z) because Shift+Tab reports Tab as printable text. Until TG exposes // public input-processor state for this, clear that one-shot suppression after the editor // handles Unindent so the user's next Tab reaches us. If TG renames this private field, the diff --git a/src/Terminal.Gui.Editor/Document/TextDocument.cs b/src/Terminal.Gui.Editor/Document/TextDocument.cs index 597d05e..e52d037 100644 --- a/src/Terminal.Gui.Editor/Document/TextDocument.cs +++ b/src/Terminal.Gui.Editor/Document/TextDocument.cs @@ -25,8 +25,10 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; +using System.Text; using Terminal.Gui.Document.Utils; using System.Threading; +using System.Threading.Tasks; namespace Terminal.Gui.Document { @@ -89,6 +91,119 @@ public TextDocument(ITextSource initialText) { } +#nullable enable + private Encoding _encoding = new UTF8Encoding (false); + + /// + /// Gets or sets the encoding detected during streaming load and used by streaming save. + /// + public Encoding Encoding + { + get => _encoding; + set => _encoding = value ?? throw new ArgumentNullException (nameof (value)); + } + + /// + /// Streams text from in decoded chunks, invoking (and awaiting) + /// for each chunk in order as it is read. Awaiting the callback throttles the + /// reader to the consumer's cadence (natural backpressure) and lets a UI consumer apply each chunk + /// progressively without ever materializing the whole file as a single string. This method does not create + /// or mutate a and has no UI-thread affinity — the consumer decides where and how + /// each chunk is applied. See Editor.LoadAsync for the progressive editor consumer. + /// + /// The source stream. + /// Fallback encoding used when no BOM is detected. Defaults to UTF-8 (no BOM). + /// + /// Invoked and awaited for every decoded chunk, in order. The supplied memory is only valid for the duration + /// of the call; copy it if it must outlive the returned . + /// + /// Invoked once with the encoding the resolved. + /// Optional progress sink, reported after each applied chunk. + /// Observed between chunks. + public static async Task StreamAsync ( + Stream stream, + Encoding? encoding, + Func, CancellationToken, ValueTask> onChunk, + Action? onEncodingDetected = null, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull (stream); + ArgumentNullException.ThrowIfNull (onChunk); + + const int bufferSize = 32 * 1024; + Encoding fallbackEncoding = encoding ?? new UTF8Encoding (false); + long? totalBytes = stream.CanSeek ? stream.Length : null; + char[] buffer = new char[bufferSize]; + long charactersRead = 0; + var encodingReported = false; + + using StreamReader reader = new ( + stream, fallbackEncoding, detectEncodingFromByteOrderMarks: true, bufferSize, leaveOpen: true); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested (); + int read = await reader.ReadAsync (buffer.AsMemory (0, buffer.Length), cancellationToken) + .ConfigureAwait (false); + + if (!encodingReported) + { + // CurrentEncoding is only resolved after the first read consumes (or rules out) a BOM. + encodingReported = true; + onEncodingDetected?.Invoke (reader.CurrentEncoding); + } + + if (read == 0) + { + break; + } + + await onChunk (buffer.AsMemory (0, read), cancellationToken).ConfigureAwait (false); + charactersRead += read; + progress?.Report (new TextDocumentProgress (charactersRead, null, + stream.CanSeek ? stream.Position : null, totalBytes)); + } + } + + /// + /// Streams text from into a new without materializing the + /// entire document as a single string. The whole stream is consumed before the document is returned, so this + /// is not progressive; UI consumers that want incremental rendering should use + /// (see Editor.LoadAsync). + /// + public static async Task LoadAsync (Stream stream, Encoding? encoding = null, + IProgress? progress = null, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull (stream); + + Rope rope = new (); + Encoding detected = encoding ?? new UTF8Encoding (false); + + await StreamAsync ( + stream, + encoding, + (chunk, _) => + { + rope.AddRange (chunk.ToArray (), 0, chunk.Length); + + return ValueTask.CompletedTask; + }, + enc => detected = enc, + progress, + cancellationToken).ConfigureAwait (false); + + TextDocument document = new (rope) + { + Encoding = detected + }; + document.UndoStack.MarkAsOriginalFile (); + document.SetOwnerThread (null); + + return document; + } +#nullable disable + // gets the text from a text source, directly retrieving the underlying rope where possible private static IEnumerable GetTextFromTextSource(ITextSource textSource) { @@ -144,8 +259,9 @@ public ReadOnlyMemory GetTextAsMemory(int offset, int length) /// /// /// - /// The owner can be set to null, which means that no thread can access the document. But, if the document - /// has no owner thread, any thread may take ownership by calling . + /// The owner can be set to null, which means that the next thread to access the document takes ownership + /// on first access. If the document has no owner thread, any thread may also take ownership by calling + /// . /// /// public void SetOwnerThread(Thread newOwner) @@ -162,6 +278,13 @@ public void SetOwnerThread(Thread newOwner) private void VerifyAccess() { + if (ownerThread == null) + { + SetOwnerThread (Thread.CurrentThread); + + return; + } + if(Thread.CurrentThread != ownerThread) { throw new InvalidOperationException("Call from invalid thread."); @@ -247,6 +370,49 @@ public string Text } } +#nullable enable + /// + /// Streams the document to using without materializing the + /// entire document as a single string. + /// + public async Task SaveAsync (Stream stream, IProgress? progress = null, + CancellationToken cancellationToken = default) + { + VerifyAccess (); + + ArgumentNullException.ThrowIfNull (stream); + + const int bufferSize = 32 * 1024; + ITextSource snapshot = CreateSnapshot (); + SetOwnerThread (null); + char[] buffer = new char[bufferSize]; + long charactersWritten = 0; + long totalCharacters = snapshot.TextLength; + + using (TextReader reader = snapshot.CreateReader ()) + using (StreamWriter writer = new (stream, Encoding, bufferSize, leaveOpen: true)) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested (); + int read = await reader.ReadAsync (buffer.AsMemory (0, buffer.Length), cancellationToken) + .ConfigureAwait (false); + + if (read == 0) + { + break; + } + + await writer.WriteAsync (buffer.AsMemory (0, read), cancellationToken).ConfigureAwait (false); + charactersWritten += read; + progress?.Report (new TextDocumentProgress (charactersWritten, totalCharacters)); + } + + await writer.FlushAsync (cancellationToken).ConfigureAwait (false); + } + } +#nullable disable + /// /// This event is called after a group of changes is completed. /// diff --git a/src/Terminal.Gui.Editor/Document/TextDocumentProgress.cs b/src/Terminal.Gui.Editor/Document/TextDocumentProgress.cs new file mode 100644 index 0000000..52dd3f4 --- /dev/null +++ b/src/Terminal.Gui.Editor/Document/TextDocumentProgress.cs @@ -0,0 +1,21 @@ +namespace Terminal.Gui.Document; + +/// Reports streaming document I/O progress. +/// The number of decoded characters loaded or saved so far. +/// The total character count, when known. +/// The number of bytes consumed so far, when known. +/// The total byte count, when known. +public readonly record struct TextDocumentProgress ( + long CharactersProcessed, + long? TotalCharacters = null, + long? BytesProcessed = null, + long? TotalBytes = null) +{ + /// Gets the best available completion fraction, or when no total is known. + public double? Fraction => + TotalBytes is > 0 && BytesProcessed is { } bytesProcessed + ? Math.Clamp ((double)bytesProcessed / TotalBytes.Value, 0, 1) + : TotalCharacters is > 0 + ? Math.Clamp ((double)CharactersProcessed / TotalCharacters.Value, 0, 1) + : null; +} diff --git a/src/Terminal.Gui.Editor/Editor.Drawing.cs b/src/Terminal.Gui.Editor/Editor.Drawing.cs index 23018c6..80bda8f 100644 --- a/src/Terminal.Gui.Editor/Editor.Drawing.cs +++ b/src/Terminal.Gui.Editor/Editor.Drawing.cs @@ -31,6 +31,16 @@ protected override bool OnDrawingContent (DrawContext? context) EnsureColorizerAttribute (normal); DrawVisibleLines (viewport, normal, selected); + + if (_maxWidthGrewDuringDraw) + { + // A rendered line was wider than the estimate; resize content (cheap — not _maxWidthDirty) + // so the horizontal scrollbar reflects what's now on screen. Monotonic: once the widest + // visible line is measured this stops firing, so no draw/layout loop. + _maxWidthGrewDuringDraw = false; + UpdateContentSize (); + } + SetAttribute (normal); UpdateCursor (); @@ -311,6 +321,16 @@ private void DrawVisualLine ( // two don't thrash each other's entries (they use different attribute sets). CellVisualLine visualLine = GetOrBuildDrawVisualLine (line, segments, normal, selected, selStart, selEnd); + // A line we actually render gives us its exact width for free. If the running max was an + // estimate (large document) and this visible line is wider, grow the extent — reconciled + // once after the draw in OnDrawingContent so the horizontal scrollbar tracks visible content. + if (!WordWrap && visualLine.VisualLength > _maxVisualWidth) + { + _maxVisualWidth = visualLine.VisualLength; + _maxWidthLineNumber = line.LineNumber; + _maxWidthGrewDuringDraw = true; + } + foreach (IBackgroundRenderer renderer in BackgroundRenderers) { renderer.Draw (this, visualLine, row, Viewport); diff --git a/src/Terminal.Gui.Editor/Editor.FileIO.cs b/src/Terminal.Gui.Editor/Editor.FileIO.cs new file mode 100644 index 0000000..6e95d20 --- /dev/null +++ b/src/Terminal.Gui.Editor/Editor.FileIO.cs @@ -0,0 +1,212 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Terminal.Gui.Document; + +namespace Terminal.Gui.Editor; + +public partial class Editor +{ + // Chars buffered before the very first paint. Small so the first screenful appears almost immediately — + // the whole point of progressive load. + private const int FirstFlushChars = 16 * 1024; + + // Chars buffered between subsequent paints. Each flush is one marshalled append + a visible-viewport + // redraw, so this only needs to be large enough to avoid an excessive number of round-trips. 64 KiB + // makes a 10 MiB file fill in ~160 smooth steps; 256 KiB felt chunky. + private const int SubsequentFlushChars = 64 * 1024; + + /// + /// Streams text from into without ever materializing the + /// whole file as a single string (resolves OPEN-003 / DEC-009). + /// + /// When is supplied (UI consumers such as ted), an empty document is + /// installed and painted immediately and decoded chunks are appended on the UI thread as they + /// arrive, so the editor fills in top-down while staying responsive. When is + /// (synchronous callers / tests) the file is read in chunks and the document is + /// installed once at the end. + /// + /// + /// The source stream. + /// Fallback encoding when no BOM is detected. Defaults to UTF-8 (no BOM). + /// Optional progress sink. + /// Cancels the load between chunks. + /// + /// Marshals an action onto the UI thread and completes when it has run. UI consumers pass their + /// application-invoke helper so the read happens on a background thread while every document mutation + /// happens on the UI thread. + /// + public async Task LoadAsync ( + Stream stream, + Encoding? encoding = null, + IProgress? progress = null, + CancellationToken cancellationToken = default, + Func? marshal = null) + { + ArgumentNullException.ThrowIfNull (stream); + + if (marshal is null) + { + await LoadNonProgressiveAsync (stream, encoding, progress, cancellationToken).ConfigureAwait (false); + + return; + } + + await LoadProgressiveAsync (stream, encoding, progress, marshal, cancellationToken).ConfigureAwait (false); + } + + // Synchronous / non-UI path: build the whole document off-thread in chunks (no giant string), then install + // it once. The document's owner thread is released at the end so the next single consumer (the caller, a + // test, the UI) claims it on first access — matches the pre-progressive idiom and TextDocument's affinity. + private async Task LoadNonProgressiveAsync ( + Stream stream, + Encoding? encoding, + IProgress? progress, + CancellationToken cancellationToken) + { + Document?.SetOwnerThread (null); + + TextDocument document = + await TextDocument.LoadAsync (stream, encoding, progress, cancellationToken).ConfigureAwait (false); + + document.SetOwnerThread (Thread.CurrentThread); + Document = document; + CaretOffset = 0; + document.SetOwnerThread (null); + } + + // UI path: install an empty document immediately so the editor paints, then append decoded chunks on the UI + // thread as they stream in. The read/decode runs on a background thread; the document is only ever touched + // on the UI thread (assign here, every append + the finalize marshalled). + private async Task LoadProgressiveAsync ( + Stream stream, + Encoding? encoding, + IProgress? progress, + Func marshal, + CancellationToken cancellationToken) + { + Document?.SetOwnerThread (null); + TextDocument document = new (); + document.SetOwnerThread (Thread.CurrentThread); + + bool priorReadOnly = ReadOnly; + Document = document; + CaretOffset = 0; + + // Read-only while streaming: the background reader appends at the end; user edits at arbitrary offsets + // would race those appends. The buffer becomes editable the moment the load completes. + ReadOnly = true; + + Encoding detected = encoding ?? new UTF8Encoding (false); + var firstFlushDone = false; + var pending = new StringBuilder (SubsequentFlushChars + FirstFlushChars); + + void AppendOnUiThread (string text) + { + // The user opened a different file (or LoadAsync was called again) while this load was in flight — + // stop feeding a document the editor no longer shows. + if (!ReferenceEquals (_document, document)) + { + throw new OperationCanceledException (); + } + + document.Insert (document.TextLength, text); + + // Keep the caret pinned to the top so the viewport stays put on the first screenful while the rest + // streams in below, off-screen. Without this the AfterInsertion caret anchor rides every tail append + // and EnsureCaretVisible follows it, scrolling the view for the whole load. Tail-follow is a host + // policy (a host can scroll / set CaretOffset on progress); the editor's default is a stable viewport. + CaretOffset = 0; + SetNeedsDraw (); + } + + async ValueTask OnChunk (ReadOnlyMemory chunk, CancellationToken token) + { + pending.Append (chunk.Span); + + int threshold = firstFlushDone ? SubsequentFlushChars : FirstFlushChars; + + if (pending.Length < threshold) + { + return; + } + + string text = pending.ToString (); + pending.Clear (); + firstFlushDone = true; + await marshal (() => AppendOnUiThread (text)).ConfigureAwait (false); + } + + try + { + // Read + decode off the UI thread; OnChunk marshals each flush back onto it. + await Task.Run ( + () => TextDocument.StreamAsync ( + stream, + encoding, + OnChunk, + enc => detected = enc, + progress, + cancellationToken), + cancellationToken).ConfigureAwait (false); + + await marshal ( + () => + { + if (!ReferenceEquals (_document, document)) + { + return; + } + + if (pending.Length > 0) + { + document.Insert (document.TextLength, pending.ToString ()); + pending.Clear (); + } + + document.Encoding = detected; + + // Loading is not an undoable edit (matches every editor); discard the transient append + // history and mark the freshly loaded content as the pristine on-disk state. + document.UndoStack.ClearAll (); + document.UndoStack.MarkAsOriginalFile (); + + ReadOnly = priorReadOnly; + CaretOffset = 0; + SetNeedsDraw (); + }).ConfigureAwait (false); + } + catch + { + // Restore editability even on cancel/failure; keep whatever streamed in so far. + await marshal ( + () => + { + if (ReferenceEquals (_document, document)) + { + ReadOnly = priorReadOnly; + } + }).ConfigureAwait (false); + + throw; + } + } + + /// + /// Streams to by delegating to + /// . + /// + public Task SaveAsync ( + Stream stream, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + TextDocument document = Document + ?? throw new InvalidOperationException ( + "Cannot save because the editor has no document."); + + return document.SaveAsync (stream, progress, cancellationToken); + } +} diff --git a/src/Terminal.Gui.Editor/Editor.cs b/src/Terminal.Gui.Editor/Editor.cs index b924bcb..574e494 100644 --- a/src/Terminal.Gui.Editor/Editor.cs +++ b/src/Terminal.Gui.Editor/Editor.cs @@ -73,6 +73,18 @@ public partial class Editor : View private bool _maxWidthDirty = true; private int _maxWidthLineNumber; + // Above this document size the horizontal extent is estimated from each line's character count + // (O(1) per line) instead of building + syntax-highlighting a CellVisualLine for every line. + // Building every line on load is what made a 10 MiB open take ~10 s; the model layer alone + // loads in ~0.2 s. Smaller documents keep the exact computation (tab/wide-glyph precise). + private const int MaxWidthEstimateThresholdBytes = 256 * 1024; + + // Set by the draw path when a rendered line turned out wider than the running max (e.g. an + // estimated large doc whose visible tab-indented line expands past the char-length estimate). + // OnDrawingContent reconciles the content size once after the draw, so the horizontal scrollbar + // grows as wider lines scroll into view — matching VS Code's "estimate, then refine" model. + private bool _maxWidthGrewDuringDraw; + /// /// Sticky column for vertical caret moves. Tracks the column the user *intends* to be in, /// even when the current line is shorter, so Up/Down across short lines snap back to the @@ -456,7 +468,8 @@ protected override void Dispose (bool disposing) // external code retains the TextDocument (test fixtures, future shared docs across panes, // etc.). The Document setter unsubscribes on swap; this covers View-teardown. _document.Changed -= OnDocumentChanged; - _lastKnownCaretOffset = CaretOffset; + // Dispose can run after document ownership moved; _lastKnownCaretOffset is maintained + // during caret movement and document changes, so avoid reading CaretOffset here. _caretAnchor = null; _selectionAnchor = null; _additionalCarets.Clear (); @@ -526,6 +539,25 @@ private void UpdateContentSize () SetContentSize (new Size (_maxVisualWidth + 1, Math.Max (1, visibleLines))); } + /// + /// Per-line horizontal extent used for the content width / horizontal scrollbar. For documents + /// below this is the exact visual width (builds the + /// line, tab/wide-glyph precise). For larger documents it is the line's character count — O(1), + /// no build, no syntax-highlight — so opening a multi-MB file does not build + highlight every + /// line just to size a scrollbar. The estimate can be short for tab-indented / wide-glyph lines; + /// the draw path refines exactly for lines it actually renders, so + /// visible content always has a correct extent and the scrollbar grows on scroll. + /// + private int MeasureLineWidth (DocumentLine line) + { + if (_document is { } document && document.TextLength >= MaxWidthEstimateThresholdBytes) + { + return line.Length; + } + + return GetOrBuildDefaultVisualLine (line).VisualLength; + } + /// Full O(N) recompute — only called on Document swap, IndentationSize change, etc. private void RecomputeMaxWidth () { @@ -541,7 +573,7 @@ private void RecomputeMaxWidth () foreach (DocumentLine line in _document.Lines) { - var width = GetOrBuildDefaultVisualLine (line).VisualLength; + var width = MeasureLineWidth (line); if (width > _maxVisualWidth) { @@ -587,7 +619,7 @@ private void UpdateMaxWidthIncremental (DocumentChangeEventArgs e) for (var lineNum = firstAffected.LineNumber; lineNum <= scanEnd; lineNum++) { DocumentLine line = _document.GetLineByNumber (lineNum); - var width = GetOrBuildDefaultVisualLine (line).VisualLength; + var width = MeasureLineWidth (line); if (width >= newMax) { @@ -618,7 +650,7 @@ private void UpdateMaxWidthIncremental (DocumentChangeEventArgs e) for (var lineNum = firstAffected.LineNumber; lineNum <= endLine; lineNum++) { DocumentLine line = _document.GetLineByNumber (lineNum); - var width = GetOrBuildDefaultVisualLine (line).VisualLength; + var width = MeasureLineWidth (line); if (width > _maxVisualWidth) { diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTabTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTabTests.cs index 1d6dc62..93ea325 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/EditorTabTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/EditorTabTests.cs @@ -148,9 +148,9 @@ public async Task Backspace_At_End_Of_Leading_Whitespace_Removes_One_Indentation } [Fact] - public async Task Ted_RawAnsi_Tab_After_ShiftTab_Reindents_Line_On_First_Keypress () + public async Task RawAnsi_Tab_After_ShiftTab_Reindents_Line_On_First_Keypress () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); fx.Top.Editor.SetFocus (); fx.Top.Editor.Document!.Text = "hello world"; fx.Top.Editor.CaretOffset = 0; diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs index af53462..b9d2747 100644 --- a/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs +++ b/tests/Terminal.Gui.Editor.IntegrationTests/TedAppTests.cs @@ -2,6 +2,8 @@ using System.Collections.Immutable; using System.Drawing; +using System.IO; +using System.Text; using Ted; using Terminal.Gui.Configuration; using Terminal.Gui.Editor.IntegrationTests.Testing; @@ -29,9 +31,9 @@ private static void DeleteIfExists (string filePath) [Fact] public void NewFile_ClearsEditor_AndCurrentFilePath () { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.ShowOpenDialog = () => "/tmp/ted-open.txt"; - app.ReadAllText = _ => "opened"; + app.OpenRead = _ => new MemoryStream (Encoding.UTF8.GetBytes ("opened")); Assert.True (app.OpenFile ()); app.Editor.SelectAll (); @@ -47,9 +49,9 @@ public void NewFile_ClearsEditor_AndCurrentFilePath () [Fact] public void OpenFile_Canceled_DoesNotChangeEditor () { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.ShowOpenDialog = () => null; - app.ReadAllText = _ => throw new InvalidOperationException ("Canceled open should not read."); + app.OpenRead = _ => throw new InvalidOperationException ("Canceled open should not read."); Assert.False (app.OpenFile ()); @@ -65,7 +67,7 @@ public void OpenFile_LoadsSelectedFile_FromDisk () try { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.ShowOpenDialog = () => filePath; Assert.True (app.OpenFile ()); @@ -88,7 +90,7 @@ public void OpenMissingFile_SetsPath_AndMarksDocumentModified () try { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.OpenMissingFile (filePath); Assert.Equal (filePath, app.CurrentFilePath); @@ -101,6 +103,150 @@ public void OpenMissingFile_SetsPath_AndMarksDocumentModified () } } + [Fact] + public async Task OpenFileAsync_Updates_LoadStatusShortcut () + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + app.ShowOpenDialog = () => "/tmp/ted-progress.txt"; + app.OpenRead = _ => new MemoryStream (Encoding.UTF8.GetBytes (new string ('x', 100_000))); + + Assert.Equal (string.Empty, app.LoadSpinnerShortcut.Title); + + Assert.True (await app.OpenFileAsync (TestContext.Current.CancellationToken)); + + Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.Title); + Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.HelpText); + Assert.Same (app.LoadStatusSpinner, app.LoadSpinnerShortcut.CommandView); + Assert.False (app.LoadStatusSpinner.Visible); + Assert.False (app.LoadStatusSpinner.AutoSpin); + Assert.Equal (100_000, app.Editor.Document!.TextLength); + } + + [Fact] + public async Task OpenFileAsync_Loads_Stream_On_Background_Thread () + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + var testThreadId = Environment.CurrentManagedThreadId; + GatedReadStream stream = new (Encoding.UTF8.GetBytes (new string ('x', 100_000))); + app.ShowOpenDialog = () => "/tmp/ted-progress.txt"; + app.OpenRead = _ => stream; + + Task openTask = app.OpenFileAsync (TestContext.Current.CancellationToken); + + await stream.ReadStarted.Task.WaitAsync (TestContext.Current.CancellationToken); + + Assert.False (openTask.IsCompleted); + Assert.True (app.LoadStatusSpinner.Visible); + Assert.True (app.LoadStatusSpinner.AutoSpin); + Assert.Equal ("Loading 0 B of 97.7 KiB", app.LoadSpinnerShortcut.Title); + Assert.Equal ("Loading 0 B of 97.7 KiB", app.LoadSpinnerShortcut.HelpText); + + stream.AllowRead.SetResult (); + + Assert.True (await openTask); + Assert.NotEqual (testThreadId, stream.ReadThreadId); + Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.Title); + Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.HelpText); + } + + [Fact] + public async Task OpenFileAsync_ReadFailure_HidesSpinner_AndShowsFailureStatus () + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + app.ShowOpenDialog = () => "/tmp/ted-open-fails.txt"; + app.OpenRead = _ => new ThrowingReadStream (); + + Assert.False (await app.OpenFileAsync (TestContext.Current.CancellationToken)); + + Assert.False (app.LoadStatusSpinner.Visible); + Assert.False (app.LoadStatusSpinner.AutoSpin); + Assert.Equal ("Load failed", app.LoadSpinnerShortcut.Title); + Assert.Equal ("Load failed", app.LoadSpinnerShortcut.HelpText); + } + + [Fact] + public void OpenFile_UsesReadAllTextHook_WhenReplaced () + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + app.ShowOpenDialog = () => "/tmp/ted-legacy-open.txt"; + app.ReadAllText = path => path == "/tmp/ted-legacy-open.txt" ? "legacy open" : string.Empty; + + Assert.True (app.OpenFile ()); + + Assert.Equal ("legacy open", app.Editor.Document!.Text); + Assert.Equal ("/tmp/ted-legacy-open.txt", app.CurrentFilePath); + } + + [Fact] + public async Task OpenFileAsync_ByPath_Updates_LoadStatusShortcut () + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + GatedReadStream stream = new (Encoding.UTF8.GetBytes (new string ('x', 100_000))); + app.OpenRead = _ => stream; + + Task openTask = app.OpenFileAsync ("/tmp/ted-progress.cs", TestContext.Current.CancellationToken); + + await stream.ReadStarted.Task.WaitAsync (TestContext.Current.CancellationToken); + + Assert.Equal ("Loading 0 B of 97.7 KiB", app.LoadSpinnerShortcut.Title); + Assert.Equal ("Loading 0 B of 97.7 KiB", app.LoadSpinnerShortcut.HelpText); + + stream.AllowRead.SetResult (); + + Assert.True (await openTask); + Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.Title); + Assert.Equal ("Loaded 97.7 KiB", app.LoadSpinnerShortcut.HelpText); + Assert.False (app.LoadStatusSpinner.Visible); + Assert.False (app.LoadStatusSpinner.AutoSpin); + } + + [Fact] + public async Task StatusBar_Shows_Loaded_FileSize_After_StartupOpen () + { + var filePath = Path.Combine (Path.GetTempPath (), $"ted-startup-{Guid.NewGuid ():N}.cs"); + await File.WriteAllTextAsync (filePath, new string ('x', 100_000), TestContext.Current.CancellationToken); + + try + { + await using AppFixture fx = new (() => + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + app.OpenFileAsync (filePath).GetAwaiter ().GetResult (); + + return app; + }); + + fx.Render (); + + DriverAssert.ContentsContains (fx.Driver, "Loaded 97.7 KiB"); + } + finally + { + File.Delete (filePath); + } + } + + [Fact] + public async Task OpenFileAsync_LargeFile_DisablesAutomaticFolding () + { + var filePath = Path.Combine (Path.GetTempPath (), $"ted-large-{Guid.NewGuid ():N}.cs"); + await File.WriteAllTextAsync (filePath, new string ('x', 1_000_001), TestContext.Current.CancellationToken); + + try + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + + Assert.True (await app.OpenFileAsync (filePath, TestContext.Current.CancellationToken)); + + Assert.Null (app.Editor.FoldingManager); + Assert.Equal ("Loaded 976.6 KiB", app.LoadSpinnerShortcut.Title); + } + finally + { + File.Delete (filePath); + } + } + [Fact] public void SaveFile_WritesCurrentEditorText_ToCurrentPath () { @@ -109,7 +255,7 @@ public void SaveFile_WritesCurrentEditorText_ToCurrentPath () try { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.ShowOpenDialog = () => filePath; Assert.True (app.OpenFile ()); app.Editor.Document!.Text = "after"; @@ -128,12 +274,12 @@ public void SaveFile_WritesCurrentEditorText_ToCurrentPath () [Fact] public void SaveFile_MarksDocumentUnmodified () { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.ShowOpenDialog = () => "/tmp/ted-save.txt"; - app.ReadAllText = _ => "before"; + app.OpenRead = _ => new MemoryStream (Encoding.UTF8.GetBytes ("before")); Assert.True (app.OpenFile ()); app.Editor.Document!.Text = "after"; - app.WriteAllText = (_, _) => { }; + app.CreateWrite = _ => new MemoryStream (); Assert.True (app.IsDocumentModified); @@ -142,6 +288,71 @@ public void SaveFile_MarksDocumentUnmodified () Assert.False (app.IsDocumentModified); } + [Fact] + public async Task SaveFileAsync_WriteFailure_HidesSpinner_AndShowsFailureStatus () + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + app.OpenMissingFile ("/tmp/ted-save-fails.txt"); + app.Editor.Document!.Text = "dirty"; + app.CreateWrite = _ => new ThrowingWriteStream (); + + Assert.False (await app.SaveFileAsync (TestContext.Current.CancellationToken)); + + Assert.False (app.LoadStatusSpinner.Visible); + Assert.False (app.LoadStatusSpinner.AutoSpin); + Assert.Equal ("Save failed", app.LoadSpinnerShortcut.Title); + Assert.Equal ("Save failed", app.LoadSpinnerShortcut.HelpText); + Assert.True (app.IsDocumentModified); + } + + [Fact] + public async Task SaveFileAsync_Canceled_HidesSpinner_AndShowsCanceledStatus () + { + var filePath = Path.Combine (Path.GetTempPath (), $"ted-save-canceled-{Guid.NewGuid ():N}.txt"); + + try + { + TedApp app = new (configPath: TedTestConfig.NewPath ()); + app.OpenMissingFile (filePath); + app.Editor.Document!.Text = "dirty"; + using CancellationTokenSource cts = new (); + await cts.CancelAsync (); + + Assert.False (await app.SaveFileAsync (cts.Token)); + + Assert.False (app.LoadStatusSpinner.Visible); + Assert.False (app.LoadStatusSpinner.AutoSpin); + Assert.Equal ("Save canceled", app.LoadSpinnerShortcut.Title); + Assert.Equal ("Save canceled", app.LoadSpinnerShortcut.HelpText); + Assert.True (app.IsDocumentModified); + } + finally + { + DeleteIfExists (filePath); + } + } + + [Fact] + public void SaveFile_UsesWriteAllTextHook_WhenReplaced () + { + string? savedPath = null; + string? savedText = null; + TedApp app = new (configPath: TedTestConfig.NewPath ()); + app.OpenMissingFile ("/tmp/ted-legacy-save.txt"); + app.Editor.Document!.Text = "legacy save"; + app.WriteAllText = (path, text) => + { + savedPath = path; + savedText = text; + }; + + Assert.True (app.SaveFile ()); + + Assert.Equal ("/tmp/ted-legacy-save.txt", savedPath); + Assert.Equal ("legacy save", savedText); + Assert.False (app.IsDocumentModified); + } + [Fact] public void Open_Save_RoundTrip_Preserves_Tab_Characters () { @@ -150,7 +361,7 @@ public void Open_Save_RoundTrip_Preserves_Tab_Characters () try { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.ShowOpenDialog = () => filePath; Assert.True (app.OpenFile ()); @@ -168,9 +379,14 @@ public void Open_Save_RoundTrip_Preserves_Tab_Characters () public void SaveFileAs_Canceled_DoesNotWrite () { var wrote = false; - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.ShowSaveDialog = () => " "; - app.WriteAllText = (_, _) => wrote = true; + app.CreateWrite = _ => + { + wrote = true; + + return new MemoryStream (); + }; Assert.False (app.SaveFileAs ()); @@ -185,7 +401,7 @@ public void SaveFileAs_WritesEditorText_ToSelectedPath () try { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.ShowSaveDialog = () => filePath; app.Editor.Document!.Text = "save as"; @@ -204,7 +420,7 @@ public void SaveFileAs_WritesEditorText_ToSelectedPath () public void QuitFile_ModifiedDocument_CancelChoice_DoesNotQuit () { var prompted = false; - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.Editor.Document!.Text = "dirty"; app.ShowSaveChangesDialog = () => { @@ -222,18 +438,19 @@ public void QuitFile_ModifiedDocument_CancelChoice_DoesNotQuit () [Fact] public async Task QuitFile_ModifiedDocument_SaveChoice_SavesBeforeQuitting () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); string? savedPath = null; string? savedText = null; fx.Top.ShowOpenDialog = () => "/tmp/ted-save-on-quit.txt"; - fx.Top.ReadAllText = _ => "before"; + fx.Top.OpenRead = _ => new MemoryStream (Encoding.UTF8.GetBytes ("before")); Assert.True (fx.Top.OpenFile ()); fx.Top.Editor.Document!.Text = "after"; fx.Top.ShowSaveChangesDialog = () => SaveChangesChoice.Save; - fx.Top.WriteAllText = (path, text) => + fx.Top.CreateWrite = path => { savedPath = path; - savedText = text; + + return new CapturingWriteStream (text => savedText = text); }; Assert.True (fx.Top.QuitFile ()); @@ -251,7 +468,7 @@ public void QuitFile_MissingFile_DiscardChoice_DoesNotCreateFile () try { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.OpenMissingFile (filePath); app.ShowSaveChangesDialog = () => SaveChangesChoice.Discard; @@ -272,7 +489,7 @@ public void QuitFile_MissingFile_SaveChoice_CreatesEmptyFile () try { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.OpenMissingFile (filePath); app.ShowSaveChangesDialog = () => SaveChangesChoice.Save; @@ -291,7 +508,7 @@ public void QuitFile_MissingFile_SaveChoice_CreatesEmptyFile () [Fact] public async Task Renders_FileMenu_Header () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); DriverAssert.ContentsContains (fx.Driver, "File"); } @@ -299,7 +516,7 @@ public async Task Renders_FileMenu_Header () [Fact] public async Task Renders_OptionsMenu_Header () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); DriverAssert.ContentsContains (fx.Driver, "Options"); } @@ -307,7 +524,7 @@ public async Task Renders_OptionsMenu_Header () [Fact] public async Task Renders_ViewMenu_Header () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); DriverAssert.ContentsContains (fx.Driver, "View"); } @@ -315,7 +532,7 @@ public async Task Renders_ViewMenu_Header () [Fact] public async Task Constructor_Defaults_To_Plain_Text_Highlighting () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); Assert.Null (fx.Top.Editor.HighlightingDefinition); Assert.Equal ("Plain Text", fx.Top.LanguageShortcut.Title); @@ -328,7 +545,7 @@ public async Task Highlighting_Auto_Detects_From_File_Extension () try { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); fx.Top.OpenMissingFile (tempXmlFilePath); Assert.NotNull (fx.Top.Editor.HighlightingDefinition); @@ -344,7 +561,7 @@ public async Task Highlighting_Auto_Detects_From_File_Extension () [Fact] public async Task OptionsMenu_Contains_Settings_Item () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); InputInjectionOptions options = new () { Mode = InputInjectionMode.Direct }; fx.Injector.InjectKey (Key.O.WithAlt, options); @@ -364,7 +581,7 @@ public void Constructor_ReadOnly_Sets_Editor_ReadOnly () [Fact] public void Constructor_Defaults_AutoIndent_To_Enabled () { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); Assert.IsType (app.Editor.IndentationStrategy); } @@ -372,7 +589,7 @@ public void Constructor_Defaults_AutoIndent_To_Enabled () [Fact] public async Task Loc_StatusBar_Shortcut_Initially_Shows_Line_1_Column_1 () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); Assert.Equal ("Ln 1, Col 1", fx.Top.LocShortcut.Title); } @@ -382,7 +599,7 @@ public async Task Loc_StatusBar_Shortcut_Tracks_Caret_Movement () { await using AppFixture fx = new (() => { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.Editor.Document!.Text = "alpha\nbeta\ngamma"; return app; @@ -399,7 +616,7 @@ public async Task Loc_StatusBar_Shortcut_Updates_When_Document_Edit_Shifts_Caret { await using AppFixture fx = new (() => { - TedApp app = new (); + TedApp app = new (configPath: TedTestConfig.NewPath ()); app.Editor.Document!.Text = "abc"; return app; @@ -416,7 +633,7 @@ public async Task Loc_StatusBar_Shortcut_Updates_When_Document_Edit_Shifts_Caret [Fact] public async Task FileMenu_OpensViaKeyboard_AltF () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); // The "Open..." menu item is unique to the dropdown — the StatusBar shortcut is just "Open". DriverAssert.ContentsDoesNotContain (fx.Driver, "Open..."); @@ -431,7 +648,7 @@ public async Task FileMenu_OpensViaKeyboard_AltF () [Fact] public async Task ViewMenu_TogglesLineNumbers_ViaKeyboard () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); Assert.True (fx.Top.Editor.GutterOptions.HasFlag (GutterOptions.LineNumbers)); InputInjectionOptions options = new () { Mode = InputInjectionMode.Direct }; @@ -461,7 +678,7 @@ public async Task ViewMenu_TogglesLineNumbers_ViaKeyboard () [Fact] public async Task FileMenu_OpensViaMouse_ClickOnHeader () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); DriverAssert.ContentsDoesNotContain (fx.Driver, "Open..."); @@ -486,7 +703,7 @@ public async Task FileMenu_OpensViaMouse_ClickOnHeader () [Fact] public async Task EditMenu_OpensViaKeyboard_AltE_Contains_Find_And_Replace () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); DriverAssert.ContentsDoesNotContain (fx.Driver, "Find..."); DriverAssert.ContentsDoesNotContain (fx.Driver, "Replace..."); @@ -502,7 +719,7 @@ public async Task EditMenu_OpensViaKeyboard_AltE_Contains_Find_And_Replace () [Fact] public async Task Editor_RightClick_Opens_Edit_Context_Menu () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); DriverAssert.ContentsDoesNotContain (fx.Driver, "Select all"); @@ -526,7 +743,7 @@ public async Task Editor_RightClick_Opens_Edit_Context_Menu () [Fact] public async Task ThemeDropDown_Initially_Shows_Current_Theme () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); Assert.Equal (ThemeManager.Theme, fx.Top.ThemeDropDown.Text); } @@ -534,7 +751,7 @@ public async Task ThemeDropDown_Initially_Shows_Current_Theme () [Fact] public async Task ThemeDropDown_Source_Contains_All_Available_Themes () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); ImmutableList expected = ThemeManager.GetThemeNames (); Assert.True (expected.Count > 0, "ThemeManager should expose at least one theme."); @@ -549,7 +766,7 @@ public async Task ThemeDropDown_Source_Contains_All_Available_Themes () [Fact] public async Task ThemeDropDown_Selection_Changes_Active_Theme () { - await using AppFixture fx = new (() => new TedApp ()); + await using AppFixture fx = new (() => new TedApp (configPath: TedTestConfig.NewPath ())); ImmutableList names = ThemeManager.GetThemeNames (); @@ -566,4 +783,115 @@ public async Task ThemeDropDown_Selection_Changes_Active_Theme () Assert.Equal (target, ThemeManager.Theme); } + + private sealed class CapturingWriteStream : MemoryStream + { + private readonly Action _capture; + + public CapturingWriteStream (Action capture) + { + _capture = capture; + } + + protected override void Dispose (bool disposing) + { + if (disposing) + { + _capture (Encoding.UTF8.GetString (ToArray ())); + } + + base.Dispose (disposing); + } + + public override async ValueTask DisposeAsync () + { + _capture (Encoding.UTF8.GetString (ToArray ())); + await base.DisposeAsync (); + } + } + + private sealed class ThrowingReadStream : Stream + { + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => 1; + + public override long Position { get; set; } + + public override void Flush () + { + } + + public override int Read (byte[] buffer, int offset, int count) + { + throw new IOException ("read failed"); + } + + public override ValueTask ReadAsync (Memory buffer, CancellationToken cancellationToken = default) + { + throw new IOException ("read failed"); + } + + public override long Seek (long offset, SeekOrigin origin) + { + throw new NotSupportedException (); + } + + public override void SetLength (long value) + { + throw new NotSupportedException (); + } + + public override void Write (byte[] buffer, int offset, int count) + { + throw new NotSupportedException (); + } + } + + private sealed class ThrowingWriteStream : MemoryStream + { + public override void Write (byte[] buffer, int offset, int count) + { + throw new IOException ("write failed"); + } + + public override ValueTask WriteAsync (ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + throw new IOException ("write failed"); + } + } + + /// Gates async reads and captures the reading thread ID for background-load tests. + private sealed class GatedReadStream : MemoryStream + { + public GatedReadStream (byte[] buffer) + : base (buffer) + { + } + + public TaskCompletionSource AllowRead { get; } = new (TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource ReadStarted { get; } = new (TaskCreationOptions.RunContinuationsAsynchronously); + + public int ReadThreadId { get; private set; } + + public override ValueTask ReadAsync (Memory buffer, CancellationToken cancellationToken = default) + { + ReadThreadId = Environment.CurrentManagedThreadId; + ReadStarted.TrySetResult (); + + return new ValueTask (ReadAfterGateAsync (buffer, cancellationToken)); + } + + private async Task ReadAfterGateAsync (Memory buffer, CancellationToken cancellationToken) + { + await AllowRead.Task.WaitAsync (cancellationToken); + + return await base.ReadAsync (buffer, cancellationToken); + } + } } diff --git a/tests/Terminal.Gui.Editor.IntegrationTests/Testing/TedTestConfig.cs b/tests/Terminal.Gui.Editor.IntegrationTests/Testing/TedTestConfig.cs new file mode 100644 index 0000000..40921ca --- /dev/null +++ b/tests/Terminal.Gui.Editor.IntegrationTests/Testing/TedTestConfig.cs @@ -0,0 +1,23 @@ +using System; +using System.IO; + +namespace Terminal.Gui.Editor.IntegrationTests.Testing; + +/// +/// Supplies a unique throwaway ted.config.json path under the OS temp directory for +/// TedApp construction in tests, so a real TedApp exercising menu/dialog actions +/// persists view settings there instead of polluting the developer's real +/// ~/.tui/ted.config.json. Per-instance (passed to the TedApp constructor) — no +/// environment-variable or static mutation, so it stays parallel-safe. +/// +internal static class TedTestConfig +{ + internal static string NewPath () + { + return Path.Combine ( + Path.GetTempPath (), + "ted-tests", + Guid.NewGuid ().ToString ("N"), + "ted.config.json"); + } +} diff --git a/tests/Terminal.Gui.Editor.PerformanceTests/LargeDocumentLoadPerformanceTests.cs b/tests/Terminal.Gui.Editor.PerformanceTests/LargeDocumentLoadPerformanceTests.cs new file mode 100644 index 0000000..0a90cda --- /dev/null +++ b/tests/Terminal.Gui.Editor.PerformanceTests/LargeDocumentLoadPerformanceTests.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; +using System.Text; +using Terminal.Gui.Document; +using Terminal.Gui.Highlighting; +using Xunit; + +namespace Terminal.Gui.Editor.PerformanceTests; + +public class LargeDocumentLoadPerformanceTests +{ + /// + /// A 10 MiB / ~150k-line C#-highlighted document must load through + /// well under budget. Before max-width virtualization this took ~10 s because the editor built and + /// syntax-highlighted a CellVisualLine for every line just to size the horizontal scrollbar. + /// The model layer alone loads in ~0.2 s, so 3 s is a deliberately loose CI-jitter budget (~5×). + /// + [Fact] + public async Task Editor_LoadAsync_10Mb_HighlightedSource_CompletesWellUnderBudget () + { + var sb = new StringBuilder (10 * 1024 * 1024 + 128); + + while (sb.Length < 10 * 1024 * 1024) + { + sb.Append (" private const int Id = 12345; // a representative C# source line\n"); + } + + var bytes = Encoding.UTF8.GetBytes (sb.ToString ()); + + Editor editor = new () + { + HighlightingDefinition = HighlightingManager.Instance.GetDefinitionByExtension (".cs") + }; + + await using MemoryStream stream = new (bytes); + + Stopwatch sw = Stopwatch.StartNew (); + await editor.LoadAsync (stream, cancellationToken: TestContext.Current.CancellationToken); + sw.Stop (); + + Assert.Equal (bytes.Length, editor.Document!.TextLength); + Assert.True ( + sw.ElapsedMilliseconds < 3000, + $"Editor.LoadAsync of {bytes.Length:N0} bytes took {sw.ElapsedMilliseconds} ms — " + + "expected < 3000 ms (was ~10 s before max-width virtualization)."); + } +} diff --git a/tests/Terminal.Gui.Editor.PerformanceTests/StreamingLoadPerformanceTests.cs b/tests/Terminal.Gui.Editor.PerformanceTests/StreamingLoadPerformanceTests.cs new file mode 100644 index 0000000..4f91af7 --- /dev/null +++ b/tests/Terminal.Gui.Editor.PerformanceTests/StreamingLoadPerformanceTests.cs @@ -0,0 +1,36 @@ +using System.Diagnostics; +using System.Text; +using Terminal.Gui.Document; +using Xunit; + +namespace Terminal.Gui.Editor.PerformanceTests; + +public class StreamingLoadPerformanceTests +{ + private static readonly TimeSpan InitialProgressBudget = TimeSpan.FromMilliseconds (500); + + [Fact] + public async Task StreamingLoad_10Mb_ReportsInitialProgressWithinBudget () + { + var bytes = Encoding.UTF8.GetBytes (new string ('x', 10 * 1024 * 1024)); + await using MemoryStream stream = new (bytes); + TaskCompletionSource firstProgress = new (); + Progress progress = new (_ => firstProgress.TrySetResult ()); + + Stopwatch sw = Stopwatch.StartNew (); + Task loadTask = TextDocument.LoadAsync ( + stream, + progress: progress, + cancellationToken: TestContext.Current.CancellationToken); + Task completed = await Task.WhenAny ( + firstProgress.Task, + Task.Delay (InitialProgressBudget, TestContext.Current.CancellationToken)); + sw.Stop (); + + Assert.Same (firstProgress.Task, completed); + Assert.True (sw.Elapsed < InitialProgressBudget, + $"Initial streaming load progress took {sw.ElapsedMilliseconds}ms — expected < {InitialProgressBudget.TotalMilliseconds:N0}ms."); + + _ = await loadTask; + } +} diff --git a/tests/Terminal.Gui.Editor.Tests/MaxWidthEstimationTests.cs b/tests/Terminal.Gui.Editor.Tests/MaxWidthEstimationTests.cs new file mode 100644 index 0000000..4ae2ca3 --- /dev/null +++ b/tests/Terminal.Gui.Editor.Tests/MaxWidthEstimationTests.cs @@ -0,0 +1,48 @@ +// Claude - claude-opus-4-7 + +using System.Drawing; +using System.Text; +using Terminal.Gui.Document; +using Xunit; + +namespace Terminal.Gui.Editor.Tests; + +/// +/// The horizontal content extent is computed exactly (building a CellVisualLine per line) +/// for normal-size documents, but estimated from character length for large ones — building + +/// highlighting every line on load is what made a 10 MiB open take ~10 s. These pin both branches +/// and the threshold so the fast path can't silently change small-document behavior. +/// +public class MaxWidthEstimationTests +{ + // A single tab-only line: char length 1, but exact visual width expands to a tab stop (> 1). + [Fact] + public void SmallDocument_UsesExactTabExpandedWidth () + { + Editor editor = new () { Document = new TextDocument ("\t\t\tx") }; + + // Exact path: 3 tabs expand to tab stops, so the extent is far wider than the 4-char length. + Assert.True ( + editor.GetContentSize ().Width > 4 + 1, + $"Small-doc width {editor.GetContentSize ().Width} should be tab-expanded (exact), not the " + + "char-length estimate (5)."); + } + + [Fact] + public void LargeDocument_UsesCharLengthEstimate_NotTabExpanded () + { + // > 256 KiB of identical tab-heavy lines. Each line is "\t\t\tx" (char length 4); the exact + // tab-expanded visual width would be much larger. The estimate path must report char length. + var sb = new StringBuilder (400 * 1024); + + while (sb.Length < 300 * 1024) + { + sb.Append ("\t\t\tx\n"); + } + + Editor editor = new () { Document = new TextDocument (sb.ToString ()) }; + + // Estimate path: width == longest line's char length (4) + 1 (caret past EOL), NOT tab-expanded. + Assert.Equal (4 + 1, editor.GetContentSize ().Width); + } +} diff --git a/tests/Terminal.Gui.Editor.Tests/TextDocumentStreamingTests.cs b/tests/Terminal.Gui.Editor.Tests/TextDocumentStreamingTests.cs new file mode 100644 index 0000000..32f0dd8 --- /dev/null +++ b/tests/Terminal.Gui.Editor.Tests/TextDocumentStreamingTests.cs @@ -0,0 +1,113 @@ +// CoPilot - gpt-5.4 + +using System.Runtime.CompilerServices; +using System.Text; +using Terminal.Gui.Document; +using Xunit; + +namespace Terminal.Gui.Editor.Tests; + +public class TextDocumentStreamingTests +{ + [Fact] + public async Task LoadAsync_SaveAsync_RoundTrips_MixedLineEndings_AndBom () + { + UTF8Encoding encoding = new (true); + var text = "one\r\ntwo\nthree\rfour"; + var bytes = encoding.GetPreamble ().Concat (encoding.GetBytes (text)).ToArray (); + + await using MemoryStream input = new (bytes); + TextDocument document = await TextDocument.LoadAsync ( + input, + cancellationToken: TestContext.Current.CancellationToken); + + await using MemoryStream output = new (); + await document.SaveAsync (output, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal (text, document.Text); + Assert.Equal (bytes, output.ToArray ()); + } + + [Fact] + public async Task LoadAsync_Reports_Multiple_Progress_Updates_For_Large_Stream () + { + var text = new string ('x', 100_000); + await using MemoryStream input = new (Encoding.UTF8.GetBytes (text)); + List reports = []; + CapturingProgress progress = new (reports); + + TextDocument document = await TextDocument.LoadAsync ( + input, + progress: progress, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal (text.Length, document.TextLength); + Assert.True (reports.Count > 1); + Assert.Equal (text.Length, reports[^1].CharactersProcessed); + } + + [Fact] + public async Task LoadAsync_Observes_Cancellation () + { + await using MemoryStream input = new (Encoding.UTF8.GetBytes ("abc")); + using CancellationTokenSource cts = new (); + await cts.CancelAsync (); + + await Assert.ThrowsAsync (() => + TextDocument.LoadAsync (input, cancellationToken: cts.Token)); + } + + [Fact] + public async Task Editor_LoadAsync_And_SaveAsync_Delegate_To_Document () + { + Editor editor = new (); + await using MemoryStream input = new (Encoding.UTF8.GetBytes ("alpha\r\nbeta")); + + await editor.LoadAsync (input, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal ("alpha\r\nbeta", editor.Document!.Text); + + await using MemoryStream output = new (); + await editor.SaveAsync (output, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal ("alpha\r\nbeta", Encoding.UTF8.GetString (output.ToArray ())); + } + + [Fact] + public void SetOwnerThread_Documentation_Describes_NullOwner_FirstAccessClaim () + { + string source = File.ReadAllText (LocateSource ("Document/TextDocument.cs")); + + Assert.Contains ("first access", source, StringComparison.OrdinalIgnoreCase); + } + + private static string LocateSource (string relativePath, [CallerFilePath] string testFilePath = "") + { + var testDirectory = Path.GetDirectoryName (testFilePath) + ?? throw new InvalidOperationException ("Caller file path was not provided."); + var candidate = Path.GetFullPath ( + Path.Combine (testDirectory, "..", "..", "src", "Terminal.Gui.Editor", relativePath)); + + if (File.Exists (candidate)) + { + return candidate; + } + + throw new FileNotFoundException ($"Could not locate {relativePath}."); + } + + private sealed class CapturingProgress : IProgress + { + private readonly List _reports; + + public CapturingProgress (List reports) + { + _reports = reports; + } + + public void Report (TextDocumentProgress value) + { + _reports.Add (value); + } + } +} diff --git a/third_party/AvaloniaEdit/UPSTREAM.md b/third_party/AvaloniaEdit/UPSTREAM.md index fe4927a..18c9826 100644 --- a/third_party/AvaloniaEdit/UPSTREAM.md +++ b/third_party/AvaloniaEdit/UPSTREAM.md @@ -81,6 +81,7 @@ Each lifted file carries `// Adapted for Terminal.Gui from AvaloniaEdit d7a6b63` | All `Indentation/*.cs` | `namespace AvaloniaEdit.Indentation` → `namespace Terminal.Gui.Text.Indentation`; `using AvaloniaEdit.Document` → `using Terminal.Gui.Document`. | | `Indentation/DefaultIndentationStrategy.cs` | Replaced `ArgumentNullException` throws with `ArgumentNullException.ThrowIfNull` (modern pattern). Replaced `var previousLine = line.PreviousLine;` with `DocumentLine? previousLine = line.PreviousLine;` (house style: explicit type for non-built-in). Null-check replaced with pattern match (`is null`). | | `Document/DocumentLineTree.cs` | Stripped `using Avalonia.Threading;` and the five `Dispatcher.UIThread.VerifyAccess()` call sites (commented out with rationale). The document is no longer thread-affined — that's a UI concern, owned by `Terminal.Gui.Editor`. | +| `Document/TextDocument.cs` | **Fork addition (file-io, DEC-009).** Added streaming `LoadAsync(Stream, ...)`, `SaveAsync(Stream, ...)`, and `Encoding` metadata so the rope-backed document can load/save large files without materializing the whole file as one `string`. `VerifyAccess()` now lazily claims a document whose owner was deliberately released after async load/save handoff. | | `Document/TextSegmentCollection.cs` | Same `Avalonia.Threading` strip + one `VerifyAccess()` site stripped. | | `Search/ISearchStrategy.cs` | Namespace transform only. No Avalonia references upstream. | | `Search/RegexSearchStrategy.cs` | Namespace transform; `using AvaloniaEdit.Document` → `using Terminal.Gui.Document`. No Avalonia references upstream. Contains both `RegexSearchStrategy` and `SearchResult` (kept as a single file matching upstream layout). Added `#nullable disable` directive after the "Adapted for" line — upstream predates nullable reference types (`IEquatable.Equals` override, `SearchResult.Data` auto-property, and `FindAll().FirstOrDefault()` all trip CS warnings under nullable enable; suppressing per-file matches the fork policy of "minimal targeted edits to lifted source"). **Correctness deviation**: `Equals(ISearchStrategy)` now includes `_matchWholeWords` in the comparison. Upstream omits it, so two strategies that differ only by whole-word matching compare equal — breaks consumer caching/dedup. Surfaced in Copilot review of PR #76. **Perf deviation** (gui-cs/Text#82): `FindAll` now drives the regex engine via `Regex.Match(text, startat)` + `NextMatch()` from `offset` instead of `_searchPattern.Matches(text)` over the whole document followed by post-filtering. Upstream re-scans the prefix `[0, offset)` on every call — wasted work for incremental advancing search (one FindNext per F3 keystroke). The .NET regex engine preserves `RegexOptions.Multiline` `^` / `$` semantics across `startat` (anchoring at the start position only when it is 0 or follows a newline). Worth mirroring upstream at AvaloniaEdit. |