diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 4982b92701..5617e76024 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -48,33 +48,17 @@ The repository uses multiple GitHub Actions workflows. What runs and when: 3. Runs IntegrationTests with diagnostic output 4. Uploads logs per-OS -### 4) Create Release (`.github/workflows/release.yml`) - -- **Triggers**: `workflow_dispatch` (manual trigger from GitHub Actions UI) -- **Inputs**: - - `release_type`: Choose from `beta`, `rc`, or `stable` - - `version_override`: (Optional) Specify exact version number, otherwise GitVersion calculates it -- **Process**: - 1. Checks out `main` branch - 2. Determines version using GitVersion or override - 3. Creates annotated git tag (e.g., `v2.0.1-rc.1` or `v2.1.0`) - 4. Creates release commit with message - 5. Pushes tag and commit to repository - 6. Creates GitHub Release (marked as pre-release if not stable) - 7. Automatically triggers publish workflow (see below) -- **Purpose**: Automates the release process to prevent manual errors - -### 5) Publish to NuGet (`.github/workflows/publish.yml`) +### 4) Publish to NuGet (`.github/workflows/publish.yml`) - **Triggers**: push to `develop` and tags `v*` (ignores `**.md`) - Uses GitVersion to compute SemVer, builds Release, packs Terminal.Gui and Terminal.Gui.Interop.Spectre with symbols, and pushes both to NuGet.org using `NUGET_API_KEY` -- **Automatically triggered** by the Create Release workflow when a new tag is pushed +- **Automatically triggered** when a `v*` tag is pushed — typically by the **Finalize Release** workflow after a release PR (from **Prepare Release**) is merged into `main` - **Additional actions on tag push** (`v*`): - Triggers Terminal.Gui.templates repository update via repository_dispatch (requires `TEMPLATE_REPO_TOKEN` secret) -### 6) Build and publish API docs (`.github/workflows/api-docs.yml`) +### 5) Build and publish API docs (`.github/workflows/api-docs.yml`) -- **Triggers**: push to `main` +- **Triggers**: push to `develop`, and `workflow_dispatch` - Builds DocFX site on Windows and deploys to GitHub Pages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index e48d1f2e38..0000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,144 +0,0 @@ -name: Create Release - -on: - workflow_dispatch: - inputs: - release_type: - description: 'Release type' - required: true - type: choice - options: - - prealpha - - alpha - - beta - - rc - - stable - default: 'beta' - version_override: - description: 'Version override (optional, e.g., 2.0.0). If not provided, GitVersion will calculate it.' - required: false - type: string - -jobs: - create-release: - name: Create and Tag Release - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Checkout main - uses: actions/checkout@v7 - with: - ref: main - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Configure Git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Install GitVersion - uses: gittools/actions/gitversion/setup@v4.5.0 - with: - versionSpec: '6.0.x' - - - name: Determine Version - uses: gittools/actions/gitversion/execute@v4.5.0 - with: - useConfigFile: true - updateAssemblyInfo: true - id: gitversion - - - name: Prepare Release Variables - id: prepare - run: | - if [ -n "${{ github.event.inputs.version_override }}" ]; then - VERSION="${{ github.event.inputs.version_override }}" - else - VERSION="${{ steps.gitversion.outputs.MajorMinorPatch }}" - fi - - RELEASE_TYPE="${{ github.event.inputs.release_type }}" - - if [ "$RELEASE_TYPE" = "stable" ]; then - TAG="v${VERSION}" - RELEASE_NAME="v${VERSION}" - else - TAG="v${VERSION}-${RELEASE_TYPE}" - RELEASE_NAME="v${VERSION}-${RELEASE_TYPE}" - fi - - echo "version=${VERSION}" >> $GITHUB_OUTPUT - echo "tag=${TAG}" >> $GITHUB_OUTPUT - echo "release_name=${RELEASE_NAME}" >> $GITHUB_OUTPUT - echo "release_type=${RELEASE_TYPE}" >> $GITHUB_OUTPUT - - - name: Check if tag already exists - run: | - if git rev-parse "${{ steps.prepare.outputs.tag }}" >/dev/null 2>&1; then - echo "::error::Tag ${{ steps.prepare.outputs.tag }} already exists in the repository." - echo "::error::To resolve this:" - echo "::error:: - Choose a different version number, or" - echo "::error:: - Delete the existing tag if this is intentional: git push --delete origin ${{ steps.prepare.outputs.tag }}" - exit 1 - fi - - - name: Create Release Tag - run: | - # Create tag first to match the original manual release script behavior - # Note: This means the tag points to the commit before the release commit - git tag -a "${{ steps.prepare.outputs.tag }}" -m "Release ${{ steps.prepare.outputs.release_name }}" - echo "Created tag: ${{ steps.prepare.outputs.tag }}" - - - name: Create Release Commit - run: | - git commit --allow-empty -m "Release ${{ steps.prepare.outputs.release_name }}" - echo "Created release commit" - - - name: Push Changes - run: | - git push origin main - git push origin "${{ steps.prepare.outputs.tag }}" - echo "Pushed branch and tag to origin" - - - name: Create GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_NAME: ${{ steps.prepare.outputs.release_name }} - VERSION: ${{ steps.prepare.outputs.version }} - RELEASE_TYPE: ${{ steps.prepare.outputs.release_type }} - TAG: ${{ steps.prepare.outputs.tag }} - run: | - PRERELEASE_FLAG="" - if [ "$RELEASE_TYPE" != "stable" ]; then - PRERELEASE_FLAG="--prerelease" - fi - - # Create release notes - cat > /tmp/release_notes.md << EOF - Release ${RELEASE_NAME} - - This release was created automatically using the release workflow. - - - **Version:** ${VERSION} - - **Type:** ${RELEASE_TYPE} - - The package will be automatically published to NuGet.org via the publish workflow. - EOF - - gh release create "$TAG" \ - --title "$RELEASE_NAME" \ - $PRERELEASE_FLAG \ - --notes-file /tmp/release_notes.md - - - name: Summary - run: | - echo "## Release Created Successfully! :rocket:" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- **Tag:** ${{ steps.prepare.outputs.tag }}" >> $GITHUB_STEP_SUMMARY - echo "- **Version:** ${{ steps.prepare.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "- **Type:** ${{ steps.prepare.outputs.release_type }}" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "The publish workflow will automatically build and publish this release to NuGet.org." >> $GITHUB_STEP_SUMMARY diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aa962c6a98..6449f2f275 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -336,23 +336,21 @@ foreach (Rune rune in text.EnumerateRunes ()) ### Automated Release Workflow -Releases are now automated using GitHub Actions to prevent manual errors. To create a release: +Releases are automated using GitHub Actions to prevent manual errors. The flow is PR-based: **Prepare Release** opens a release PR from `develop` → `main`, and merging it runs **Finalize Release**. To create a release: 1. **Navigate to Actions tab** in the GitHub repository -2. **Select "Create Release" workflow** from the left sidebar +2. **Select "Prepare Release" workflow** from the left sidebar 3. **Click "Run workflow"** button 4. **Configure release parameters:** - - **Branch:** Ensure `main` is selected - - **Release type:** Choose from `prealpha`, `alpha`, `beta`, `rc`, or `stable` + - **Release type:** Choose from `beta`, `rc`, or `stable` - **Version override:** (Optional) Specify exact version (e.g., `2.0.0`), otherwise GitVersion calculates it automatically -5. **Click "Run workflow"** to start the automated release process - -The workflow will: -- Create an annotated git tag (e.g., `v2.0.0-prealpha` or `v2.0.0`) -- Create a release commit on `main` -- Push the tag and commit to the repository -- Create a GitHub Release -- Automatically trigger the publish workflow to push the package to NuGet.org +5. **Click "Run workflow"** to create the release PR + +Prepare Release creates a `release/` branch — where `` is the full version tag, e.g. `release/v2.0.0` for a stable release or `release/v2.0.0-beta.1` for a pre-release — with the GitVersion label updated for the release type, and opens a PR into `main`. After CI passes, **review and merge that PR**. Merging triggers **Finalize Release**, which: +- Creates an annotated git tag (e.g., `v2.0.0-beta.1` or `v2.0.0`) +- Creates a GitHub Release with auto-generated notes +- Opens a back-merge PR from `main` → `develop` +- Triggers the publish workflow (via the `v*` tag) to push the package to NuGet.org ## What NOT to Do diff --git a/GitVersion.yml b/GitVersion.yml index b6e1287aae..18f1a8b6f5 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -34,7 +34,7 @@ branches: # Matches the main branch regex: ^main$ # Uses an empty label for stable releases - label: beta + label: '' # Increments patch version (x.y.z+1) on commits increment: Patch # Specifies develop as the source branch diff --git a/Terminal.Gui/Configuration/Settings/TuiConfigurationBuilder.cs b/Terminal.Gui/Configuration/Settings/TuiConfigurationBuilder.cs index b753e862e5..a8f8176ddb 100644 --- a/Terminal.Gui/Configuration/Settings/TuiConfigurationBuilder.cs +++ b/Terminal.Gui/Configuration/Settings/TuiConfigurationBuilder.cs @@ -1,4 +1,3 @@ -using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Reflection; using Microsoft.Extensions.Configuration; @@ -168,7 +167,7 @@ public void ApplyToStaticFacades () [UnconditionalSuppressMessage ("Trimming", "IL2026", Justification = "Settings POCOs are simple types preserved by DynamicDependency in ConfigPropertyHostTypes.")] [UnconditionalSuppressMessage ("AOT", "IL3050", Justification = "Settings POCOs are simple types; no generic instantiation needed at runtime.")] - private static void BindSection (IConfiguration config, string sectionName, Action apply) where T : new () + private static void BindSection<[DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicProperties)] T> (IConfiguration config, string sectionName, Action apply) where T : new () { T settings = new (); IConfigurationSection section = config.GetSection (sectionName); @@ -206,10 +205,10 @@ private static void BindThemeScalar (IConfiguration config) /// /// Binds flat dotted keys (e.g. Driver.Force16Colors) from the configuration root to the - /// corresponding properties on the settings POCO. + /// corresponding properties on the settings POCO. 's public properties are + /// preserved for trimming via the on the type parameter. /// - [UnconditionalSuppressMessage ("Trimming", "IL2026", Justification = "Settings POCOs are simple types preserved by DynamicDependency in ConfigPropertyHostTypes.")] - private static void BindFlatDottedKeys (IConfiguration config, string sectionName, T settings) + private static void BindFlatDottedKeys<[DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.PublicProperties)] T> (IConfiguration config, string sectionName, T settings) { string prefix = sectionName + "."; @@ -244,8 +243,12 @@ private static void BindFlatDottedKeys (IConfiguration config, string section } /// - /// Converts a configuration string value to the target property type, using a fast path for the - /// common scalar types and falling back to a . + /// Converts a configuration string value to the target property type. Only the scalar types used by the + /// settings POCOs are supported, so this path is trim/AOT-safe — it deliberately avoids + /// TypeDescriptor.GetConverter (which is / + /// and breaks NativeAOT/trimmed consumers). New non-scalar + /// settings property types must be added here explicitly. Unsupported types return + /// and are skipped by . /// private static object? ConvertValue (string value, Type targetType) { @@ -269,16 +272,14 @@ private static void BindFlatDottedKeys (IConfiguration config, string section return value.Length > 0 ? new Rune (value [0]) : new Rune ('+'); } - if (targetType.IsEnum) + if (targetType == typeof (Key)) { - return Enum.Parse (targetType, value); + return Key.TryParse (value, out Key key) ? key : null; } - TypeConverter converter = TypeDescriptor.GetConverter (targetType); - - if (converter.CanConvertFrom (typeof (string))) + if (targetType.IsEnum) { - return converter.ConvertFromInvariantString (value); + return Enum.Parse (targetType, value); } return null; diff --git a/Terminal.Gui/README.md b/Terminal.Gui/README.md index a2deae60dc..283ec00a4a 100644 --- a/Terminal.Gui/README.md +++ b/Terminal.Gui/README.md @@ -51,7 +51,7 @@ Two workflows handle the release lifecycle: **Step 1 — [Prepare Release](../.github/workflows/prepare-release.yml)** (manual trigger): 1. Go to **Actions → Prepare Release → Run workflow**. 2. Pick the release type (`beta`, `rc`, `stable`) and optionally override the version. -3. The workflow creates a `release/vX.Y.Z` branch from `develop`, updates the `GitVersion.yml` label, and opens a PR into `main`. +3. The workflow creates a `release/` branch from `develop` — where `` is the full version tag, e.g. `release/v2.0.0` or `release/v2.0.0-beta.1` — updates the `GitVersion.yml` label, and opens a PR into `main`. 4. Review the PR — CI runs, branch protections apply. **Step 2 — [Finalize Release](../.github/workflows/finalize-release.yml)** (automatic on PR merge): @@ -110,8 +110,7 @@ All workflows are in [`.github/workflows/`](../.github/workflows/): |----------|---------|---------| | **[prepare-release.yml](../.github/workflows/prepare-release.yml)** | Manual dispatch | Creates a release PR from `develop` → `main` with label updates | | **[finalize-release.yml](../.github/workflows/finalize-release.yml)** | Release PR merged to `main` | Creates tag, GitHub Release, back-merge PR to `develop` | -| **[publish.yml](../.github/workflows/publish.yml)** | Push to `main` or `develop`, version tags | Builds Release config, packs, and publishes to NuGet.org | -| **[release.yml](../.github/workflows/release.yml)** | Manual dispatch | **(Legacy)** Direct tag-and-release on `main`; superseded by prepare/finalize | +| **[publish.yml](../.github/workflows/publish.yml)** | Push to `develop`, or `v*` tags | Builds Release config, packs, and publishes to NuGet.org | | **[build-validation.yml](../.github/workflows/build-validation.yml)** | Push/PR to `main` or `develop` | Builds all configurations to validate compilation | | **[unit-tests.yml](../.github/workflows/unit-tests.yml)** | Push/PR to `main` or `develop` | Runs all unit tests | | **[api-docs.yml](../.github/workflows/api-docs.yml)** | Push to `develop` | Builds and deploys API docs to GitHub Pages | @@ -135,7 +134,7 @@ These branches are **not** configured in `GitVersion.yml` (the config was remove ## NuGet Package - **Package**: [nuget.org/packages/Terminal.Gui](https://www.nuget.org/packages/Terminal.Gui) -- **Auto-published** on every push to `main` or `develop` (pre-release versions from `develop`, stable versions from `main`) +- **Auto-published** on every push to `develop` (pre-release versions) and on `v*` tag pushes (stable/pre-release versions tagged on `main`) - Pre-release versions (e.g., `2.1.0-develop.42`) are marked as pre-release on NuGet ### Local Package Development @@ -154,7 +153,7 @@ dotnet build Terminal.Gui/Terminal.Gui.csproj -c Release - **Live docs**: [tui-cs.github.io/Terminal.Gui](https://tui-cs.github.io/Terminal.Gui) - **DocFX source**: [`docfx/`](../docfx/) — see [`docfx/README.md`](../docfx/README.md) for local generation -- **API docs** are auto-deployed to GitHub Pages on every push to `main` +- **API docs** are auto-deployed to GitHub Pages on every push to `develop` ## Contributing diff --git a/Terminal.Gui/ViewBase/View.Layout.cs b/Terminal.Gui/ViewBase/View.Layout.cs index 4dff1da40a..c6688cdd4e 100644 --- a/Terminal.Gui/ViewBase/View.Layout.cs +++ b/Terminal.Gui/ViewBase/View.Layout.cs @@ -665,54 +665,12 @@ public bool SetRelativeLayout (Size superviewContentSize) // TODO: Should move to View.LayoutSubViews? SetTextFormatterSize (); - int newX, newW, newY, newH; - - try - { - // Calculate the new X, Y, Width, and Height - // If the Width or Height is Dim.Auto, calculate the Width or Height first. Otherwise, calculate the X or Y first. - if (_width.Has (out _)) - { - newW = _width.Calculate (0, superviewContentSize.Width, this, Dimension.Width); - newX = _x.Calculate (superviewContentSize.Width, newW, this, Dimension.Width); - - if (newW != Frame.Width) - { - // Pos.Calculate got us a new position. We need to redo dimension - newW = _width.Calculate (newX, superviewContentSize.Width, this, Dimension.Width); - } - } - else - { - newX = _x.Calculate (superviewContentSize.Width, _width, this, Dimension.Width); - newW = _width.Calculate (newX, superviewContentSize.Width, this, Dimension.Width); - } - - if (_height.Has (out _)) - { - newH = _height.Calculate (0, superviewContentSize.Height, this, Dimension.Height); - newY = _y.Calculate (superviewContentSize.Height, newH, this, Dimension.Height); - - if (newH != Frame.Height) - { - // Pos.Calculate got us a new position. We need to redo dimension - newH = _height.Calculate (newY, superviewContentSize.Height, this, Dimension.Height); - } - } - else - { - newY = _y.Calculate (superviewContentSize.Height, _height, this, Dimension.Height); - newH = _height.Calculate (newY, superviewContentSize.Height, this, Dimension.Height); - } - } - catch (LayoutException) + if (!TryComputeRelativeFrame (superviewContentSize, out Rectangle newFrame)) { - //Debug.WriteLine ($"A Dim/PosFunc function threw (typically this is because a dependent View was not laid out)\n{le}."); + //Debug.WriteLine ($"A Dim/PosFunc function threw (typically this is because a dependent View was not laid out)"); return false; } - Rectangle newFrame = new (newX, newY, newW, newH); - if (Frame != newFrame) { // Set the frame. Do NOT use `Frame = newFrame` as it overwrites X, Y, Width, and Height @@ -768,6 +726,23 @@ public bool SetRelativeLayout (Size superviewContentSize) } } + FinalizeTextFormatterConstraints (); + + return true; + } + + /// + /// Finalizes 's size constraints after the has been resolved. + /// Any constraint left unset by (e.g. for fixed dimensions whose content + /// size was not explicitly set) is filled from the view's content size so wrapped/clipped text formats + /// correctly. + /// + /// + /// Called by and by the setter's same-frame fast path, which + /// skips but must leave in the same state. + /// + private void FinalizeTextFormatterConstraints () + { if (TextFormatter.ConstrainToWidth is null) { TextFormatter.ConstrainToWidth = GetContentWidth (); @@ -777,6 +752,70 @@ public bool SetRelativeLayout (Size superviewContentSize) { TextFormatter.ConstrainToHeight = GetContentHeight (); } + } + + /// + /// Resolves this view's / expressions into the absolute + /// they would produce, without applying it. This is the size/position computation + /// performs and is the single source of truth for both that method and any + /// speculative "would the Frame change?" check. + /// + /// The size of the SuperView's content. + /// The resolved Frame. Valid only when the method returns . + /// + /// if the Frame could be resolved; if a dependency was not ready + /// (a was thrown during calculation). + /// + private bool TryComputeRelativeFrame (Size superviewContentSize, out Rectangle frame) + { + frame = Rectangle.Empty; + + int newX, newW, newY, newH; + + try + { + // Calculate the new X, Y, Width, and Height + // If the Width or Height is Dim.Auto, calculate the Width or Height first. Otherwise, calculate the X or Y first. + if (_width.Has (out _)) + { + newW = _width.Calculate (0, superviewContentSize.Width, this, Dimension.Width); + newX = _x.Calculate (superviewContentSize.Width, newW, this, Dimension.Width); + + if (newW != Frame.Width) + { + // Pos.Calculate got us a new position. We need to redo dimension + newW = _width.Calculate (newX, superviewContentSize.Width, this, Dimension.Width); + } + } + else + { + newX = _x.Calculate (superviewContentSize.Width, _width, this, Dimension.Width); + newW = _width.Calculate (newX, superviewContentSize.Width, this, Dimension.Width); + } + + if (_height.Has (out _)) + { + newH = _height.Calculate (0, superviewContentSize.Height, this, Dimension.Height); + newY = _y.Calculate (superviewContentSize.Height, newH, this, Dimension.Height); + + if (newH != Frame.Height) + { + // Pos.Calculate got us a new position. We need to redo dimension + newH = _height.Calculate (newY, superviewContentSize.Height, this, Dimension.Height); + } + } + else + { + newY = _y.Calculate (superviewContentSize.Height, _height, this, Dimension.Height); + newH = _height.Calculate (newY, superviewContentSize.Height, this, Dimension.Height); + } + } + catch (LayoutException) + { + return false; + } + + frame = new (newX, newY, newW, newH); return true; } diff --git a/Terminal.Gui/ViewBase/View.Text.cs b/Terminal.Gui/ViewBase/View.Text.cs index 5625c77ecd..820f0689ff 100644 --- a/Terminal.Gui/ViewBase/View.Text.cs +++ b/Terminal.Gui/ViewBase/View.Text.cs @@ -65,11 +65,83 @@ public virtual string Text _text = value; UpdateTextFormatterText (); + + if (TryRedrawWithoutLayout ()) + { + OnTextChanged (); + + return; + } + SetNeedsLayout (); OnTextChanged (); } } + /// + /// If the new resolves to the same (position and size) the view has now, + /// handles the change with a redraw only — skipping and the ancestor layout + /// propagation it triggers. See issue #5499. + /// + /// + /// + /// The prediction reuses — the exact computation + /// runs — so it can never disagree with the Frame a real layout pass would + /// produce, and it compares the whole (a can depend on + /// , so a same-size change can still move the view). + /// + /// + /// The optimization applies only when the view has already been laid out and both and + /// are either a fixed dimension or exactly . A + /// with (including ) + /// lays out subviews while calculating, so resolving it here would have side effects; those views fall back + /// to . + /// + /// + /// On the fast path this mirrors the setup + /// performs ( plus the post-resolve constraint finalization) so wrapped or + /// clipped text stays correctly constrained, then marks the view for reformat and redraw. + /// + /// + /// + /// if the change was handled by redraw only; if a normal layout + /// pass is required. + /// + private bool TryRedrawWithoutLayout () + { + if (_frame is null) + { + // Not yet laid out; let the normal layout pass establish the Frame. + return false; + } + + if (!IsEagerlyResolvable (_width) || !IsEagerlyResolvable (_height)) + { + return false; + } + + // Mirror SetRelativeLayout's preamble so the prediction sees the same TextFormatter state. + SetTextFormatterSize (); + + if (!TryComputeRelativeFrame (GetContainerSize (), out Rectangle predicted) || predicted != Frame) + { + return false; + } + + // The Frame is unchanged, so nothing outside this view can be affected. Mirror SetRelativeLayout's formatter + // finalization (skipped because we bypass it) so wrapped/clipped text stays constrained, then reformat and + // redraw without a layout pass. + FinalizeTextFormatterConstraints (); + TextFormatter.NeedsFormat = true; + SetNeedsDraw (); + + return true; + + // A dimension is safe to resolve here only when doing so has no side effects beyond this view's own + // TextFormatter. DimAbsolute is constant; a Text-only DimAuto depends solely on the TextFormatter. + static bool IsEagerlyResolvable (Dim dim) => dim is DimAbsolute or DimAuto { Style: DimAutoStyle.Text }; + } + /// /// Gets or sets how the View's is aligned horizontally when drawn. Changing this property will /// redisplay the . diff --git a/Terminal.slnx b/Terminal.slnx index db03cfdc59..6a1b34315d 100644 --- a/Terminal.slnx +++ b/Terminal.slnx @@ -64,7 +64,6 @@ - diff --git a/Tests/UnitTestsParallelizable/Configuration/MecDottedKeyTests.cs b/Tests/UnitTestsParallelizable/Configuration/MecDottedKeyTests.cs index ba38373ebe..ceebb43f70 100644 --- a/Tests/UnitTestsParallelizable/Configuration/MecDottedKeyTests.cs +++ b/Tests/UnitTestsParallelizable/Configuration/MecDottedKeyTests.cs @@ -4,6 +4,7 @@ using System.Text; using Microsoft.Extensions.Configuration; using Terminal.Gui.Configuration; +using Terminal.Gui.Input; namespace ConfigurationTests; @@ -154,4 +155,28 @@ public void ApplyToStaticFacades_StillBindsNestedSectionFormat () DriverSettings.Defaults = original; } } + + /// + /// Verifies issue #5561 fix: a flat dotted key (PopoverMenu.DefaultKey) binds via + /// the AOT-safe fast path — i.e. without the trim-unsafe TypeDescriptor.GetConverter + /// fallback that was removed. + /// + [Fact] + public void ApplyToStaticFacades_BindsFlatDottedKeyTypedProperty () + { + PopoverMenuSettings original = PopoverMenuSettings.Defaults; + + try + { + TuiConfigurationBuilder builder = new (); + builder.RuntimeConfig = """{ "PopoverMenu.DefaultKey": "Ctrl+P" }"""; + builder.ApplyToStaticFacades (); + + Assert.Equal (Key.P.WithCtrl, PopoverMenuSettings.Defaults.DefaultKey); + } + finally + { + PopoverMenuSettings.Defaults = original; + } + } } diff --git a/Tests/UnitTestsParallelizable/ViewBase/Layout/Dim.AutoTests.TextOptimization.cs b/Tests/UnitTestsParallelizable/ViewBase/Layout/Dim.AutoTests.TextOptimization.cs new file mode 100644 index 0000000000..9a3b1fa069 --- /dev/null +++ b/Tests/UnitTestsParallelizable/ViewBase/Layout/Dim.AutoTests.TextOptimization.cs @@ -0,0 +1,374 @@ +using static Terminal.Gui.ViewBase.Dim; + +namespace ViewBaseTests.Layout; + +// Claude - Opus 4.8 +// Diagnostics for issue #5499: changing Text on a view sized solely by its Text (both Width and Height are exactly +// DimAutoStyle.Text) must skip SetNeedsLayout - and the ancestor propagation it triggers - when the new Text produces +// the same Frame size. The content still changes, so a reformat and redraw must still happen. +public partial class DimAutoTests +{ + [Fact] + public void Text_SameSize_DoesNotSetNeedsLayout_ButStillRedraws () + { + View view = new () { Width = Auto (DimAutoStyle.Text), Height = Auto (DimAutoStyle.Text), Text = "12:00:00" }; + view.Layout (); + Assert.Equal (new Rectangle (0, 0, 8, 1), view.Frame); + + view.NeedsLayout = false; + view.ClearNeedsDraw (); + + // Same width and height (8x1), different content + view.Text = "12:00:01"; + + Assert.False (view.NeedsLayout); + Assert.True (view.NeedsDraw); + } + + [Fact] + public void Text_SameSize_SetsTextFormatterNeedsFormat () + { + View view = new () { Width = Auto (DimAutoStyle.Text), Height = Auto (DimAutoStyle.Text), Text = "12:00:00" }; + view.Layout (); + + view.NeedsLayout = false; + view.TextFormatter.NeedsFormat = false; + + view.Text = "12:00:01"; + + Assert.True (view.TextFormatter.NeedsFormat); + } + + [Fact] + public void Text_SameSize_DoesNotPropagateNeedsLayoutToSuperView () + { + // This is the key diagnostic the issue asked for: an identical-size text update must not mark the ancestor + // chain as needing layout. + View superView = new () { Width = 50, Height = 10 }; + View view = new () { Width = Auto (DimAutoStyle.Text), Height = Auto (DimAutoStyle.Text), Text = "12:00:00" }; + superView.Add (view); + superView.Layout (new Size (50, 10)); + Assert.Equal (new Rectangle (0, 0, 8, 1), view.Frame); + + superView.NeedsLayout = false; + view.NeedsLayout = false; + + view.Text = "12:00:01"; + + Assert.False (view.NeedsLayout); + Assert.False (superView.NeedsLayout); + } + + [Fact] + public void Text_DifferentSize_SetsNeedsLayout_AndPropagatesToSuperView () + { + View superView = new () { Width = 50, Height = 10 }; + View view = new () { Width = Auto (DimAutoStyle.Text), Height = Auto (DimAutoStyle.Text), Text = "12:00:00" }; + superView.Add (view); + superView.Layout (new Size (50, 10)); + + superView.NeedsLayout = false; + view.NeedsLayout = false; + + // Wider text changes the Frame size + view.Text = "12:00:00 in the morning"; + + Assert.True (view.NeedsLayout); + Assert.True (superView.NeedsLayout); + } + + [Fact] + public void Text_SameSize_PreservesPendingLayoutRequest () + { + // The optimization only avoids *setting* NeedsLayout; it never clears it. A layout request that was already + // pending must survive an identical-size text change. + View view = new () { Width = Auto (DimAutoStyle.Text), Height = Auto (DimAutoStyle.Text), Text = "12:00:00" }; + view.Layout (); + + view.SetNeedsLayout (); + Assert.True (view.NeedsLayout); + + view.Text = "12:00:01"; + + Assert.True (view.NeedsLayout); + } + + [Fact] + public void Text_RepeatedSameWidthUpdates_NoFrameDrift_NoAncestorLayout () + { + // Clock simulation: a Text-auto label whose text changes every "tick" to the same width must never drift its + // Frame and never mark its SuperView for layout. See issue #5499. + View view = new () { Width = Auto (DimAutoStyle.Text), Height = Auto (DimAutoStyle.Text), Text = "00:00:00" }; + View superView = new () { Width = 40, Height = 5 }; + superView.Add (view); + superView.Layout (new Size (40, 5)); + Rectangle frame0 = view.Frame; + + for (var i = 0; i < 100; i++) + { + superView.NeedsLayout = false; + view.NeedsLayout = false; + + view.Text = $"{i % 24:D2}:{i % 60:D2}:{i % 60:D2}"; // always 8 wide + + Assert.False (view.NeedsLayout); + Assert.False (superView.NeedsLayout); + Assert.Equal (frame0, view.Frame); + } + } + + [Fact] + public void Text_ReentrantTextChanged_Settles () + { + // A TextChanged handler that sets Text again (to another same-width value) on the fast path must settle + // without an infinite loop and apply the final value. + View view = new () { Width = Auto (DimAutoStyle.Text), Height = Auto (DimAutoStyle.Text), Text = "aaa" }; + view.Layout (); + + var count = 0; + view.TextChanged += (sender, _) => + { + count++; + + if (count == 1) + { + ((View)sender!).Text = "bbb"; + } + }; + + view.NeedsLayout = false; + + view.Text = "ccc"; + + Assert.Equal ("bbb", view.Text); + Assert.Equal (new Rectangle (0, 0, 3, 1), view.Frame); + } + + [Fact] + public void Text_AutoStyle_BypassesOptimization () + { + // DimAutoStyle.Auto (Content | Text) also depends on content/subviews, so the optimization must not apply + // even when the text size is unchanged. + View view = new () { Width = Auto (), Height = Auto (), Text = "12:00:00" }; + view.Layout (); + + view.NeedsLayout = false; + + view.Text = "12:00:01"; + + Assert.True (view.NeedsLayout); + } + + [Fact] + public void Text_FixedDimensions_DoesNotSetNeedsLayout_ButStillRedraws () + { + // A fixed-size view's Frame never changes on a text change, so layout is skipped and only a redraw happens - + // even when the new text is a different length (it still fits the fixed Frame). See issue #5499 (Finding 3: + // fixed-size text redraws should not force layout). + View view = new () { Width = 20, Height = 3, Text = "12:00:00" }; + view.Layout (); + + view.NeedsLayout = false; + view.ClearNeedsDraw (); + + view.Text = "a much longer string that still fits the fixed width"; + + Assert.False (view.NeedsLayout); + Assert.True (view.NeedsDraw); + } + + [Fact] + public void Text_FixedDimensions_WordWrap_FormatterConstraintsMatchFullLayout () + { + // Regression: the same-frame fast path must mirror SetRelativeLayout's formatter finalization. Otherwise + // UpdateTextFormatterText clears the constraints and they are never restored, so a null (= int.MaxValue) width + // breaks wrapping/clipping for fixed-size text. See issue #5499 review. + View view = new () { Width = 5, Height = 2 }; + view.TextFormatter.WordWrap = true; + view.Text = "abc"; + view.Layout (); + + view.NeedsLayout = false; + + // Different content, same fixed Frame - takes the redraw-only fast path + view.Text = "abcdefghi"; + + Assert.False (view.NeedsLayout); + + // Contract: the fast path leaves TextFormatter in the same state a full layout pass would. + int? fastWidth = view.TextFormatter.ConstrainToWidth; + int? fastHeight = view.TextFormatter.ConstrainToHeight; + Assert.NotNull (fastWidth); + Assert.NotNull (fastHeight); + + view.SetNeedsLayout (); + view.Layout (); + Assert.Equal (view.TextFormatter.ConstrainToWidth, fastWidth); + Assert.Equal (view.TextFormatter.ConstrainToHeight, fastHeight); + } + + [Fact] + public void Text_OneAxisAuto_WordWrap_FormatterConstraintsMatchFullLayout () + { + // One axis Text-auto, the other fixed - same contract: fast-path constraints equal a full layout's. + View view = new () { Width = Auto (DimAutoStyle.Text), Height = 2 }; + view.TextFormatter.WordWrap = true; + view.Text = "abc"; + view.Layout (); + + view.NeedsLayout = false; + + view.Text = "xyz"; // same 3x2 Frame + + Assert.False (view.NeedsLayout); + + int? fastWidth = view.TextFormatter.ConstrainToWidth; + int? fastHeight = view.TextFormatter.ConstrainToHeight; + Assert.NotNull (fastWidth); + Assert.NotNull (fastHeight); + + view.SetNeedsLayout (); + view.Layout (); + Assert.Equal (view.TextFormatter.ConstrainToWidth, fastWidth); + Assert.Equal (view.TextFormatter.ConstrainToHeight, fastHeight); + } + + [Fact] + public void Text_OneAxisAuto_SameSize_DoesNotSetNeedsLayout () + { + // One axis Text-auto, the other fixed - a common label shape. Same-size text change skips layout. See issue + // #5499 (Finding 3: one-axis auto scenarios). + View view = new () { Width = Auto (DimAutoStyle.Text), Height = 1, Text = "12:00:00" }; + view.Layout (); + Assert.Equal (new Rectangle (0, 0, 8, 1), view.Frame); + + view.NeedsLayout = false; + + view.Text = "12:00:01"; + + Assert.False (view.NeedsLayout); + } + + [Fact] + public void Text_OneAxisAuto_DifferentSize_SetsNeedsLayout () + { + View view = new () { Width = Auto (DimAutoStyle.Text), Height = 1, Text = "12:00:00" }; + view.Layout (); + + view.NeedsLayout = false; + + view.Text = "12:00:00 in the morning"; + + Assert.True (view.NeedsLayout); + } + + [Fact] + public void Text_PositionDependsOnText_SameSize_SetsNeedsLayout () + { + // Finding 1: comparing only size is wrong. A Pos can depend on Text, so a same-size text change can still move + // the view. The full-Frame prediction must detect the position change and run layout. + View view = new () { Width = Auto (DimAutoStyle.Text), Height = Auto (DimAutoStyle.Text), Text = "aa" }; + view.X = Pos.Func (v => v!.Text.StartsWith ("b") ? 5 : 0, view); + view.Layout (); + Assert.Equal (new Rectangle (0, 0, 2, 1), view.Frame); + + view.NeedsLayout = false; + + // Same size (2x1) but X must move from 0 to 5 + view.Text = "bb"; + + Assert.True (view.NeedsLayout); + } + + [Fact] + public void Text_CompositeDim_BypassesOptimization () + { + // Finding 2: the guard must be exact. Dim.Auto(Text) + 2 is a composite (DimCombine), not a bare Text-auto, so + // it must not enter the size-only fast path. + View view = new () { Width = Auto (DimAutoStyle.Text) + 2, Height = Auto (DimAutoStyle.Text), Text = "12:00:00" }; + view.Layout (); + + view.NeedsLayout = false; + + view.Text = "12:00:01"; + + Assert.True (view.NeedsLayout); + } + + [Fact] + public void Text_NotYetLaidOut_SetsNeedsLayout () + { + // _frame is null until the first layout pass; the optimization must bypass so the first layout can establish + // the Frame. + View view = new () { Width = Auto (DimAutoStyle.Text), Height = Auto (DimAutoStyle.Text) }; + + view.Text = "12:00:00"; + + Assert.True (view.NeedsLayout); + } + + [Fact] + public void Text_SameSize_WithMinimum_DoesNotSetNeedsLayout () + { + // Both texts are narrower than the minimum, so the minimum anchor clamps the width to the same value. The + // predictor reuses DimAuto.Calculate, so the min anchor is honored. + View view = new () + { + Width = Auto (DimAutoStyle.Text, minimumContentDim: 20), + Height = Auto (DimAutoStyle.Text), + Text = "ab" + }; + view.Layout (); + Assert.Equal (20, view.Frame.Width); + + view.NeedsLayout = false; + + view.Text = "cd"; + + Assert.Equal (20, view.Frame.Width); + Assert.False (view.NeedsLayout); + } + + [Fact] + public void Text_ExceedsMinimum_SetsNeedsLayout () + { + // Growing past the minimum changes the resulting width, so layout must run. + View view = new () + { + Width = Auto (DimAutoStyle.Text, minimumContentDim: 20), + Height = Auto (DimAutoStyle.Text), + Text = "ab" + }; + view.Layout (); + Assert.Equal (20, view.Frame.Width); + + view.NeedsLayout = false; + + view.Text = new string ('x', 30); + + Assert.True (view.NeedsLayout); + } + + [Fact] + public void Text_SameSize_WithBorderAdornment_DoesNotSetNeedsLayout () + { + // The adornment thickness is part of the Frame size. The predictor must account for it (it does, by reusing + // DimAuto.Calculate which adds adornment thickness). + View view = new () + { + Width = Auto (DimAutoStyle.Text), + Height = Auto (DimAutoStyle.Text), + BorderStyle = LineStyle.Single, // 1-thick adornment on each side + Text = "12:00:00" + }; + view.Layout (); + Assert.Equal (new Rectangle (0, 0, 10, 3), view.Frame); // 8 + 2 wide, 1 + 2 tall + + view.NeedsLayout = false; + + view.Text = "12:00:01"; + + Assert.Equal (new Rectangle (0, 0, 10, 3), view.Frame); + Assert.False (view.NeedsLayout); + } +} diff --git a/Tests/UnitTestsParallelizable/ViewBase/Layout/TextFastPathRenderTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Layout/TextFastPathRenderTests.cs new file mode 100644 index 0000000000..b8c20a16b2 --- /dev/null +++ b/Tests/UnitTestsParallelizable/ViewBase/Layout/TextFastPathRenderTests.cs @@ -0,0 +1,117 @@ +using UnitTests; +using static Terminal.Gui.ViewBase.Dim; + +namespace ViewBaseTests.Layout; + +// Claude - Opus 4.8 +// End-to-end guard for the #5499 Text fast path: the redraw-only path (no layout pass after a same-Frame text change) +// must leave the view rendering exactly as a full layout pass would - including wrapped/clipped fixed-size text, whose +// TextFormatter constraints would otherwise be lost. Compares the FAST path (laid out only if the setter requested it) +// against a CONTROL that always runs a full layout. +public class TextFastPathRenderTests : TestDriverBase +{ + private static string DriverContents (IDriver driver) + { + System.Text.StringBuilder sb = new (); + + for (var r = 0; r < driver.Screen.Height; r++) + { + for (var c = 0; c < driver.Screen.Width; c++) + { + string g = driver.Contents! [r, c].Grapheme; + sb.Append (string.IsNullOrEmpty (g) ? " " : g); + } + + sb.Append ('\n'); + } + + return sb.ToString ().TrimEnd (); + } + + private string RenderFast (View v, IDriver driver, string newText) + { + v.NeedsLayout = false; + v.Text = newText; + + // Mirror the app loop: lay out only if the setter requested it. This keeps the redraw-only fast path isolated. + if (v.NeedsLayout) + { + v.Layout (); + } + + v.Draw (); + + return DriverContents (driver); + } + + private string RenderControl (View v, IDriver driver, string newText) + { + v.Text = newText; + v.SetNeedsLayout (); + v.Layout (); + v.Draw (); + + return DriverContents (driver); + } + + [Theory] + [InlineData (5, 2, true, "abcdefghi")] // fixed size + word wrap (the reported regression) + [InlineData (5, 2, false, "abcdefghi")] // fixed size, no wrap (clipping) + [InlineData (6, 1, true, "ab cd ef gh")] // fixed width, single fixed row + public void FixedSize_TextChange_RendersLikeFullLayout (int width, int height, bool wrap, string newText) + { + IDriver dFast = CreateTestDriver (24, 8); + IDriver dCtrl = CreateTestDriver (24, 8); + + View fast = new () { Driver = dFast, Width = width, Height = height, Text = "abc" }; + fast.TextFormatter.WordWrap = wrap; + View ctrl = new () { Driver = dCtrl, Width = width, Height = height, Text = "abc" }; + ctrl.TextFormatter.WordWrap = wrap; + + fast.Layout (); + fast.Draw (); + ctrl.Layout (); + ctrl.Draw (); + + string fastRender = RenderFast (fast, dFast, newText); + string ctrlRender = RenderControl (ctrl, dCtrl, newText); + + Assert.False (fast.NeedsLayout); // confirms the fast path was actually taken + Assert.Equal (ctrlRender, fastRender); + + fast.Dispose (); + ctrl.Dispose (); + dFast.Dispose (); + dCtrl.Dispose (); + } + + [Theory] + [InlineData (5)] // Auto(Text, max:5) + wrap: text wider than max wraps to multiple rows + [InlineData (4)] + public void MaxConstrainedAuto_WordWrap_RendersLikeFullLayout (int max) + { + IDriver dFast = CreateTestDriver (24, 8); + IDriver dCtrl = CreateTestDriver (24, 8); + + View fast = new () { Driver = dFast, Width = Auto (DimAutoStyle.Text, maximumContentDim: max), Height = Auto (DimAutoStyle.Text), Text = "ab cd" }; + fast.TextFormatter.WordWrap = true; + View ctrl = new () { Driver = dCtrl, Width = Auto (DimAutoStyle.Text, maximumContentDim: max), Height = Auto (DimAutoStyle.Text), Text = "ab cd" }; + ctrl.TextFormatter.WordWrap = true; + + fast.Layout (); + fast.Draw (); + ctrl.Layout (); + ctrl.Draw (); + + // "ab cd ef" keeps the same max-constrained width while wrapping - exercises the width->height prediction. + string fastRender = RenderFast (fast, dFast, "ab cd ef"); + string ctrlRender = RenderControl (ctrl, dCtrl, "ab cd ef"); + + Assert.Equal (ctrlRender, fastRender); + + fast.Dispose (); + ctrl.Dispose (); + dFast.Dispose (); + dCtrl.Dispose (); + } +}