diff --git a/.github/workflows/build-validation.yml b/.github/workflows/build-validation.yml
index e88baa6a05..a136aa68c8 100644
--- a/.github/workflows/build-validation.yml
+++ b/.github/workflows/build-validation.yml
@@ -20,6 +20,8 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6
+ with:
+ fetch-depth: 0 # GitVersion.MsBuild needs full history
- name: Setup .NET Core
uses: actions/setup-dotnet@v5
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index ff3f13d3e0..55277f0aa6 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -32,9 +32,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
with:
- # We must fetch at least the immediate parents so that if this is
- # a pull request then we can checkout the head.
- fetch-depth: 2
+ fetch-depth: 0 # GitVersion.MsBuild needs full history
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml
new file mode 100644
index 0000000000..c7b69669f1
--- /dev/null
+++ b/.github/workflows/finalize-release.yml
@@ -0,0 +1,120 @@
+name: Finalize Release
+
+# Runs automatically when a release PR is merged into main.
+# Creates the git tag, GitHub Release, and opens a back-merge PR to develop.
+
+on:
+ pull_request:
+ types: [closed]
+ branches: [main]
+
+jobs:
+ finalize-release:
+ name: Tag, Release, and Back-merge
+ # Only run when a release PR is actually merged (not just closed)
+ if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+
+ steps:
+ - name: Checkout main
+ uses: actions/checkout@v6
+ with:
+ ref: main
+ fetch-depth: 0
+ token: ${{ secrets.RELEASE_PAT }}
+
+ - name: Configure Git
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ - name: Extract release info from branch name
+ id: release
+ run: |
+ BRANCH="${{ github.event.pull_request.head.ref }}"
+ # Branch name is release/vX.Y.Z or release/vX.Y.Z-beta
+ TAG="${BRANCH#release/}"
+ SEMVER="${TAG#v}"
+
+ # Determine if this is a pre-release
+ if echo "$SEMVER" | grep -q '-'; then
+ PRERELEASE="true"
+ else
+ PRERELEASE="false"
+ fi
+
+ echo "tag=${TAG}" >> $GITHUB_OUTPUT
+ echo "semver=${SEMVER}" >> $GITHUB_OUTPUT
+ echo "prerelease=${PRERELEASE}" >> $GITHUB_OUTPUT
+
+ - name: Check if tag already exists
+ run: |
+ if git rev-parse "${{ steps.release.outputs.tag }}" >/dev/null 2>&1; then
+ echo "::error::Tag ${{ steps.release.outputs.tag }} already exists. Skipping."
+ exit 1
+ fi
+
+ - name: Create annotated tag
+ run: |
+ TAG="${{ steps.release.outputs.tag }}"
+ git tag -a "$TAG" -m "Release $TAG"
+ git push origin "$TAG"
+ echo "Created and pushed tag: $TAG"
+
+ - name: Create GitHub Release
+ env:
+ GH_TOKEN: ${{ secrets.RELEASE_PAT }}
+ TAG: ${{ steps.release.outputs.tag }}
+ SEMVER: ${{ steps.release.outputs.semver }}
+ PRERELEASE: ${{ steps.release.outputs.prerelease }}
+ run: |
+ PRERELEASE_FLAG=""
+ if [ "$PRERELEASE" = "true" ]; then
+ PRERELEASE_FLAG="--prerelease"
+ fi
+
+ gh release create "$TAG" \
+ --title "$TAG" \
+ --generate-notes \
+ $PRERELEASE_FLAG
+
+ - name: Delete release branch
+ run: |
+ BRANCH="${{ github.event.pull_request.head.ref }}"
+ git push origin --delete "$BRANCH" || true
+ echo "Deleted branch: $BRANCH"
+
+ - name: Create back-merge PR (main → develop)
+ env:
+ GH_TOKEN: ${{ secrets.RELEASE_PAT }}
+ TAG: ${{ steps.release.outputs.tag }}
+ run: |
+ BACKMERGE_BRANCH="backmerge/${TAG}"
+
+ # Create a branch from main for the back-merge
+ git checkout -b "$BACKMERGE_BRANCH" main
+ git push origin "$BACKMERGE_BRANCH"
+
+ # Create the PR
+ gh pr create \
+ --base develop \
+ --head "$BACKMERGE_BRANCH" \
+ --title "Back-merge ${TAG} from main into develop" \
+ --body "Automatic back-merge after release ${TAG}. Merge this to keep \`develop\` in sync with \`main\`."
+
+ - name: Summary
+ run: |
+ echo "## Release Finalized :rocket:" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "- **Tag:** ${{ steps.release.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **Version:** ${{ steps.release.outputs.semver }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **Pre-release:** ${{ steps.release.outputs.prerelease }}" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### What happened" >> $GITHUB_STEP_SUMMARY
+ echo "1. ✅ Tag \`${{ steps.release.outputs.tag }}\` created" >> $GITHUB_STEP_SUMMARY
+ echo "2. ✅ GitHub Release created with auto-generated notes" >> $GITHUB_STEP_SUMMARY
+ echo "3. ✅ Publish workflow triggered (NuGet)" >> $GITHUB_STEP_SUMMARY
+ echo "4. ✅ Back-merge PR opened to develop" >> $GITHUB_STEP_SUMMARY
diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
index 49848aee8a..3514c44a3f 100644
--- a/.github/workflows/integration-tests.yml
+++ b/.github/workflows/integration-tests.yml
@@ -24,6 +24,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
+ with:
+ fetch-depth: 0 # GitVersion.MsBuild needs full history
- name: Setup .NET Core
uses: actions/setup-dotnet@v5
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
new file mode 100644
index 0000000000..7e973b6eff
--- /dev/null
+++ b/.github/workflows/prepare-release.yml
@@ -0,0 +1,208 @@
+name: Prepare Release
+
+# Creates a release PR from develop → main.
+# Maintainers trigger this from Actions → Prepare Release → Run workflow.
+# After reviewing, merge the PR — the "Finalize Release" workflow handles the rest.
+
+on:
+ workflow_dispatch:
+ inputs:
+ release_type:
+ description: 'Release type (controls the pre-release label on main)'
+ required: true
+ type: choice
+ options:
+ - beta
+ - rc
+ - stable
+ default: 'beta'
+ version_override:
+ description: 'Version override (optional, e.g., 2.0.0). Leave blank to let GitVersion calculate it.'
+ required: false
+ type: string
+
+# Prevent two prepare-release runs from picking the same version number
+concurrency:
+ group: prepare-release
+ cancel-in-progress: false
+
+jobs:
+ prepare-release:
+ name: Create Release PR
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+
+ steps:
+ - name: Checkout develop
+ uses: actions/checkout@v6
+ with:
+ ref: develop
+ fetch-depth: 0
+ token: ${{ secrets.RELEASE_PAT }}
+
+ - 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.x'
+
+ - name: Determine Version
+ uses: gittools/actions/gitversion/execute@v4.5.0
+ with:
+ useConfigFile: true
+ id: gitversion
+
+ - name: Compute release version
+ id: version
+ 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
+ SEMVER="${VERSION}"
+ TAG="v${VERSION}"
+ else
+ # Find the latest existing tag for this version + release type
+ # and increment the pre-release number.
+ # --sort=-v:refname gives correct numeric ordering (e.g., .9 before .10)
+ LATEST_TAG=$(git tag -l "v${VERSION}-${RELEASE_TYPE}.*" --sort=-v:refname | head -1)
+
+ if [ -z "$LATEST_TAG" ]; then
+ NEXT_NUM=1
+ else
+ # Extract the trailing number: v2.0.0-beta.217 → 217
+ CURRENT_NUM="${LATEST_TAG##*.}"
+ NEXT_NUM=$((CURRENT_NUM + 1))
+ fi
+
+ SEMVER="${VERSION}-${RELEASE_TYPE}.${NEXT_NUM}"
+ TAG="v${SEMVER}"
+ fi
+
+ echo "version=${VERSION}" >> $GITHUB_OUTPUT
+ echo "semver=${SEMVER}" >> $GITHUB_OUTPUT
+ echo "tag=${TAG}" >> $GITHUB_OUTPUT
+ echo "release_type=${RELEASE_TYPE}" >> $GITHUB_OUTPUT
+
+ echo "::notice::Computed version: ${SEMVER} (tag: ${TAG})"
+
+ - name: Check for conflicts
+ run: |
+ TAG="${{ steps.version.outputs.tag }}"
+
+ # Ensure tag does not already exist
+ if git rev-parse "${TAG}" >/dev/null 2>&1; then
+ echo "::error::Tag ${TAG} already exists."
+ echo "::error::Choose a different version or delete the existing tag first."
+ exit 1
+ fi
+
+ # Ensure no release branch or open PR already targets this version
+ BRANCH="release/${TAG}"
+ if git ls-remote --heads origin "${BRANCH}" | grep -q .; then
+ echo "::error::Branch ${BRANCH} already exists on the remote."
+ echo "::error::Delete it first or choose a different version."
+ exit 1
+ fi
+
+ - name: Create release branch
+ run: |
+ BRANCH="release/${{ steps.version.outputs.tag }}"
+ git checkout -b "$BRANCH"
+ echo "branch=${BRANCH}" >> $GITHUB_OUTPUT
+ id: branch
+
+ - name: Update GitVersion.yml label for release type
+ run: |
+ RELEASE_TYPE="${{ steps.version.outputs.release_type }}"
+
+ if [ "$RELEASE_TYPE" = "stable" ]; then
+ NEW_LABEL="''"
+ else
+ NEW_LABEL="${RELEASE_TYPE}"
+ fi
+
+ # Update the label line that follows the main branch regex.
+ sed -i "/regex: \^main/,/^ [a-z]/{s/^ label: .*/ label: ${NEW_LABEL}/}" GitVersion.yml
+
+ echo "Updated GitVersion.yml main label to: '${NEW_LABEL}'"
+ grep -A 5 "^ main:" GitVersion.yml
+
+ - name: Commit label change
+ run: |
+ if git diff --quiet GitVersion.yml; then
+ echo "No label change needed"
+ else
+ git add GitVersion.yml
+ git commit -m "Set release label to '${{ steps.version.outputs.release_type }}' for ${{ steps.version.outputs.tag }}"
+ fi
+
+ - name: Push release branch
+ run: |
+ BRANCH="release/${{ steps.version.outputs.tag }}"
+ git push origin "$BRANCH"
+
+ - name: Create Pull Request
+ env:
+ GH_TOKEN: ${{ secrets.RELEASE_PAT }}
+ SEMVER: ${{ steps.version.outputs.semver }}
+ TAG: ${{ steps.version.outputs.tag }}
+ RELEASE_TYPE: ${{ steps.version.outputs.release_type }}
+ run: |
+ BRANCH="release/${TAG}"
+
+ if [ "$RELEASE_TYPE" = "stable" ]; then
+ PRERELEASE_NOTE=""
+ else
+ PRERELEASE_NOTE="This is a **${RELEASE_TYPE}** pre-release."
+ fi
+
+ cat > /tmp/pr_body.md << EOF
+ ## Release ${TAG}
+
+ ${PRERELEASE_NOTE}
+
+ **Version:** \`${SEMVER}\`
+ **NuGet Package:** \`Terminal.Gui ${SEMVER}\`
+
+ ### What happens when this PR is merged
+
+ 1. ✅ The **Finalize Release** workflow will automatically create tag \`${TAG}\`
+ 2. ✅ The **Publish** workflow will build and push to [NuGet.org](https://www.nuget.org/packages/Terminal.Gui)
+ 3. ✅ A **GitHub Release** will be created with auto-generated notes
+ 4. ✅ A **back-merge PR** from \`main\` → \`develop\` will be opened
+
+ ### Checklist
+
+ - [ ] CI passes on this PR
+ - [ ] Version looks correct: \`${SEMVER}\`
+ - [ ] Release notes reviewed (will be auto-generated on merge)
+ EOF
+
+ gh pr create \
+ --base main \
+ --head "$BRANCH" \
+ --title "Release ${TAG}" \
+ --body-file /tmp/pr_body.md
+
+ - name: Summary
+ run: |
+ echo "## Release PR Created :rocket:" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "- **Version:** ${{ steps.version.outputs.semver }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **Tag:** ${{ steps.version.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **Type:** ${{ steps.version.outputs.release_type }}" >> $GITHUB_STEP_SUMMARY
+ echo "- **Branch:** release/${{ steps.version.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "Review and merge the PR to trigger the release." >> $GITHUB_STEP_SUMMARY
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 7f7e5abd17..186c66b5a1 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -19,6 +19,9 @@ jobs:
with:
fetch-depth: 0 # fetch-depth is needed for GitVersion https://github.com/GitTools/actions/blob/main/docs/cloning.md
+ # GitVersion.MsBuild (in Terminal.Gui.csproj) automatically computes the version
+ # from git history + GitVersion.yml during build. We still run the CLI here to
+ # capture the SemVer for use in subsequent steps (push, template dispatch).
- name: Install GitVersion
uses: gittools/actions/gitversion/setup@v4.5.0
with:
@@ -28,7 +31,6 @@ jobs:
uses: gittools/actions/gitversion/execute@v4.5.0
with:
useConfigFile: true
- updateAssemblyInfo: true
id: gitversion # step id used as reference for output values
- name: Setup dotnet
@@ -41,7 +43,7 @@ jobs:
run: dotnet build Terminal.Gui/Terminal.Gui.csproj --no-incremental --nologo --force --configuration Release
- name: Pack Release Terminal.Gui ${{ steps.gitversion.outputs.SemVer }} for Nuget
- run: dotnet pack Terminal.Gui/Terminal.Gui.csproj -c Release --include-symbols -p:Version='${{ steps.gitversion.outputs.SemVer }}'
+ run: dotnet pack Terminal.Gui/Terminal.Gui.csproj -c Release --include-symbols --no-build
# - name: Test to generate Code Coverage Report
# run: |
diff --git a/.github/workflows/stress-tests.yml b/.github/workflows/stress-tests.yml
index 0660308e30..481741c6fd 100644
--- a/.github/workflows/stress-tests.yml
+++ b/.github/workflows/stress-tests.yml
@@ -22,6 +22,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
+ with:
+ fetch-depth: 0 # GitVersion.MsBuild needs full history
- name: Setup .NET Core
uses: actions/setup-dotnet@v5
@@ -65,6 +67,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
+ with:
+ fetch-depth: 0 # GitVersion.MsBuild needs full history
- name: Setup .NET Core
uses: actions/setup-dotnet@v5
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index b466926f52..a73ca887de 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -26,6 +26,8 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6
+ with:
+ fetch-depth: 0 # GitVersion.MsBuild needs full history
- name: Setup .NET Core
uses: actions/setup-dotnet@v5
@@ -84,6 +86,8 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6
+ with:
+ fetch-depth: 0 # GitVersion.MsBuild needs full history
- name: Setup .NET Core
uses: actions/setup-dotnet@v5
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 6718b0de22..43bd0c219f 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -47,6 +47,7 @@
+
diff --git a/Examples/AI/AI.csproj b/Examples/AI/AI.csproj
index 751ecad327..6361cd36df 100644
--- a/Examples/AI/AI.csproj
+++ b/Examples/AI/AI.csproj
@@ -1,10 +1,6 @@
Exe
- 2.0
- 2.0
- 2.0
- 2.0
enable
latest
diff --git a/Examples/Example/Example.csproj b/Examples/Example/Example.csproj
index a734c759e3..ed4cca28a1 100644
--- a/Examples/Example/Example.csproj
+++ b/Examples/Example/Example.csproj
@@ -1,13 +1,6 @@
Exe
-
-
-
- 2.0
- 2.0
- 2.0
- 2.0
enable
latest
diff --git a/Examples/InlineCLI/InlineCLI.cs b/Examples/InlineCLI/InlineCLI.cs
index 872f814f8b..a13c9129e0 100644
--- a/Examples/InlineCLI/InlineCLI.cs
+++ b/Examples/InlineCLI/InlineCLI.cs
@@ -15,6 +15,7 @@
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;
using UICatalog;
+// ReSharper disable AccessToModifiedClosure
// Set Inline mode BEFORE Init
Application.AppModel = AppModel.Inline;
diff --git a/Examples/InlineCLI/InlineCLI.csproj b/Examples/InlineCLI/InlineCLI.csproj
index d5931053e5..963226eeae 100644
--- a/Examples/InlineCLI/InlineCLI.csproj
+++ b/Examples/InlineCLI/InlineCLI.csproj
@@ -1,13 +1,6 @@
Exe
-
-
-
- 2.0
- 2.0
- 2.0
- 2.0
enable
latest
diff --git a/Examples/InlineColorPicker/InlineColorPicker.csproj b/Examples/InlineColorPicker/InlineColorPicker.csproj
index 38c47ab3fc..46d92f7faf 100644
--- a/Examples/InlineColorPicker/InlineColorPicker.csproj
+++ b/Examples/InlineColorPicker/InlineColorPicker.csproj
@@ -1,13 +1,6 @@
Exe
-
-
-
- 2.0
- 2.0
- 2.0
- 2.0
enable
latest
diff --git a/Examples/InlineSelect/InlineSelect.csproj b/Examples/InlineSelect/InlineSelect.csproj
index 38c47ab3fc..46d92f7faf 100644
--- a/Examples/InlineSelect/InlineSelect.csproj
+++ b/Examples/InlineSelect/InlineSelect.csproj
@@ -1,13 +1,6 @@
Exe
-
-
-
- 2.0
- 2.0
- 2.0
- 2.0
enable
latest
diff --git a/Examples/README.md b/Examples/README.md
new file mode 100644
index 0000000000..0f6aa383ff
--- /dev/null
+++ b/Examples/README.md
@@ -0,0 +1,3 @@
+# Terminal.Gui Examples
+
+This directory contains example applications demonstrating Terminal.Gui. Each example has its own README with usage details and documentation.
diff --git a/Examples/ReactiveExample/ReactiveExample.csproj b/Examples/ReactiveExample/ReactiveExample.csproj
index f639a03d79..e3052b9b61 100644
--- a/Examples/ReactiveExample/ReactiveExample.csproj
+++ b/Examples/ReactiveExample/ReactiveExample.csproj
@@ -1,13 +1,6 @@
Exe
-
-
-
- 2.0
- 2.0
- 2.0
- 2.0
diff --git a/Examples/ScenarioRunner/ScenarioRunner.csproj b/Examples/ScenarioRunner/ScenarioRunner.csproj
index 1030734925..b9f3a1dfef 100644
--- a/Examples/ScenarioRunner/ScenarioRunner.csproj
+++ b/Examples/ScenarioRunner/ScenarioRunner.csproj
@@ -1,13 +1,6 @@
Exe
-
-
-
- 2.0
- 2.0
- 2.0
- 2.0
TRACE
diff --git a/Examples/UICatalog/README.md b/Examples/UICatalog/README.md
index 042bc8a945..007733bd8c 100644
--- a/Examples/UICatalog/README.md
+++ b/Examples/UICatalog/README.md
@@ -1,92 +1,77 @@
# Terminal.Gui UI Catalog
-UI Catalog is a comprehensive sample library for Terminal.Gui. It attempts to satisfy the following goals:
+UI Catalog is a comprehensive sample library for Terminal.Gui. It provides:
-1. Be an easy-to-use showcase for Terminal.Gui concepts and features.
-2. Provide sample code that illustrates how to properly implement
-said concepts & features.
-3. Make it easy for contributors to add additional samples in a structured way.
+1. An easy-to-use, interactive, showcase for Terminal.Gui concepts and features.
+2. Sample code that illustrates how to properly implement said concepts & features.
+3. A structured way for contributors to add additional samples.
-
-
-## Motivation
-
-The original `demo.cs` sample app for Terminal.Gui is neither good to showcase, nor does it explain different concepts. In addition, because it is built on a single source file, it has proven to cause friction when multiple contributors are simultaneously working on different aspects of Terminal.Gui.
-See [Issue #368](https://github.com/giu-cs/Terminal.Gui/issues/368) for more background.
+
## How To Use
-Build and run UI Catalog by typing `dotnet run` from the `UI Catalog` folder or by using the `Terminal.Gui` Visual Studio solution.
-
-`Program.cs` is the main **UI Catalog** app and provides a UI for selecting and running **Scenarios**. Each **Scenario* is implemented as a class derived from `Scenario` and `Program.cs` uses reflection to dynamically build the UI.
-
-**Scenarios** are tagged with categories using the `[ScenarioCategory]` attribute. The left pane of the main screen lists the categories. Clicking on a category shows all the scenarios in that category.
-
-**Scenarios** can be run either from the **UICatalog.exe** app UI or by being specified on the command line:
-
```
-UICatalog.exe
+dotnet run --project ./Examples/UICatalog/UICatalog.csproj
```
-e.g.
+or
```
-UICatalog.exe Buttons
+dotnet build
+./Examples/UICatalog/bin/Debug/net10.0/UICatalog.exe
```
-Hitting ENTER on a selected Scenario or double-clicking on a Scenario runs that scenario as though it were a stand-alone Terminal.Gui app.
-
-When a **Scenario** is run, it runs as though it were a standalone `Terminal.Gui` app. However, scaffolding is provided (in the `Scenario` base class) that (optionally) takes care of `Terminal.Gui` initialization.
+## Implementing a Scenario
-## Contributing by Adding Scenarios
+**Scenarios** are tagged with categories using the `[ScenarioCategory]` attribute. The left pane of the main screen lists the categories. Clicking on a category shows all the scenarios in that category.
-To add a new **Scenario** simply:
+To add a new **Scenario**:
-1. Create a new `.cs` file in the `Scenarios` directory that derives from `Scenario`.
-2. Add a `[ScenarioMetaData]` attribute to the class specifying the scenario's name and description.
-3. Add one or more `[ScenarioCategory]` attributes to the class specifying which categories the sceanrio belongs to. If you don't specify a category the sceanrio will show up in "All".
+1. Create a new `.cs` file in `./Examples/UICatalog/Scenarios` that derives from `Scenario`.
+2. Add a `[ScenarioMetaData]` attribute specifying the scenario's name and description.
+3. Add one or more `[ScenarioCategory]` attributes specifying which categories the scenario belongs to. If you don't specify a category, the scenario will show up in "All".
4. Implement the `Setup` override which will be called when a user selects the scenario to run.
5. Optionally, implement the `Init` and/or `Run` overrides to provide a custom implementation.
-The sample below is provided in the `.\UICatalog\Scenarios` directory as a generic sample that can be copied and re-named:
-
-```csharp
-
-namespace UICatalog {
- [ScenarioMetadata (Name: "Generic", Description: "Generic sample - A template for creating new Scenarios")]
- [ScenarioCategory ("Controls")]
- class MyScenario : Scenario {
- public override void Setup ()
- {
- // Put your scenario code here, e.g.
- Win.Add (new Button () {
-Text = "Press me!",
- X = Pos.Center (),
- Y = Pos.Center (),
- Clicked = () => MessageBox.Query (20, 7, "Hi", "Neat?", Strings.btnNo, Strings.btnYes)
- });
- }
- }
-}
-```
+See `./Examples/UICatalog/Scenarios/Generic.cs` for a starting point.
+
+### Contribution Guidelines
-`Scenario` provides `Win`, a `Window` object that provides a canvas for the Scenario to operate.
+- Provide a terse, descriptive `Name` for `Scenarios`. Keep them short.
+- Provide a clear `Description`.
+- Comment `Scenario` code to describe to others why it's a useful `Scenario`.
+- Annotate `Scenarios` with `[ScenarioCategory]` attributes. Minimize the number of new categories created.
-The default `Window` shows the Scenario name and supports exiting the Scenario through the `Esc` key.
-
+## Command Line Arguments
-To build a more advanced scenario, where control of the `Runnable` and `Window` is needed (e.g. for scenarios using `MenuBar` or `StatusBar`), simply use `Application.Top` per normal Terminal.Gui programming, as seen in the `Notepad` scenario.
+Usage: `UICatalog [] [options]`
-For complete control, the `Init` and `Run` overrides can be implemented. The `base.Init` creates `Win`. The `base.Run` simply calls `Application.Run(Application.Top)`.
+| Option | Description |
+|--------|-------------|
+| `` | The name of the Scenario to run. If not provided, the interactive UI will be shown. |
+| `-dl`, `--debug-log-level` | The log level (`Trace`, `Debug`, `Information`, `Warning`, `Error`, `Critical`, `None`). Default: `Warning`. |
+| `-b`, `--benchmark` | Enables benchmarking. If a Scenario is specified, just that Scenario will be benchmarked. |
+| `-t`, `--timeout ` | Max time in milliseconds per benchmark. Default: `2500`. |
+| `-f`, `--file ` | File to save benchmark results to. If omitted, results are displayed in a `TableView`. |
+| `-d`, `--driver ` | The `IDriver` to use (`ansi`, `dotnet`, `windows`). |
+| `-dcm`, `--disable-cm` | Disables Configuration Management. Only `ConfigLocations.HardCoded` settings will be loaded. |
+| `-16`, `--force-16-colors` | Forces 16-color mode instead of TrueColor. |
+| `--help` | Show help (renders this README as formatted markdown). |
+| `--version` | Show version information. |
-## Contribution Guidelines
+### Examples
-- Provide a terse, descriptive `Name` for `Scenarios`. Keep them short.
-- Provide a clear `Description`.
-- Comment `Scenario` code to describe to others why it's a useful `Scenario`.
-- Annotate `Scenarios` with `[ScenarioCategory]` attributes. Minimize the number of new categories created.
-- Use the `Bug Repo` Category for `Scenarios` that reproduce bugs.
- - Include the Github Issue # in the Description.
- - Once the bug has been fixed in `develop` submit another PR to remove the `Scenario` (or modify it to provide a good regression test/sample).
-- Tag bugs or suggestions for `UI Catalog` as [`Terminal.Gui` Github Issues](https://github.com/gui-cs/Terminal.Gui/issues) with "UICatalog: ".
+```bash
+# Show formatted help
+./Examples/UICatalog/bin/Debug/net10.0/UICatalog.exe --help
+
+# Run a specific scenario
+./Examples/UICatalog/bin/Debug/net10.0/UICatalog.exe "All Views Tester"
+
+# Benchmark all scenarios
+./Examples/UICatalog/bin/Debug/net10.0/UICatalog.exe --benchmark
+
+# Run with a specific driver and debug logging
+./Examples/UICatalog/bin/Debug/net10.0/UICatalog.exe -d ansi -dl Debug
+```
\ No newline at end of file
diff --git a/Examples/UICatalog/UICatalog.cs b/Examples/UICatalog/UICatalog.cs
index 1945190ddf..b7ea221c03 100644
--- a/Examples/UICatalog/UICatalog.cs
+++ b/Examples/UICatalog/UICatalog.cs
@@ -12,6 +12,7 @@
global using Terminal.Gui.Resources;
using System.CommandLine;
using System.CommandLine.Help;
+using System.CommandLine.Invocation;
using System.CommandLine.Parsing;
using System.Diagnostics;
using System.Globalization;
@@ -22,6 +23,7 @@
using Serilog;
using Serilog.Core;
using Serilog.Events;
+using TextMateSharp.Grammars;
using ILogger = Microsoft.Extensions.Logging.ILogger;
#nullable enable
@@ -175,7 +177,7 @@ private static int Main (string [] args)
DontEnableConfigurationManagement = context.GetRequiredValue (disableConfigManagement),
Benchmark = context.GetRequiredValue (benchmarkFlag),
BenchmarkTimeout = context.GetRequiredValue (benchmarkTimeout),
- ResultsFile = context.GetRequiredValue (resultsFile) ?? string.Empty,
+ ResultsFile = context.GetRequiredValue (resultsFile),
DebugLogLevel = context.GetRequiredValue (debugLogLevel),
// Only set Force16Colors if explicitly specified on command line
@@ -186,31 +188,52 @@ private static int Main (string [] args)
Options = options;
});
- var helpShown = false;
+ // Capture terminal dimensions before any driver changes them
+ int terminalWidth;
+ int terminalHeight;
- ParseResult parseResult = rootCommand.Parse (args);
-
- // Check if the analysis results indicate that help should be displayed
- if (parseResult.Errors.Count == 0 && parseResult.Action is HelpAction)
+ try
{
- helpShown = true;
+ terminalWidth = Console.WindowWidth;
+ terminalHeight = Console.WindowHeight;
}
-
- parseResult.Invoke ();
-
- if (helpShown)
+ catch (IOException)
{
- return 0;
+ // Console dimensions unavailable (e.g. output is piped)
+ terminalWidth = 120;
+ terminalHeight = 40;
}
+ // Override --help to render the embedded README.md as formatted markdown
+ HelpOption helpOption = rootCommand.Options.OfType ().First ();
+ helpOption.Action = new MarkdownHelpAction (() => RenderMarkdown (ReadEmbeddedReadme (), terminalWidth, terminalHeight));
+
+ // Override --version to show the Terminal.Gui library version
+ VersionOption versionOption = rootCommand.Options.OfType ().First ();
+ versionOption.Action = new VersionAction (() => Console.WriteLine (GetLibraryVersion ()));
+
+ ParseResult parseResult = rootCommand.Parse (args);
+
+ // If there are parse errors, show README then print diagnostics underneath
if (parseResult.Errors.Count > 0)
{
+ RenderMarkdown (ReadEmbeddedReadme (), terminalWidth, terminalHeight);
+
foreach (ParseError error in parseResult.Errors)
{
Console.Error.WriteLine (error.Message);
}
- return 1; // Non-zero exit code for error
+ return 1;
+ }
+
+ parseResult.Invoke ();
+
+ // If our SetAction callback wasn't invoked (--help, --version, etc.),
+ // Options won't have been initialized — return early.
+ if (Options.DebugLogLevel is null)
+ {
+ return 0;
}
Scenario.BenchmarkTimeout = Options.BenchmarkTimeout;
@@ -324,4 +347,104 @@ private static void UICatalogMain (UICatalogCommandLineOptions options)
runner.RunInteractive (!Options.DontEnableConfigurationManagement);
}
+
+ ///
+ /// Renders markdown content to the terminal using the ANSI driver and exits.
+ ///
+ private static void RenderMarkdown (string markdown, int width, int height)
+ {
+ // Prevent the ANSI driver from trying to read/write real terminal size or capabilities
+ Environment.SetEnvironmentVariable ("DisableRealDriverIO", "1");
+ IApplication app = Application.Create ();
+ app.Init (DriverRegistry.Names.ANSI);
+
+ app.Driver?.SetScreenSize (width, height);
+
+ Markdown markdownView = new ()
+ {
+ App = app,
+ UseThemeBackground = true,
+ ShowCopyButtons = false,
+ Width = Dim.Fill (),
+ Height = Dim.Fill (),
+ SyntaxHighlighter = new TextMateSyntaxHighlighter (ThemeName.Dracula),
+ Text = markdown
+ };
+
+ // Layout to get natural content height
+ markdownView.SetRelativeLayout (app.Screen.Size);
+ markdownView.Layout ();
+
+ // Resize to the full content height but keep the terminal width
+ int contentHeight = markdownView.GetContentHeight ();
+ app.Driver?.SetScreenSize (width, contentHeight);
+ markdownView.SetRelativeLayout (app.Screen.Size);
+
+ markdownView.Frame = app.Screen with { X = 0, Y = 0 };
+ markdownView.Layout ();
+
+ app.Driver?.ClearContents ();
+ markdownView.Draw ();
+ Console.WriteLine (app.Driver?.ToAnsi ());
+ }
+
+ ///
+ /// Gets the Terminal.Gui library version from its assembly metadata.
+ ///
+ public static string GetLibraryVersion ()
+ {
+ Assembly libAssembly = typeof (View).Assembly;
+
+ string? informationalVersion = libAssembly.GetCustomAttribute ()?.InformationalVersion;
+
+ if (informationalVersion is { })
+ {
+ // Strip build metadata (everything after '+') for a terse SemVer
+ int plusIndex = informationalVersion.IndexOf ('+');
+
+ return plusIndex >= 0 ? informationalVersion [..plusIndex] : informationalVersion;
+ }
+
+ return libAssembly.GetName ().Version?.ToString () ?? "unknown";
+ }
+
+ ///
+ /// Reads the embedded README.md resource from the assembly.
+ ///
+ private static string ReadEmbeddedReadme ()
+ {
+ Assembly assembly = Assembly.GetExecutingAssembly ();
+ string resourceName = assembly.GetManifestResourceNames ().First (n => n.EndsWith ("README.md", StringComparison.Ordinal));
+
+ using Stream stream = assembly.GetManifestResourceStream (resourceName)!;
+ using StreamReader reader = new (stream);
+
+ return reader.ReadToEnd ();
+ }
+}
+
+///
+/// Custom help action that renders the embedded README.md as formatted markdown.
+///
+internal sealed class MarkdownHelpAction (Action renderHelp) : SynchronousCommandLineAction
+{
+ public override int Invoke (ParseResult parseResult)
+ {
+ renderHelp ();
+
+ return 0;
+ }
+}
+
+///
+/// Custom version action that displays the Terminal.Gui library version.
+///
+internal sealed class VersionAction (Action printVersion) : SynchronousCommandLineAction
+{
+ public override int Invoke (ParseResult parseResult)
+ {
+ printVersion ();
+
+ return 0;
+ }
}
diff --git a/Examples/UICatalog/UICatalog.csproj b/Examples/UICatalog/UICatalog.csproj
index 88e916f9cf..1dc8d9f303 100644
--- a/Examples/UICatalog/UICatalog.csproj
+++ b/Examples/UICatalog/UICatalog.csproj
@@ -2,13 +2,7 @@
UICatalog.UICatalog
Exe
-
-
-
- 2.0
- 2.0
- 2.0
- 2.0
+
Linux
@@ -22,6 +16,7 @@
+
diff --git a/Examples/UICatalog/UICatalogRunnable.cs b/Examples/UICatalog/UICatalogRunnable.cs
index aa57f3599f..4dc1b1137b 100644
--- a/Examples/UICatalog/UICatalogRunnable.cs
+++ b/Examples/UICatalog/UICatalogRunnable.cs
@@ -913,7 +913,7 @@ private void ShowAboutDialog ()
{
Width = Dim.Auto (),
Height = Dim.Auto (),
- Text = "v2 - Beta",
+ Text = $"Terminal.Gui {UICatalog.GetLibraryVersion ()}",
X = Pos.Center (),
Y = Pos.Bottom (logo) + 1
};
diff --git a/Examples/mdv/mdv.csproj b/Examples/mdv/mdv.csproj
index f465e91566..98e6fca9b7 100644
--- a/Examples/mdv/mdv.csproj
+++ b/Examples/mdv/mdv.csproj
@@ -1,13 +1,6 @@
Exe
-
-
-
- 2.0
- 2.0
- 2.0
- 2.0
enable
latest
diff --git a/GitVersion.yml b/GitVersion.yml
index ddfe73b07c..f10138884e 100644
--- a/GitVersion.yml
+++ b/GitVersion.yml
@@ -70,20 +70,28 @@ branches:
# source-branches: ['v1_develop']
# Pull Request Branches
- # Configures versioning for PRs (e.g., 2.0.0-pr.feature-123.1)
+ # Configures versioning for PRs (e.g., 2.0.0-PullRequest5009.1)
pull-request:
- # Matches typical PR branch names
- regex: ^(pull|pull\-requests|pr)[/-]
- # Uses 'pr' prefix with branch name in the label (e.g., pr.feature-123)
- label: pr.{BranchName}
- # Inherits increment strategy from source branch
+ regex: "^(pull|pull\\-requests|pr)[\\/-](?\\d+)"
+ label: "PullRequest{Number}"
increment: Inherit
source-branches:
- develop
- main
- # High weight ensures PR versions sort after regular pre-releases
+ - feature
pre-release-weight: 30000
+ # Feature / Fix / Topic Branches
+ # Matches branches like feature/*, fix/*, issue/*, etc.
+ # Inherits version from develop and uses the branch name as pre-release label.
+ # e.g., if develop is at 2.2.0, fix/foobar produces 2.2.0-fix-foobar.1
+ feature:
+ regex: "^(feature|fix|issue|dependabot|copilot)[\\/-](?.+)"
+ label: "{BranchName}"
+ increment: Inherit
+ source-branches:
+ - develop
+
# Ignore specific commits if needed (currently empty)
ignore:
sha: []
\ No newline at end of file
diff --git a/Terminal.Gui/App/ApplicationImpl.Driver.cs b/Terminal.Gui/App/ApplicationImpl.Driver.cs
index 1ce67a23b6..e906a164b0 100644
--- a/Terminal.Gui/App/ApplicationImpl.Driver.cs
+++ b/Terminal.Gui/App/ApplicationImpl.Driver.cs
@@ -199,21 +199,6 @@ internal void SubscribeDriverEvents ()
Driver.MouseEvent += Driver_MouseEvent;
}
- internal void UnsubscribeDriverEvents ()
- {
- if (Driver is null)
- {
- Logging.Error ("Driver is null");
-
- return;
- }
-
- Driver.SizeChanged -= Driver_SizeChanged;
- Driver.KeyDown -= Driver_KeyDown;
- Driver.KeyUp -= Driver_KeyUp;
- Driver.MouseEvent -= Driver_MouseEvent;
- }
-
private void Driver_KeyDown (object? sender, Key e) => Keyboard.RaiseKeyDownEvent (e);
private void Driver_KeyUp (object? sender, Key e) => Keyboard.RaiseKeyUpEvent (e);
diff --git a/Terminal.Gui/App/ApplicationImpl.Run.cs b/Terminal.Gui/App/ApplicationImpl.Run.cs
index b00ec37c68..523d7617f9 100644
--- a/Terminal.Gui/App/ApplicationImpl.Run.cs
+++ b/Terminal.Gui/App/ApplicationImpl.Run.cs
@@ -7,7 +7,7 @@ namespace Terminal.Gui.App;
internal partial class ApplicationImpl
{
// Lock object to protect session stack operations and cached state updates
- private readonly object _sessionStackLock = new ();
+ private readonly Lock _sessionStackLock = new ();
#region Session State - Stack and TopRunnable
@@ -225,10 +225,11 @@ public void Invoke (Action action)
}
// Begin the session (adds to stack, raises IsRunningChanging/IsRunningChanged)
- SessionToken? token;
- // Find it on the stack
- token = runnable.IsRunning ? SessionStack?.FirstOrDefault (st => st.Runnable == runnable) : Begin (runnable);
+ SessionToken? token =
+
+ // Find it on the stack
+ runnable.IsRunning ? SessionStack?.FirstOrDefault (st => st.Runnable == runnable) : Begin (runnable);
if (token is null)
{
diff --git a/Terminal.Gui/App/ApplicationImpl.Screen.cs b/Terminal.Gui/App/ApplicationImpl.Screen.cs
index 285e0c724f..4f3f06af1e 100644
--- a/Terminal.Gui/App/ApplicationImpl.Screen.cs
+++ b/Terminal.Gui/App/ApplicationImpl.Screen.cs
@@ -245,7 +245,7 @@ public void LayoutAndDraw (bool forceRedraw = false)
Logging.Redraws.Add (1);
// Clip uses the output buffer dimensions (0-indexed), not the terminal offset.
- Rectangle clipRect = new (0, 0, Screen.Width, Screen.Height);
+ Rectangle clipRect = Screen with { X = 0, Y = 0 };
Driver.Clip = new Region (clipRect);
// Only force a complete redraw if needed (needsLayout or forceRedraw).
@@ -258,6 +258,13 @@ public void LayoutAndDraw (bool forceRedraw = false)
Driver?.Refresh ();
}
+ if (neededLayout || needsDraw)
+ {
+ LayoutAndDrawComplete?.Invoke (this, EventArgs.Empty);
+ }
Trace.Draw ("ApplicationImpl", "End", $"neededLayout={neededLayout}, needsDraw={needsDraw}");
}
+
+ ///
+ public event EventHandler? LayoutAndDrawComplete;
}
diff --git a/Terminal.Gui/App/IApplication.cs b/Terminal.Gui/App/IApplication.cs
index 9372627154..e13872c00a 100644
--- a/Terminal.Gui/App/IApplication.cs
+++ b/Terminal.Gui/App/IApplication.cs
@@ -592,6 +592,13 @@ public interface IApplication : IDisposable
///
public void LayoutAndDraw (bool forceRedraw = false);
+ /// Raised when the has completed and one or more Views has been laid out or drawn.
+ ///
+ /// This is not raised every iteration; or call to LayoutAndDraw. It is only raised if View.Draw or View.Layout was called
+ /// on at least one View in the app.
+ ///
+ public event EventHandler? LayoutAndDrawComplete;
+
#endregion Layout and Drawing
#region Navigation and Popover
@@ -672,4 +679,5 @@ public interface IApplication : IDisposable
///
/// A string representation of the Application
public string ToString ();
+
}
diff --git a/Terminal.Gui/App/MainLoop/ApplicationMainLoop.cs b/Terminal.Gui/App/MainLoop/ApplicationMainLoop.cs
index b614738746..86a757da7f 100644
--- a/Terminal.Gui/App/MainLoop/ApplicationMainLoop.cs
+++ b/Terminal.Gui/App/MainLoop/ApplicationMainLoop.cs
@@ -21,9 +21,6 @@ namespace Terminal.Gui.App;
/// Type of raw input events, e.g. for .NET driver
public class ApplicationMainLoop : IApplicationMainLoop where TInputRecord : struct
{
- private bool _firstRenderCompleted;
- private bool _startupWaitLogged;
-
///
public IApplication? App { get; private set; }
@@ -110,6 +107,9 @@ public void Iteration ()
}
}
+ private bool _startupWaitLogged;
+ private bool _firstLayoutAndDrawComplete;
+
internal void IterationImpl ()
{
// Pull any input events from the input queue and process them
@@ -125,7 +125,7 @@ internal void IterationImpl ()
IDriver? driver = App?.Driver;
// First-render deferral: wait for the ANSI startup gate unless bypassed.
- if (!_firstRenderCompleted
+ if (!_firstLayoutAndDrawComplete
&& driver?.AnsiStartupGate is { IsReady: false } startupGate)
{
// ForceInlinePosition bypasses the gate for inline mode testing.
@@ -157,7 +157,7 @@ internal void IterationImpl ()
}
// Startup gate just became ready — set up inline state if needed.
- if (!_firstRenderCompleted)
+ if (!_firstLayoutAndDrawComplete)
{
if (_startupWaitLogged)
{
@@ -178,8 +178,9 @@ internal void IterationImpl ()
Trace.Draw (nameof (ApplicationMainLoop), "IterationDraw", $"Screen={App?.Screen}");
// Layout and draw any views that need it
+ // This will raise IApplication.LayoutAndDrawComplete
App?.LayoutAndDraw (false);
- _firstRenderCompleted = true;
+ _firstLayoutAndDrawComplete = true;
// Update the cursor
App?.Navigation?.UpdateCursor ();
diff --git a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs
index a588c7c184..386cf7a418 100644
--- a/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs
+++ b/Terminal.Gui/App/MainLoop/MainLoopCoordinator.cs
@@ -17,8 +17,6 @@ namespace Terminal.Gui.App;
/// Type of raw input events, e.g. for .NET driver
internal class MainLoopCoordinator : IMainLoopCoordinator where TInputRecord : struct
{
- private static readonly TimeSpan DeviceAttributesStartupQueryTimeout = TimeSpan.FromSeconds (1);
-
///
/// Creates a new coordinator that will manage the main UI loop and input thread.
///
@@ -269,7 +267,7 @@ private void BuildDriverIfPossible (IApplication? app)
private void QueueDeviceAttributesProbe (IAnsiStartupGate startupGate)
{
IDisposable deviceAttributesQueryCompletionHandle = startupGate.RegisterQuery (AnsiStartupQuery.DeviceAttributesPrimary,
- DeviceAttributesStartupQueryTimeout);
+ TimeSpan.FromSeconds (1));
AnsiEscapeSequenceRequest request = new ()
{
diff --git a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs
index f9864684ae..776abe7e20 100644
--- a/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs
+++ b/Terminal.Gui/Drivers/AnsiDriver/AnsiOutput.cs
@@ -369,8 +369,9 @@ public void Dispose ()
return;
}
- // Restore terminal state: disable mouse, show cursor
+ // Restore terminal state: disable mouse, reset attributes, show cursor
Write (EscSeqUtils.CSI_DisableMouseEvents);
+ Write (EscSeqUtils.CSI_ResetAttributes);
if (AppModel == AppModel.Inline)
{
@@ -382,14 +383,14 @@ public void Dispose ()
int lastInlineRow = appScreen.Y + appScreen.Height; // 1-indexed last row
Write (EscSeqUtils.CSI_SetCursorPosition (lastInlineRow, 1));
Write ("\n");
- Write (EscSeqUtils.CSI_ShowCursor);
}
else
{
// FullScreen mode: restore alternate buffer and show cursor
Write (EscSeqUtils.CSI_RestoreCursorAndRestoreAltBufferWithBackscroll);
- Write (EscSeqUtils.CSI_ShowCursor);
}
+
+ Write (EscSeqUtils.CSI_ShowCursor);
}
catch
{
diff --git a/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs b/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs
index 2935f7c3f2..e967608bad 100644
--- a/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs
+++ b/Terminal.Gui/Drivers/AnsiHandling/EscSeqUtils/EscSeqUtils.cs
@@ -751,6 +751,12 @@ public static void CSI_AppendBackgroundColorRGB (StringBuilder builder, int r, i
///
public static void CSI_AppendResetBackgroundColor (StringBuilder builder) => builder.Append ($"{CSI}49m");
+ ///
+ /// ESC[0m - Resets all graphic attributes (foreground, background, bold, underline, etc.)
+ /// to the terminal's defaults (SGR 0).
+ ///
+ public static readonly string CSI_ResetAttributes = $"{CSI}0m";
+
#endregion Colors
#region Text Styles
diff --git a/Terminal.Gui/README.md b/Terminal.Gui/README.md
index fbc18bafd8..abbbfffb41 100644
--- a/Terminal.Gui/README.md
+++ b/Terminal.Gui/README.md
@@ -1,126 +1,161 @@
-# Terminal.Gui Project
+# Terminal.Gui Library — Maintainer Guide
-**Terminal.Gui** is a cross-platform UI toolkit for creating console-based graphical user interfaces in .NET. This repository contains all files required to build the **Terminal.Gui** library and NuGet package, enabling developers to create rich terminal applications with ease.
+This directory contains the core **Terminal.Gui** library source code. This README documents how to maintain and release the library. For contribution guidelines, see [CONTRIBUTING.md](../CONTRIBUTING.md). For building apps with Terminal.Gui, see [the documentation](https://gui-cs.github.io/Terminal.Gui).
-## Project Overview
+## Versioning
-**Terminal.Gui** provides a comprehensive framework for building interactive console applications with support for keyboard and mouse input, customizable views, and a robust event system. It is designed to work across Windows, macOS, and Linux, leveraging platform-specific console capabilities where available.
+Versions are computed automatically by [GitVersion 6.x](https://gitversion.net) using the [GitFlow](https://gitversion.net/docs/learn/branching-strategies/gitflow/) branching strategy. Configuration is in [`GitVersion.yml`](../GitVersion.yml).
-## Project Folder Structure
+The [GitVersion.MsBuild](https://www.nuget.org/packages/GitVersion.MsBuild) NuGet package is included in `Terminal.Gui.csproj` and automatically sets `Version`, `AssemblyVersion`, `FileVersion`, and `InformationalVersion` from git history at build time. No manual version management is needed.
-This directory contains the core **Terminal.Gui** library source code. For a detailed repository structure, see [CONTRIBUTING.md - Repository Structure](../CONTRIBUTING.md#repository-structure).
+### How Versions Are Computed
-## Getting Started
+| Branch | Example Version | Increment | Notes |
+|--------|----------------|-----------|-------|
+| `main` (pre-release) | `2.0.0-beta.3` | Patch | Label set in `GitVersion.yml` (`label: beta`) |
+| `main` (stable) | `2.0.0` | Patch | Set `label: ''` for stable release |
+| `develop` | `2.1.0-develop.42` | Minor | Always carries `-develop` pre-release label |
+| `feature/*`, `fix/*`, etc. | `2.1.0-my-feature.1` | Inherit | Inherits from `develop`; branch name becomes label |
+| `pull-request/*` | `2.0.0-pr.123.1` | Inherit | PR number in label |
-For instructions on how to start using **Terminal.Gui**, refer to the [Getting Started Guide](https://gui-cs.github.io/Terminal.Gui/docs/getting-started.html) in our documentation.
+### Checking Versions Locally
-## Documentation
+```powershell
+# Install the CLI tool (one-time)
+dotnet tool install --global GitVersion.Tool
-Comprehensive documentation is available at [gui-cs.github.io/Terminal.Gui](https://gui-cs.github.io/Terminal.Gui).
+# Show what version would be computed for the current branch
+dotnet-gitversion
+```
-For information on generating and updating the API documentation locally, refer to the [DocFX README](../docfx/README.md).
+The version is also embedded in every build. For example, `UICatalog --version` displays the terse SemVer (build metadata after `+` is stripped).
-## Versioning
+### Pre-Release Label Progression on `main`
-Version information for Terminal.Gui is managed by [gitversion](https://gitversion.net). To install `gitversion`:
+To change the pre-release stage, edit the `label` field under `main` in `GitVersion.yml`:
-```powershell
-dotnet tool install --global GitVersion.Tool
-dotnet-gitversion
-```
+| Stage | `label` value | Example Output |
+|-------|--------------|----------------|
+| Beta | `beta` | `2.0.0-beta.1` |
+| Release Candidate | `rc` | `2.0.0-rc.1` |
+| Stable Release | `''` (empty) | `2.0.0` |
-The project version (used in the NuGet package and `Terminal.Gui.dll`) is determined from the latest `git tag`. The format of version numbers is `major.minor.patch.build.height` and follows [Semantic Versioning](https://semver.org/) rules.
+## Publishing a Release
-To define a new version, tag a commit using `git tag`:
+Releases follow [Semantic Versioning](https://semver.org/): **MAJOR** for breaking changes, **MINOR** for new features, **PATCH** for bug fixes.
-```powershell
-git tag v2.1.0-beta.1 -a -m "Release v2.1.0 Beta 1"
-dotnet-gitversion /updateprojectfiles
-dotnet build -c Release
-```
+### Automated Release Workflow (Preferred)
-**DO NOT COMMIT AFTER USING `/updateprojectfiles`!** Doing so will update the `.csproj` files in your branch with version info, which we do not want.
+Two workflows handle the release lifecycle:
-## Publishing a Release of Terminal.Gui
+**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`.
+4. Review the PR — CI runs, branch protections apply.
-To release a new version, follow these steps based on [Semantic Versioning](https://semver.org/) rules:
+**Step 2 — [Finalize Release](../.github/workflows/finalize-release.yml)** (automatic on PR merge):
+When the release PR is merged into `main`:
+1. Creates an annotated tag (`vX.Y.Z` or `vX.Y.Z-beta`)
+2. Creates a GitHub Release with auto-generated notes
+3. [Publish](../.github/workflows/publish.yml) fires automatically → NuGet package published
+4. Opens a back-merge PR (`main` → `develop`) to keep branches in sync
-- **MAJOR** version for incompatible API changes.
-- **MINOR** version for backwards-compatible functionality additions.
-- **PATCH** version for backwards-compatible bug fixes.
+### Manual Release Steps
-### Steps for Release:
+If you need to release manually:
-1. **Verify the `develop` branch is ready for release**:
- - Ensure all changes are committed and pushed to the `develop` branch.
- - Ensure your local `develop` branch is up-to-date with `upstream/develop`.
+1. **Ensure `develop` is ready**: all changes committed, CI passing.
-2. **Create a pull request for the release in the `develop` branch**:
- - Title the PR as "Release vX.Y.Z".
- ```powershell
- git checkout develop
- git pull upstream develop
- git checkout -b vX_Y_Z
- git add .
- git commit -m "Release vX.Y.Z"
- git push
- ```
- - Go to the link printed by `git push` and fill out the Pull Request.
+2. **Merge `develop` into `main`**:
+ ```powershell
+ git checkout main
+ git pull upstream main
+ git checkout develop
+ git pull upstream develop
+ git checkout main
+ git merge develop
+ # Fix any merge conflicts
+ ```
-3. **On github.com, verify the build action worked on your fork, then merge the PR**.
+3. **Update the pre-release label** in `GitVersion.yml` if changing stages (e.g., `beta` → `rc` → `''`).
-4. **Pull the merged `develop` from `upstream`**:
- ```powershell
- git checkout develop
- git pull upstream develop
- ```
+4. **Tag the release on `main`**:
+ ```powershell
+ git tag vX.Y.Z -a -m "Release vX.Y.Z"
+ ```
-5. **Merge `develop` into `main`**:
- ```powershell
- git checkout main
- git pull upstream main
- git merge develop
- ```
- - Fix any merge errors.
+5. **Push atomically**:
+ ```powershell
+ git push --atomic upstream main vX.Y.Z
+ ```
-6. **Create a new annotated tag for the release on `main`**:
- ```powershell
- git tag vX.Y.Z -a -m "Release vX.Y.Z"
- ```
+6. **Monitor CI**: the [Publish workflow](https://github.com/gui-cs/Terminal.Gui/actions) builds and pushes to [NuGet](https://www.nuget.org/packages/Terminal.Gui). It also triggers an update to [Terminal.Gui.templates](https://github.com/gui-cs/Terminal.Gui.templates) for stable releases.
-7. **Push the new tag to `main` on `upstream`**:
- ```powershell
- git push --atomic upstream main vX.Y.Z
- ```
+7. **Create a GitHub Release** at [Releases](https://github.com/gui-cs/Terminal.Gui/releases) with auto-generated release notes.
-8. **Monitor Github Actions to ensure the NuGet publishing worked**:
- - Check [GitHub Actions](https://github.com/gui-cs/Terminal.Gui/actions).
+8. **Merge `main` back into `develop`**:
+ ```powershell
+ git checkout develop
+ git pull upstream develop
+ git merge main
+ git push upstream develop
+ ```
-9. **Check NuGet to see the new package version (wait a few minutes)**:
- - Visit [NuGet Package](https://www.nuget.org/packages/Terminal.Gui).
+## CI/CD Workflows
-10. **Add a new Release in Github**:
- - Go to [GitHub Releases](https://github.com/gui-cs/Terminal.Gui/releases) and generate release notes with the list of PRs since the last release.
+All workflows are in [`.github/workflows/`](../.github/workflows/):
-11. **Update the `develop` branch with the new version**:
- ```powershell
- git checkout develop
- git pull upstream develop
- git merge main
- git push upstream develop
- ```
+| Workflow | Trigger | Purpose |
+|----------|---------|---------|
+| **[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 |
+| **[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 |
+| **[codeql-analysis.yml](../.github/workflows/codeql-analysis.yml)** | Push/PR to `main` or `develop` | CodeQL security analysis |
+| **[integration-tests.yml](../.github/workflows/integration-tests.yml)** | Push/PR to `main` or `develop` | Integration tests |
+| **[stress-tests.yml](../.github/workflows/stress-tests.yml)** | Push/PR to `main` or `develop` | Stress tests |
-## NuGet
+## V1 Legacy Branches
-The official NuGet package for Terminal.Gui is available at [https://www.nuget.org/packages/Terminal.Gui](https://www.nuget.org/packages/Terminal.Gui). When a new version tag is defined and merged into `main`, a NuGet package is automatically generated by a GitHub Action. Pre-release versions (e.g., `2.0.0-beta.5`) are tagged as pre-release on NuGet.
+Terminal.Gui V1 (latest: `v1.19.0`) is maintained on separate branches:
-## Contributing
+| Branch | Purpose |
+|--------|---------|
+| `v1_release` | V1 stable releases (equivalent of `main` for V1) |
+| `v1_develop` | V1 development (equivalent of `develop` for V1) |
-We welcome contributions from the community. For complete contribution guidelines, including:
-- Build and test instructions
-- Coding conventions and style rules
-- Testing requirements and patterns
-- Pull request guidelines
-- CI/CD workflows
+V1 follows the same GitFlow model as V2 but is in maintenance-only mode. The V1 NuGet package is `Terminal.Gui` 1.x. V1 API docs are published from `v1_release` to a separate GitHub Pages path.
+
+These branches are **not** configured in `GitVersion.yml` (the config was removed to avoid interference with V2 versioning). V1 releases are tagged manually (e.g., `v1.19.0`).
+
+## 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`, release versions from `main`)
+- Pre-release versions (e.g., `2.0.0-beta.5`) are marked as pre-release on NuGet
+
+### Local Package Development
+
+When building in `Release` configuration, the `.csproj` automatically:
+1. Copies `.nupkg` and `.snupkg` to `../local_packages/`
+2. Pushes the package to the local NuGet cache
+
+Use the `local_packages` folder as a local NuGet source to test packages before publishing:
+```powershell
+dotnet build Terminal.Gui/Terminal.Gui.csproj -c Release
+# Package is now in local_packages/ and your global NuGet cache
+```
+
+## Documentation
+
+- **Live docs**: [gui-cs.github.io/Terminal.Gui](https://gui-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 `develop`
+
+## Contributing
-Please refer to [CONTRIBUTING.md](../CONTRIBUTING.md) in the repository root.
+See [CONTRIBUTING.md](../CONTRIBUTING.md) for build/test instructions, coding conventions, and PR guidelines.
diff --git a/Terminal.Gui/Terminal.Gui.csproj b/Terminal.Gui/Terminal.Gui.csproj
index a91b4c6d6f..893d3143e0 100644
--- a/Terminal.Gui/Terminal.Gui.csproj
+++ b/Terminal.Gui/Terminal.Gui.csproj
@@ -1,13 +1,8 @@
-
-
-
+
-
- 2.0.0
-
@@ -45,7 +40,6 @@
true
-
@@ -76,6 +70,9 @@
+
+
+
@@ -209,6 +206,6 @@
-
+
diff --git a/Terminal.Gui/ViewBase/View.Drawing.cs b/Terminal.Gui/ViewBase/View.Drawing.cs
index 4e5753d4a4..e17a052e73 100644
--- a/Terminal.Gui/ViewBase/View.Drawing.cs
+++ b/Terminal.Gui/ViewBase/View.Drawing.cs
@@ -746,7 +746,7 @@ private void DoDrawComplete (DrawContext? context)
if (!viewTransparent)
{
- exclusion.Combine (ViewportToScreen (Viewport), RegionOp.Union);
+ exclusion.Combine (ViewportToScreen (new Rectangle (Point.Empty, Viewport.Size)), RegionOp.Union);
}
// For transparent layers, also include context drawn regions (text, content, subviews)
diff --git a/Terminal.Gui/Views/TextInput/TextModel.cs b/Terminal.Gui/Views/TextInput/TextModel.cs
index f066acb137..6a9849354f 100644
--- a/Terminal.Gui/Views/TextInput/TextModel.cs
+++ b/Terminal.Gui/Views/TextInput/TextModel.cs
@@ -16,6 +16,7 @@ internal class TextModel
// Cached max visible line width to avoid O(N×L) rescans on every layout/scroll.
private int _cachedMaxWidth = -1;
private int _cachedMaxWidthTabWidth = -1;
+ private Dictionary _cachedMaxWidthPerLine = [];
///
/// Gets the number of times performed a full line scan.
@@ -34,6 +35,38 @@ internal class TextModel
/// Invalidates the cached max line width so the next call to will rescan.
internal void InvalidateMaxWidthCache () => _cachedMaxWidth = -1;
+ ///
+ /// Determines whether the max width cache should be invalidated based on the line being modified, the column width of the modification, and whether it's an insert or delete operation.
+ ///
+ /// The line number being modified.
+ /// Indicates whether the operation is an insert. Defaults to true.
+ /// The width of the column being modified. Defaults to -1 on delete.
+ /// if the cache should be invalidated; otherwise, .
+ internal bool ShouldInvalidateMaxWidthCache (int line, bool isInsert = true, int columnWidth = -1)
+ {
+ if (_cachedMaxWidth < 0)
+ {
+ return true;
+ }
+
+ if (isInsert)
+ {
+ if (_cachedMaxWidthPerLine.TryGetValue (line, out int cachedLineWidth) && columnWidth > cachedLineWidth)
+ {
+ return true;
+ }
+ }
+ else
+ {
+ if (_cachedMaxWidthPerLine.Count == 1 && _cachedMaxWidthPerLine.ContainsKey (line))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/// Adds a line to the model at the specified position.
/// Line number where the line will be inserted.
/// The line of text and color, as a List of Cell.
@@ -96,6 +129,12 @@ public int GetMaxVisibleLine (int first, int last, int tabWidth)
var maxLength = 0;
last = last < _lines.Count ? last : _lines.Count;
+ // When scanning the full range, reset cached max width per line
+ if (first == 0 && last >= _lines.Count)
+ {
+ _cachedMaxWidthPerLine = [];
+ }
+
for (int i = first; i < last; i++)
{
List line = GetLine (i);
@@ -104,6 +143,17 @@ public int GetMaxVisibleLine (int first, int last, int tabWidth)
if (colsWidth > maxLength)
{
maxLength = colsWidth;
+
+ // Cache max width per line when scanning the full range, and remove cached widths for lines that are no longer the max
+ if (first != 0 || last < _lines.Count)
+ {
+ continue;
+ }
+ _cachedMaxWidthPerLine = new Dictionary { { i, maxLength } };
+ }
+ else if (maxLength == colsWidth)
+ {
+ _cachedMaxWidthPerLine [i] = maxLength;
}
}
diff --git a/Terminal.Gui/Views/TextInput/TextView/TextView.Commands.cs b/Terminal.Gui/Views/TextInput/TextView/TextView.Commands.cs
index 06efc32c40..7af7ad1763 100644
--- a/Terminal.Gui/Views/TextInput/TextView/TextView.Commands.cs
+++ b/Terminal.Gui/Views/TextInput/TextView/TextView.Commands.cs
@@ -383,8 +383,8 @@ public bool DeleteCharLeft ()
bool retValue = DeleteTextLeft ();
- DoNeededAction ();
OnContentsChanged ();
+ DoNeededAction ();
return retValue;
}
@@ -453,37 +453,45 @@ private bool DeleteTextLeft ()
}
SetWrapModel ();
- int prowIdx = CurrentRow - 1;
- List prevRow = _model.GetLine (prowIdx);
- _historyText.Add ([[.. prevRow]], InsertionPoint);
+ if (CurrentRow - 1 > -1)
+ {
+ int prowIdx = CurrentRow - 1;
+ List prevRow = _model.GetLine (prowIdx);
+
+ _historyText.Add ([[.. prevRow]], InsertionPoint);
- List> removedLines = [[.. prevRow], [.. GetCurrentLine ()]];
+ List> removedLines = [[.. prevRow], [.. GetCurrentLine ()]];
- _historyText.Add (removedLines, new Point (CurrentColumn, prowIdx), TextEditingLineStatus.Removed);
+ _historyText.Add (removedLines, new Point (CurrentColumn, prowIdx), TextEditingLineStatus.Removed);
- int prevCount = prevRow.Count;
- _model.GetLine (prowIdx).AddRange (GetCurrentLine ());
- _model.RemoveLine (CurrentRow);
+ int prevCount = prevRow.Count;
+ _model.GetLine (prowIdx).AddRange (GetCurrentLine ());
+ _model.RemoveLine (CurrentRow);
+
+ CurrentRow--;
+
+ _historyText.Add ([GetCurrentLine ()], new Point (CurrentColumn, prowIdx), TextEditingLineStatus.Replaced);
+
+ CurrentColumn = prevCount;
+ }
if (_wordWrap)
{
_wrapNeeded = true;
}
-
- CurrentRow--;
-
- _historyText.Add ([GetCurrentLine ()], new Point (CurrentColumn, prowIdx), TextEditingLineStatus.Replaced);
-
- CurrentColumn = prevCount;
}
- // Always redraw and update content size because a glyph was deleted
+ // Text was deleted, so it's always needed to redraw and update content size if needed
SetNeedsDraw ();
- UpdateContentSize ();
+
+ if (_model.ShouldInvalidateMaxWidthCache (CurrentRow, false))
+ {
+ _model.InvalidateMaxWidthCache ();
+ UpdateContentSize ();
+ }
UpdateWrapModel ();
- OnContentsChanged ();
return true;
}
@@ -513,8 +521,13 @@ private bool DeleteTextRight ()
_historyText.Add (removedLines, InsertionPoint, TextEditingLineStatus.Removed);
currentLine.AddRange (nextLine);
_model.RemoveLine (CurrentRow + 1);
+
+ // Text was deleted, so it's always needed to redraw and update content size if needed
SetNeedsDraw ();
+ // _model.RemoveLine already invalidates the max width cache for the removed line, but we also need to check if the merged line's width changed
+ UpdateContentSize ();
+
_historyText.Add ([[.. currentLine]], InsertionPoint, TextEditingLineStatus.Replaced);
if (_wordWrap)
@@ -531,8 +544,16 @@ private bool DeleteTextRight ()
_historyText.Add ([[.. currentLine]], InsertionPoint);
currentLine.RemoveAt (CurrentColumn);
+
+ // Text was deleted, so it's always needed to redraw and update content size if needed
SetNeedsDraw ();
+ if (_model.ShouldInvalidateMaxWidthCache (CurrentRow, false))
+ {
+ _model.InvalidateMaxWidthCache ();
+ UpdateContentSize ();
+ }
+
_historyText.Add ([[.. currentLine]], InsertionPoint, TextEditingLineStatus.Replaced);
if (_wordWrap)
@@ -660,6 +681,7 @@ private bool CutToStartOfLine ()
UpdateWrapModel ();
DeleteTextLeft ();
+ OnContentsChanged ();
return true;
}
@@ -750,6 +772,7 @@ private bool KillWordLeft ()
if (CurrentColumn == 0)
{
DeleteTextLeft ();
+ OnContentsChanged ();
_historyText.ReplaceLast ([[.. GetCurrentLine ()]], InsertionPoint, TextEditingLineStatus.Replaced);
@@ -1010,6 +1033,7 @@ private bool ProcessTab (bool addTab)
if (CurrentColumn - 1 > -1 && CurrentColumn - 1 < line.Count && line [CurrentColumn - 1].Grapheme == "\t")
{
DeleteTextLeft ();
+ OnContentsChanged ();
}
else
{
diff --git a/Terminal.Gui/Views/TextInput/TextView/TextView.Movement.cs b/Terminal.Gui/Views/TextInput/TextView/TextView.Movement.cs
index 25aae3a33d..dbffdc6f9c 100644
--- a/Terminal.Gui/Views/TextInput/TextView/TextView.Movement.cs
+++ b/Terminal.Gui/Views/TextInput/TextView/TextView.Movement.cs
@@ -108,7 +108,7 @@ private bool MoveDown ()
CurrentRow++;
- if (CurrentRow >= Viewport.Y + Viewport.Height)
+ if (CurrentRow >= Viewport.Y + Viewport.Height || CurrentRow < Viewport.Y)
{
SetNeedsDraw ();
}
@@ -145,8 +145,9 @@ private bool MoveEndOfLine ()
List currentLine = GetCurrentLine ();
CurrentColumn = currentLine.Count;
- if (CurrentColumn >= Viewport.X + Viewport.Width || TextModel.CursorColumn (TextModel.CellsToStringList (currentLine), CurrentColumn, TabWidth, out _, out _) - Viewport.X >= Viewport.Width)
-{
+ if (CurrentColumn >= Viewport.X + Viewport.Width
+ || TextModel.CursorColumn (TextModel.CellsToStringList (currentLine), CurrentColumn, TabWidth, out _, out _) - Viewport.X >= Viewport.Width)
+ {
SetNeedsDraw ();
}
DoNeededAction ();
@@ -160,7 +161,10 @@ private bool MoveLeft ()
{
CurrentColumn--;
- if (Viewport.X > 0 && CurrentColumn <= Viewport.X)
+ List currentLine = GetCurrentLine ();
+ int cursorColumn = TextModel.CursorColumn (TextModel.CellsToStringList (currentLine), CurrentColumn, TabWidth, out _, out _);
+
+ if ((Viewport.X > 0 && cursorColumn <= Viewport.X) || cursorColumn - Viewport.X >= Viewport.Width)
{
SetNeedsDraw ();
}
@@ -296,7 +300,9 @@ private bool MoveRight ()
{
CurrentColumn++;
- if (CurrentColumn >= currentLine.Count || TextModel.CursorColumn (TextModel.CellsToStringList (currentLine), CurrentColumn, TabWidth, out _, out _) >= Viewport.Width)
+ int cursorColumn = TextModel.CursorColumn (TextModel.CellsToStringList (currentLine), CurrentColumn, TabWidth, out _, out _);
+
+ if (cursorColumn >= currentLine.Count || (Viewport.X > 0 && cursorColumn < Viewport.X) || cursorColumn >= Viewport.X + Viewport.Width)
{
SetNeedsDraw ();
}
@@ -346,16 +352,19 @@ private bool MoveTopHomeExtend ()
private bool MoveUp ()
{
- if (CurrentRow > 0)
+ if (CurrentRow > 0 || (CurrentRow == 0 && CurrentRow < Viewport.Y))
{
if (_columnTrack == -1)
{
_columnTrack = CurrentColumn;
}
- CurrentRow--;
+ if (CurrentRow > 0)
+ {
+ CurrentRow--;
+ }
- if (CurrentRow < Viewport.Y)
+ if (CurrentRow < Viewport.Y || CurrentRow >= Viewport.Y + Viewport.Height)
{
SetNeedsDraw ();
}
@@ -393,6 +402,14 @@ private bool MoveWordLeft ()
CurrentRow = newPos.Value.row;
}
+ List currentLine = GetCurrentLine ();
+ int cursorColumn = TextModel.CursorColumn (TextModel.CellsToStringList (currentLine), CurrentColumn, TabWidth, out _, out _);
+
+ if (CurrentRow < Viewport.Y || cursorColumn < Viewport.X || cursorColumn >= Viewport.X + Viewport.Width)
+ {
+ SetNeedsDraw ();
+ }
+
DoNeededAction ();
return true;
@@ -408,6 +425,14 @@ private bool MoveWordRight ()
CurrentRow = newPos.Value.row;
}
+ List currentLine = GetCurrentLine ();
+ int cursorColumn = TextModel.CursorColumn (TextModel.CellsToStringList (currentLine), CurrentColumn, TabWidth, out _, out _);
+
+ if (CurrentRow >= Viewport.Y + Viewport.Height || cursorColumn >= Viewport.X + Viewport.Width || cursorColumn < Viewport.X)
+ {
+ SetNeedsDraw ();
+ }
+
DoNeededAction ();
return true;
diff --git a/Terminal.Gui/Views/TextInput/TextView/TextView.Scrolling.cs b/Terminal.Gui/Views/TextInput/TextView/TextView.Scrolling.cs
index c90f82944f..c09ce04f5d 100644
--- a/Terminal.Gui/Views/TextInput/TextView/TextView.Scrolling.cs
+++ b/Terminal.Gui/Views/TextInput/TextView/TextView.Scrolling.cs
@@ -115,11 +115,16 @@ private void AdjustViewport ()
}
need = true;
}
- else if (!_wordWrap && (CurrentColumn - Viewport.X + 1 > Viewport.Width || dSize.size + 1 >= Viewport.Width))
+ else if (!_wordWrap && (CurrentColumn - Viewport.X + 1 > Viewport.Width || dSize.size - Viewport.X + 1 >= Viewport.Width))
{
Viewport = Viewport with { X = TextModel.CalculateLeftColumn (line, Viewport.X, CurrentColumn, Viewport.Width, TabWidth) };
need = true;
}
+ else if (tSize.size - Viewport.X + 1 <= Viewport.Width)
+ {
+ Viewport = Viewport with { X = Math.Max (0, tSize.size - Viewport.Width + 1) };
+ need = true;
+ }
else if ((_wordWrap && Viewport.X > 0) || (dSize.size < Viewport.Width && tSize.size < Viewport.Width))
{
if (Viewport.X > 0)
@@ -140,11 +145,6 @@ private void AdjustViewport ()
Viewport = Viewport with { Y = Math.Min (Math.Max (CurrentRow - Viewport.Height + 1, 0), CurrentRow) };
need = true;
}
- else if (!WordWrap && Viewport.Y > 0 && CurrentRow - Viewport.Height + 1 < Viewport.Y)
- {
- Viewport = Viewport with { Y = Math.Max (Viewport.Y - 1, 0) };
- need = true;
- }
if (need)
{
diff --git a/Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs b/Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs
index 1a2d9b3a69..d12c8457ff 100644
--- a/Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs
+++ b/Terminal.Gui/Views/TextInput/TextView/TextView.Text.cs
@@ -558,23 +558,19 @@ private void InsertText (Key a, Attribute? attribute = null)
return;
}
- if (Used)
- {
- Insert (new Cell { Grapheme = grapheme, Attribute = attribute });
- CurrentColumn++;
- }
- else
- {
- Insert (new Cell { Grapheme = grapheme, Attribute = attribute });
- CurrentColumn++;
- }
- UpdateContentSize ();
+ Insert (new Cell { Grapheme = grapheme, Attribute = attribute });
+ CurrentColumn++;
+
+ // Text was inserted, so it's always needed to redraw and update content size if needed
+ SetNeedsDraw ();
+
List| line = GetCurrentLine ();
- (int size, int length) dSize = TextModel.DisplaySize (line, 0, CurrentColumn, true, TabWidth);
+ (int size, int length) dSize = TextModel.DisplaySize (line, 0, line.Count, true, TabWidth);
- if (dSize.size + 1 - Viewport.X >= Viewport.Width)
+ if (_model.ShouldInvalidateMaxWidthCache (CurrentRow, true, dSize.size))
{
- SetNeedsDraw ();
+ _model.InvalidateMaxWidthCache ();
+ UpdateContentSize ();
}
}
diff --git a/Tests/UnitTests.Legacy/UnitTests.Legacy.csproj b/Tests/UnitTests.Legacy/UnitTests.Legacy.csproj
index 98cce1be6b..9eb7fa9818 100644
--- a/Tests/UnitTests.Legacy/UnitTests.Legacy.csproj
+++ b/Tests/UnitTests.Legacy/UnitTests.Legacy.csproj
@@ -1,13 +1,4 @@
-
-
-
-
- 2.0
- 2.0
- 2.0
- 2.0
-
UnitTests.Legacy
Exe
diff --git a/Tests/UnitTests.NonParallelizable/UnitTests.NonParallelizable.csproj b/Tests/UnitTests.NonParallelizable/UnitTests.NonParallelizable.csproj
index 76f1c2b6bf..b414931854 100644
--- a/Tests/UnitTests.NonParallelizable/UnitTests.NonParallelizable.csproj
+++ b/Tests/UnitTests.NonParallelizable/UnitTests.NonParallelizable.csproj
@@ -1,13 +1,4 @@
-
-
-
-
- 2.0
- 2.0
- 2.0
- 2.0
-
UnitTests.NonParallelizable
Exe
diff --git a/Tests/UnitTestsParallelizable/Drivers/Output/InlineDrawTimingTests.cs b/Tests/UnitTestsParallelizable/Drivers/Output/InlineDrawTimingTests.cs
index a0a2b56e88..27435353e5 100644
--- a/Tests/UnitTestsParallelizable/Drivers/Output/InlineDrawTimingTests.cs
+++ b/Tests/UnitTestsParallelizable/Drivers/Output/InlineDrawTimingTests.cs
@@ -227,7 +227,7 @@ public void IterationImpl_Inline_NonAnsiMonitor_DrawsImmediately ()
/// Verifies the full timeline: deferred iterations → gate ready → first draw.
/// Dumps all trace entries to the test output for diagnostic visibility.
/// | | | | | | | |
- [Fact]
+ [Fact (Skip = "Bogus test; do not use tracing for test results.")]
public void IterationImpl_Inline_FullTimeline_TraceDump ()
{
using IDisposable logScope = TestLogging.Verbose (output, TraceCategory.Lifecycle | TraceCategory.Draw);
diff --git a/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj b/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj
index f590d74bd6..7da1c0c2dd 100644
--- a/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj
+++ b/Tests/UnitTestsParallelizable/UnitTests.Parallelizable.csproj
@@ -1,13 +1,4 @@
-
-
-
-
- 2.0
- 2.0
- 2.0
- 2.0
-
Exe
true
diff --git a/Tests/UnitTestsParallelizable/ViewBase/Draw/DoDrawCompleteTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Draw/DoDrawCompleteTests.cs
index f81623a2b9..e77fec9839 100644
--- a/Tests/UnitTestsParallelizable/ViewBase/Draw/DoDrawCompleteTests.cs
+++ b/Tests/UnitTestsParallelizable/ViewBase/Draw/DoDrawCompleteTests.cs
@@ -234,6 +234,45 @@ public void TransparentView_ExcludesBorderAndPadding ()
Assert.False (driver.Clip.Contains (paddingFrame.X + 1, paddingFrame.Y), "Padding area should be excluded from clip for transparent view");
}
+ ///
+ /// Verifies that the opaque viewport exclusion in the per-layer DoDrawComplete path uses
+ /// an empty viewport location. A scrolled viewport must still exclude the on-screen viewport
+ /// area rather than an offset content rectangle.
+ ///
+ [Fact]
+ public void OpaqueView_WithScrolledViewport_ExcludesOnScreenViewportArea ()
+ {
+ IDriver driver = CreateTestDriver ();
+ driver.Clip = new Region (driver.Screen);
+
+ View view = new ()
+ {
+ X = 5,
+ Y = 5,
+ Width = 12,
+ Height = 12,
+ BorderStyle = LineStyle.Single,
+ Driver = driver
+ };
+ view.Border.ViewportSettings |= ViewportSettingsFlags.Transparent;
+ view.SetContentSize (new Size (100, 100));
+ view.ClearingViewport += (_, e) => e.Cancel = true;
+ view.BeginInit ();
+ view.EndInit ();
+ view.LayoutSubViews ();
+
+ view.Viewport = view.Viewport with { Location = new Point (10, 10) };
+
+ Assert.NotEqual (Point.Empty, view.Viewport.Location);
+
+ view.Draw ();
+
+ Rectangle viewportScreen = view.ViewportToScreen (new Rectangle (Point.Empty, view.Viewport.Size));
+
+ Assert.False (driver.Clip!.Contains (viewportScreen.X + 1, viewportScreen.Y + 1),
+ "Scrolled viewport interior should be excluded using an empty viewport location");
+ }
+
///
/// Verifies that Adornment views (Margin, Border, Padding) do NOT modify Driver.Clip
/// in their own DoDrawComplete — their parent handles clip exclusion for them.
diff --git a/Tests/UnitTestsParallelizable/Views/TextModelTests.cs b/Tests/UnitTestsParallelizable/Views/TextModelTests.cs
index 04cf75022b..dc18664755 100644
--- a/Tests/UnitTestsParallelizable/Views/TextModelTests.cs
+++ b/Tests/UnitTestsParallelizable/Views/TextModelTests.cs
@@ -1413,4 +1413,110 @@ public void GetLine_ThreadSafe_MultipleAccess ()
}
#endregion
+
+ #region Additional tests for TextModel.ShouldInvalidateMaxWidthCache logic
+
+ [Fact]
+ public void ShouldInvalidateMaxWidthCache_Returns_True_When_Line_Modified_IsGreater_Than_CachedMaxWidth_Line_On_Insert ()
+ {
+ TextModel model = new ();
+ model.LoadString ("Short\nMedium line\nThe longest line in the document");
+
+ // Prime cache
+ model.GetMaxVisibleLine (0, model.Count, 4);
+
+ // The longest line is line 2 (0-based index)
+ bool shouldInvalidate = model.ShouldInvalidateMaxWidthCache (2, true, 33);
+ Assert.True (shouldInvalidate, "Cache should be invalidated when inserting into the cached max width line");
+ }
+
+ [Fact]
+ public void ShouldInvalidateMaxWidthCache_Returns_True_When_Line_Modified_Is_CachedMaxWidth_Line_On_Delete ()
+ {
+ TextModel model = new ();
+ model.LoadString ("Short\nMedium line\nThe longest line in the document");
+
+ // Prime cache
+ model.GetMaxVisibleLine (0, model.Count, 4);
+
+ // The longest line is line 2 (0-based index)
+ bool shouldInvalidate = model.ShouldInvalidateMaxWidthCache (2, false);
+ Assert.True (shouldInvalidate, "Cache should be invalidated when deleting from the cached max width line");
+ }
+
+ [Fact]
+ public void ShouldInvalidateMaxWidthCache_Returns_False_When_Line_Modified_Is_Less_Than_CachedMaxWidth_Line_On_Insert ()
+ {
+ TextModel model = new ();
+ model.LoadString ("Short\nMedium line\nThe longest line in the document");
+
+ // Prime cache
+ model.GetMaxVisibleLine (0, model.Count, 4);
+
+ // The longest line is line 2 (0-based index)
+ bool shouldInvalidate = model.ShouldInvalidateMaxWidthCache (1, true, 10);
+ Assert.False (shouldInvalidate, "Cache should NOT be invalidated when inserting into a line shorter than the cached max width line");
+ }
+
+ [Fact]
+ public void ShouldInvalidateMaxWidthCache_Returns_False_When_Line_Modified_Is_Not_CachedMaxWidth_Line_On_Delete ()
+ {
+ TextModel model = new ();
+ model.LoadString ("Short\nMedium line\nThe longest line in the document");
+
+ // Prime cache
+ model.GetMaxVisibleLine (0, model.Count, 4);
+
+ // The longest line is line 2 (0-based index)
+ bool shouldInvalidate = model.ShouldInvalidateMaxWidthCache (1, false);
+ Assert.False (shouldInvalidate, "Cache should NOT be invalidated when deleting from a line that is not the cached max width line");
+ }
+
+ [Fact]
+ public void ShouldInvalidateMaxWidthCache_Returns_True_When_Cache_Is_Uninitialized ()
+ {
+ TextModel model = new ();
+ bool shouldInvalidate = model.ShouldInvalidateMaxWidthCache (0, true, 10);
+ Assert.True (shouldInvalidate, "Cache should be invalidated when the cache is uninitialized");
+ }
+
+ [Fact]
+ public void ShouldInvalidateMaxWidthCache_Returns_True_When_Cache_Is_Uninitialized_On_Delete ()
+ {
+ TextModel model = new ();
+ bool shouldInvalidate = model.ShouldInvalidateMaxWidthCache (0, false);
+ Assert.True (shouldInvalidate, "Cache should be invalidated when the cache is uninitialized on delete");
+ }
+
+ [Fact]
+ public void ShouldInvalidateMaxWidthCache_Returns_False_When_Line_Modified_Equals_CachedMaxWidth_Line_On_Insert ()
+ {
+ TextModel model = new ();
+ model.LoadString ("Short\nMedium line\nThe longest line in the document");
+
+ // Prime cache
+ model.GetMaxVisibleLine (0, model.Count, 4);
+
+ // The longest line is line 2 (0-based index)
+ bool shouldInvalidate = model.ShouldInvalidateMaxWidthCache (2, true, 32);
+ Assert.False (shouldInvalidate, "Cache should NOT be invalidated when inserting new width that does not exceed the cached width");
+ }
+
+ [Fact]
+ public void ShouldInvalidateMaxWidthCache_Returns_False_When_Line_Modified_HasMoreLinesWithSame_CachedMaxWidth_Line_On_Delete ()
+ {
+ TextModel model = new ();
+ model.LoadString ("Short\nMedium line\nThe longest line in the document 1\nThe longest line in the document 2");
+
+ // Prime cache
+ model.GetMaxVisibleLine (0, model.Count, 4);
+
+ // The longest line are line 2 and line 3 (0-based index)
+ bool shouldInvalidate = model.ShouldInvalidateMaxWidthCache (2, false);
+
+ Assert.False (shouldInvalidate,
+ "Cache should NOT be invalidated when deleting from a line that is not the only cached max width line because there are multiple lines with the same max width");
+ }
+
+ #endregion
}
diff --git a/Tests/UnitTestsParallelizable/Views/TextView.CommandTests.cs b/Tests/UnitTestsParallelizable/Views/TextView.CommandTests.cs
index c8da5d9433..e9ab0d8b5f 100644
--- a/Tests/UnitTestsParallelizable/Views/TextView.CommandTests.cs
+++ b/Tests/UnitTestsParallelizable/Views/TextView.CommandTests.cs
@@ -282,6 +282,54 @@ public void DeleteCharLeft_With_Selection_Removes_Selected_Text ()
Assert.False (tv.IsSelecting);
}
+ [Fact]
+ public void DeleteCharLeft_Only_Raises_ContentsChanged_Once ()
+ {
+ using IApplication app = Application.Create ();
+ using Runnable runnable = new ();
+
+ TextView tv = new () { Width = 40, Height = 10, Text = "Hello" };
+ runnable.Add (tv);
+ app.Begin (runnable);
+ tv.InsertionPoint = new Point (5, 0);
+
+ int contentsChangedCount = 0;
+ tv.ContentsChanged += (_, _) => contentsChangedCount++;
+
+ bool result = tv.DeleteCharLeft ();
+
+ Assert.True (result);
+ Assert.Equal ("Hell", tv.Text);
+ Assert.Equal (1, contentsChangedCount);
+ }
+
+ [Fact]
+ public void DeleteCharLeft_With_WordWarp_True_And_Cursor_At_Start_Of_SecondLine_Wont_Return_Negative_LineIndex ()
+ {
+ using IApplication app = Application.Create ();
+ using Runnable runnable = new ();
+
+ TextView tv = new () { Width = 10, Height = 3, Text = "One line test", WordWrap = true };
+ runnable.Add (tv);
+ app.Begin (runnable);
+ tv.InsertionPoint = new Point (0, 1);
+
+ Assert.True (tv.WordWrap);
+ Assert.Equal (2, tv.Lines);
+ string line = tv.GetLine (0).Select (cell => cell.Grapheme).Aggregate (string.Empty, (current, next) => current + next);
+ Assert.Equal ("One line ", line);
+ line = tv.GetLine (1).Select (cell => cell.Grapheme).Aggregate (string.Empty, (current, next) => current + next);
+ Assert.Equal ("test", line);
+
+ tv.NewKeyDownEvent (Key.Backspace);
+ Assert.Equal (new Point (9, 0), tv.InsertionPoint);
+ Assert.Equal (2, tv.Lines);
+ line = tv.GetLine (0).Select (cell => cell.Grapheme).Aggregate (string.Empty, (current, next) => current + next);
+ Assert.Equal ("One line ", line);
+ line = tv.GetLine (1).Select (cell => cell.Grapheme).Aggregate (string.Empty, (current, next) => current + next);
+ Assert.Equal ("test", line);
+ }
+
#endregion
#region DeleteCharRight
@@ -381,6 +429,27 @@ public void DeleteCharRight_With_Selection_Removes_Selected_Text ()
Assert.False (tv.IsSelecting);
}
+ [Fact]
+ public void DeleteCharRight_Only_Raises_ContentsChanged_Once ()
+ {
+ using IApplication app = Application.Create ();
+ using Runnable runnable = new ();
+
+ TextView tv = new () { Width = 40, Height = 10, Text = "Hello" };
+ runnable.Add (tv);
+ app.Begin (runnable);
+ tv.InsertionPoint = new Point (0, 0);
+
+ int contentsChangedCount = 0;
+ tv.ContentsChanged += (_, _) => contentsChangedCount++;
+
+ bool result = tv.DeleteCharRight ();
+
+ Assert.True (result);
+ Assert.Equal ("ello", tv.Text);
+ Assert.Equal (1, contentsChangedCount);
+ }
+
#endregion
#region Copy
diff --git a/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs b/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs
index 9d49854c2e..d40affed1e 100644
--- a/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs
+++ b/Tests/UnitTestsParallelizable/Views/TextView.InputTests.cs
@@ -467,6 +467,54 @@ public void Tab_Advances_Correctly_To_Next_Tab_Stop (string text, int tabWidth,
Assert.Equal (expectedColumn, visualColumn);
}
+ [Fact]
+ public void Typing_Tab_At_End_Of_Line_With_Wrap_Disabled_Should_Scroll_Horizontally_And_Update_Content_Size ()
+ {
+ TextView tv = new ()
+ {
+ Width = 5,
+ Height = 2,
+ Text = "Line1\nLine2\nLine3"
+ };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ tv.InsertionPoint = new Point (6, 0);
+ Assert.Equal (new Point (1, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (5, 0), tv.InsertionPoint);
+ Assert.Equal (new Size (6, 3), tv.GetContentSize ());
+
+ tv.NewKeyDownEvent (Key.Tab);
+
+ Assert.Equal (new Point (4, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (6, 0), tv.InsertionPoint);
+ Assert.Equal (new Size (9, 3), tv.GetContentSize ());
+ }
+
+ [Fact]
+ public void Typing_ShiftTab_At_End_Of_Line_With_Wrap_Disabled_Should_Scroll_Horizontally_And_Update_Content_Size ()
+ {
+ TextView tv = new ()
+ {
+ Width = 5,
+ Height = 2,
+ Text = "Line1\t\nLine2\nLine3"
+ };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ tv.InsertionPoint = new Point (9, 0);
+ Assert.Equal (new Point (4, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (6, 0), tv.InsertionPoint);
+ Assert.Equal (new Size (9, 3), tv.GetContentSize ());
+
+ tv.NewKeyDownEvent (Key.Tab.WithShift);
+
+ Assert.Equal (new Point (1, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (5, 0), tv.InsertionPoint);
+ Assert.Equal (new Size (6, 3), tv.GetContentSize ());
+ }
+
[Fact]
public void EnterKeyAddsLine_Setter_Should_Not_Scroll_View ()
{
diff --git a/Tests/UnitTestsParallelizable/Views/TextView.NavigationTests.cs b/Tests/UnitTestsParallelizable/Views/TextView.NavigationTests.cs
index 2175cb119b..8cd3572866 100644
--- a/Tests/UnitTestsParallelizable/Views/TextView.NavigationTests.cs
+++ b/Tests/UnitTestsParallelizable/Views/TextView.NavigationTests.cs
@@ -14,12 +14,7 @@ public void PageUp_Navigates_Up_One_Page ()
using IApplication app = Application.Create ();
using Runnable runnable = new ();
- TextView tv = new ()
- {
- Width = 10,
- Height = 2,
- Text = "This is the first line.\nThis is the second line.\nThis is the third line.first"
- };
+ TextView tv = new () { Width = 10, Height = 2, Text = "This is the first line.\nThis is the second line.\nThis is the third line.first" };
runnable.Add (tv);
app.Begin (runnable);
@@ -47,12 +42,7 @@ public void PageDown_Navigates_Down_One_Page ()
using IApplication app = Application.Create ();
using Runnable runnable = new ();
- TextView tv = new ()
- {
- Width = 10,
- Height = 2,
- Text = "This is the first line.\nThis is the second line.\nThis is the third line.first"
- };
+ TextView tv = new () { Width = 10, Height = 2, Text = "This is the first line.\nThis is the second line.\nThis is the third line.first" };
runnable.Add (tv);
app.Begin (runnable);
@@ -79,12 +69,7 @@ public void CtrlHome_Navigates_To_Start_Of_Document ()
using IApplication app = Application.Create ();
using Runnable runnable = new ();
- TextView tv = new ()
- {
- Width = 10,
- Height = 2,
- Text = "This is the first line.\nThis is the second line.\nThis is the third line."
- };
+ TextView tv = new () { Width = 10, Height = 2, Text = "This is the first line.\nThis is the second line.\nThis is the third line." };
runnable.Add (tv);
app.Begin (runnable);
@@ -107,12 +92,7 @@ public void CtrlN_Navigates_To_Next_Line ()
using IApplication app = Application.Create ();
using Runnable runnable = new ();
- TextView tv = new ()
- {
- Width = 10,
- Height = 2,
- Text = "This is the first line.\nThis is the second line.\nThis is the third line."
- };
+ TextView tv = new () { Width = 10, Height = 2, Text = "This is the first line.\nThis is the second line.\nThis is the third line." };
runnable.Add (tv);
app.Begin (runnable);
@@ -134,12 +114,7 @@ public void CtrlP_Navigates_To_Previous_Line ()
using IApplication app = Application.Create ();
using Runnable runnable = new ();
- TextView tv = new ()
- {
- Width = 10,
- Height = 2,
- Text = "This is the first line.\nThis is the second line.\nThis is the third line."
- };
+ TextView tv = new () { Width = 10, Height = 2, Text = "This is the first line.\nThis is the second line.\nThis is the third line." };
runnable.Add (tv);
app.Begin (runnable);
@@ -162,12 +137,7 @@ public void CursorDown_And_CursorUp_Navigate_Lines ()
using IApplication app = Application.Create ();
using Runnable runnable = new ();
- TextView tv = new ()
- {
- Width = 10,
- Height = 2,
- Text = "This is the first line.\nThis is the second line.\nThis is the third line."
- };
+ TextView tv = new () { Width = 10, Height = 2, Text = "This is the first line.\nThis is the second line.\nThis is the third line." };
runnable.Add (tv);
app.Begin (runnable);
@@ -193,12 +163,7 @@ public void End_Key_Navigates_To_End_Of_Line ()
using IApplication app = Application.Create ();
using Runnable runnable = new ();
- TextView tv = new ()
- {
- Width = 10,
- Height = 2,
- Text = "is is the first line\nThis is the second line.\nThis is the third line.first"
- };
+ TextView tv = new () { Width = 10, Height = 2, Text = "is is the first line\nThis is the second line.\nThis is the third line.first" };
runnable.Add (tv);
app.Begin (runnable);
@@ -219,12 +184,7 @@ public void Home_Key_Navigates_To_Start_Of_Line ()
using IApplication app = Application.Create ();
using Runnable runnable = new ();
- TextView tv = new ()
- {
- Width = 10,
- Height = 2,
- Text = "is is the first lin\nThis is the second line.\nThis is the third line.first"
- };
+ TextView tv = new () { Width = 10, Height = 2, Text = "is is the first lin\nThis is the second line.\nThis is the third line.first" };
runnable.Add (tv);
app.Begin (runnable);
@@ -247,12 +207,7 @@ public void CtrlE_Navigates_To_End_Of_Line ()
using IApplication app = Application.Create ();
using Runnable runnable = new ();
- TextView tv = new ()
- {
- Width = 10,
- Height = 2,
- Text = "is is the first lin\nThis is the second line.\nThis is the third line.first"
- };
+ TextView tv = new () { Width = 10, Height = 2, Text = "is is the first lin\nThis is the second line.\nThis is the third line.first" };
runnable.Add (tv);
app.Begin (runnable);
@@ -273,12 +228,7 @@ public void CtrlF_Moves_Forward_One_Character ()
using IApplication app = Application.Create ();
using Runnable runnable = new ();
- TextView tv = new ()
- {
- Width = 10,
- Height = 2,
- Text = "This is the first line.\nThis is the second line.\nThis is the third line."
- };
+ TextView tv = new () { Width = 10, Height = 2, Text = "This is the first line.\nThis is the second line.\nThis is the third line." };
runnable.Add (tv);
app.Begin (runnable);
@@ -299,12 +249,7 @@ public void CtrlB_Moves_Backward_One_Character ()
using IApplication app = Application.Create ();
using Runnable runnable = new ();
- TextView tv = new ()
- {
- Width = 10,
- Height = 2,
- Text = "This is the first line.\nThis is the second line.\nThis is the third line."
- };
+ TextView tv = new () { Width = 10, Height = 2, Text = "This is the first line.\nThis is the second line.\nThis is the third line." };
runnable.Add (tv);
app.Begin (runnable);
@@ -318,4 +263,388 @@ public void CtrlB_Moves_Backward_One_Character ()
Assert.Equal (Point.Empty, tv.InsertionPoint);
Assert.False (tv.IsSelecting);
}
-}
+
+ [Fact]
+ public void CursorRight_At_NearTheEndOfLine_With_ViewportY_Greater_Than_Zero_Does_Not_Scroll_Up ()
+ {
+ // Test that pressing CursorRight at near the end of line does not scroll up if Viewport.Y > 0
+ TextView tv = new () { Width = 10, Height = 3, Text = "Line1.\nLine2.\nLine3.\nLine4.\nLine5." };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ // Scroll to second line and set insertion point at near the end of line
+ tv.Viewport = tv.Viewport with { Y = 1 };
+ tv.InsertionPoint = new Point (5, 1);
+ Assert.Equal (new Point (0, 1), tv.Viewport.Location);
+ Assert.Equal (new Point (5, 1), tv.InsertionPoint);
+ Assert.False (tv.WordWrap);
+
+ // Press CursorRight - should not scroll up since we aren't already at first line
+ Assert.True (tv.NewKeyDownEvent (Key.CursorRight));
+ Assert.Equal (new Point (0, 1), tv.Viewport.Location);
+ Assert.Equal (new Point (6, 1), tv.InsertionPoint);
+ }
+
+ [Fact]
+ public void CursorRight_At_BeforeNearTheEndOfLine_With_ViewportX_Greater_Than_Zero_Does_Not_Scroll_Left ()
+ {
+ // Test that pressing CursorRight at near the end of line does not scroll left if Viewport.X > 0
+ TextView tv = new () { Width = 10, Height = 3, Text = "Line1 with more long text.\nLine2.\nLine3.\nLine4." };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ // Scroll to the column 10 and set insertion point at before near the end of line
+ tv.Viewport = tv.Viewport with { X = 10 };
+ tv.InsertionPoint = new Point (17, 0);
+ Assert.Equal (new Point (10, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (17, 0), tv.InsertionPoint);
+ Assert.False (tv.WordWrap);
+ Assert.True (tv.NeedsDraw);
+
+ // Clear NeedsDraw to isolate the effect of CursorRight key press
+ tv.ClearNeedsDraw ();
+ Assert.False (tv.NeedsDraw);
+
+ // Press CursorRight - should not scroll left since we aren't already at the end of the line
+ Assert.True (tv.NewKeyDownEvent (Key.CursorRight));
+ Assert.Equal (new Point (10, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (18, 0), tv.InsertionPoint);
+ Assert.False (tv.NeedsDraw);
+ }
+
+ [Fact]
+ public void CursorRight_With_CtrlKey_Pressed_At_BeforeNearTheEndOfLine_Scrolls_Right_Next_Word ()
+ {
+ // Test that pressing Ctrl+CursorRight at neat the end of line scrolls right to next word
+ TextView tv = new () { Width = 10, Height = 3, Text = "Line1 with more long text.\nLine2.\nLine3.\nLine4." };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ // Scroll to the column 10 and set insertion point at before near the end of line
+ tv.Viewport = tv.Viewport with { X = 10 };
+ tv.InsertionPoint = new Point (17, 0);
+ Assert.Equal (new Point (10, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (17, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+
+ // Clear NeedsDraw to isolate the effect of Ctrl+CursorRight key press
+ tv.ClearNeedsDraw ();
+ Assert.False (tv.NeedsDraw);
+
+ // Press Ctrl+CursorRight - should scroll right to next word
+ Assert.True (tv.NewKeyDownEvent (Key.CursorRight.WithCtrl));
+ Assert.Equal (new Point (12, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (21, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+ }
+
+ [Fact]
+ public void CursorRight_With_CtrlKey_Pressed_At_TheEndOfLine_Scrolls_Left_To_StartOfNextLine ()
+ {
+ // Test that pressing Ctrl+CursorRight at the end of line scrolls right to start of next line
+ TextView tv = new () { Width = 10, Height = 3, Text = "Line1 with more long text.\nLine2.\nLine3.\nLine4." };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ // Scroll to the column 17 and set insertion point at the end of line
+ tv.Viewport = tv.Viewport with { X = 17 };
+ tv.InsertionPoint = new Point (26, 0);
+ Assert.Equal (new Point (17, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (26, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+
+ // Clear NeedsDraw to isolate the effect of Ctrl+CursorRight key press
+ tv.ClearNeedsDraw ();
+ Assert.False (tv.NeedsDraw);
+
+ // Press Ctrl+CursorRight - should scroll right to start of next line
+ Assert.True (tv.NewKeyDownEvent (Key.CursorRight.WithCtrl));
+ Assert.Equal (new Point (0, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (0, 1), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+ }
+
+ [Fact]
+ public void CursorRight_With_CtrlKey_Pressed_With_Mixed_Graphemes_Should_Scrolls_Right_To_Make_Cursor_Visible ()
+ {
+ // Test that pressing Ctrl+CursorRight with mixed graphemes scrolls right to make cursor visible
+ TextView tv = new () { Width = 10, Height = 3, Text = "Line1\t with more long 🍎 text.\nLine2.\nLine3.\nLine4." };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ // Set insertion point at column 12 and then scroll to the column 9 so that the insertion point is near at the end of the Viewport
+ tv.InsertionPoint = new Point (12, 0);
+ tv.Viewport = tv.Viewport with { X = 9 };
+ Assert.Equal (new Point (9, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (12, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+
+ // Clear NeedsDraw to isolate the effect of Ctrl+CursorRight key press
+ tv.ClearNeedsDraw ();
+ Assert.False (tv.NeedsDraw);
+
+ // Press Ctrl+CursorRight - should move right and scroll since we are already at the end of the viewport
+ Assert.True (tv.NewKeyDownEvent (Key.CursorRight.WithCtrl));
+ Assert.Equal (new Point (10, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (17, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+ }
+
+ [Fact]
+ public void CursorLeft_With_CtrlKey_Pressed_At_BeforeNearTheStartOfLine_Scrolls_Left_Previous_Word ()
+ {
+ // Test that pressing Ctrl+CursorLeft at neat the start of line scrolls left to previous word
+ TextView tv = new () { Width = 10, Height = 3, Text = "Line1 with more long text.\nLine2.\nLine3.\nLine4." };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ // Scroll to the column 2 and set insertion point at before near the start of line
+ tv.Viewport = tv.Viewport with { X = 2 };
+ tv.InsertionPoint = new Point (4, 0);
+ Assert.Equal (new Point (2, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (4, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+
+ // Clear NeedsDraw to isolate the effect of Ctrl+CursorLeft key press
+ tv.ClearNeedsDraw ();
+ Assert.False (tv.NeedsDraw);
+
+ // Press Ctrl+CursorLeft - should scroll left to previous word
+ Assert.True (tv.NewKeyDownEvent (Key.CursorLeft.WithCtrl));
+ Assert.Equal (new Point (0, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (0, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+ }
+
+ [Fact]
+ public void CursorLeft_With_CtrlKey_Pressed_At_TheStartOfLine_Scrolls_Right_To_EndOfPreviousLine ()
+ {
+ // Test that pressing Ctrl+CursorLeft at the start of line scrolls left to end of previous line
+ TextView tv = new () { Width = 10, Height = 3, Text = "Line1 with more long text.\nLine2.\nLine3.\nLine4." };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ // Scroll to the column 0 and set insertion point at the start of line
+ tv.Viewport = tv.Viewport with { X = 0, Y = 1 };
+ tv.InsertionPoint = new Point (0, 1);
+ Assert.Equal (new Point (0, 1), tv.Viewport.Location);
+ Assert.Equal (new Point (0, 1), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+
+ // Clear NeedsDraw to isolate the effect of Ctrl+CursorLeft key press
+ tv.ClearNeedsDraw ();
+ Assert.False (tv.NeedsDraw);
+
+ // Press Ctrl+CursorLeft - should scroll left to end of previous line
+ Assert.True (tv.NewKeyDownEvent (Key.CursorLeft.WithCtrl));
+ Assert.Equal (new Point (17, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (26, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+ }
+
+ [Fact]
+ public void CursorLeft_With_CtrlKey_Pressed_With_Mixed_Graphemes_Only_Scrolls_Left_When_Needed ()
+ {
+ // Test that pressing Ctrl+CursorLeft with mixed graphemes only scrolls left when needed
+ TextView tv = new () { Width = 10, Height = 3, Text = "Line1\t with more long 🍎 text.\nLine2.\nLine3.\nLine4." };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ // Set insertion point at column 10 and then scroll to the column 8 so that the insertion point is near at the start of the Viewport
+ tv.InsertionPoint = new Point (10, 0);
+ tv.Viewport = tv.Viewport with { X = 8 };
+ Assert.Equal (new Point (8, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (10, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+
+ // Clear NeedsDraw to isolate the effect of Ctrl+CursorLeft key press
+ tv.ClearNeedsDraw ();
+ Assert.False (tv.NeedsDraw);
+
+ // Press Ctrl+CursorLeft - should move left but not scroll since we aren't already near at the start of the Viewport.X
+ Assert.True (tv.NewKeyDownEvent (Key.CursorLeft.WithCtrl));
+ Assert.Equal (new Point (8, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (7, 0), tv.InsertionPoint);
+ Assert.False (tv.NeedsDraw);
+ }
+
+ [Fact]
+ public void CursorLeft_With_Mixed_Graphemes_Only_Scrolls_Left_When_Needed ()
+ {
+ // Test that pressing CursorLeft with mixed graphemes only scrolls left when needed
+ TextView tv = new () { Width = 10, Height = 3, Text = "Line1\t with more long 🍎 text.\nLine2.\nLine3.\nLine4." };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ // Set insertion point at column 10 and then scroll to the column 10 so that the insertion point is near at the start of the Viewport
+ tv.InsertionPoint = new Point (10, 0);
+ tv.Viewport = tv.Viewport with { X = 10 };
+ Assert.Equal (new Point (10, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (10, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+
+ // Clear NeedsDraw to isolate the effect of CursorLeft key press
+ tv.ClearNeedsDraw ();
+ Assert.False (tv.NeedsDraw);
+
+ // Press CursorLeft - should move left but not scroll since we aren't already near at the start of the Viewport.X
+ Assert.True (tv.NewKeyDownEvent (Key.CursorLeft));
+ Assert.Equal (new Point (10, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (9, 0), tv.InsertionPoint);
+ Assert.False (tv.NeedsDraw);
+ }
+
+ [Fact]
+ public void CursorRight_At_Text_Hidden_By_Scroll_Move_Cursor_Adjusts_Scroll_To_Make_Cursor_Visible ()
+ {
+ // Test that pressing CursorRight at text hidden by scroll moves cursor and adjusts scroll to make cursor visible
+ TextView tv = new () { Width = 10, Height = 3, Text = "Line1 with more long text.\nLine2.\nLine3.\nLine4." };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ // Scroll to the column 10 and insertion point stays at the column 0
+ tv.Viewport = tv.Viewport with { X = 10 };
+ Assert.Equal (new Point (10, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (0, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+
+ // Clear NeedsDraw to isolate the effect of CursorRight key press
+ tv.ClearNeedsDraw ();
+ Assert.False (tv.NeedsDraw);
+
+ // Press CursorRight - should move cursor right and scroll to make it visible
+ Assert.True (tv.NewKeyDownEvent (Key.CursorRight));
+ Assert.Equal (new Point (0, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (1, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+ }
+
+ [Fact]
+ public void CursorLeft_At_Text_Hidden_By_Scroll_Move_Cursor_Adjusts_Scroll_To_Make_Cursor_Visible ()
+ {
+ // Test that pressing CursorLeft at text hidden by scroll moves cursor and adjusts scroll to make cursor visible
+ TextView tv = new () { Width = 10, Height = 3, Text = "Line1 with more long text.\nLine2.\nLine3.\nLine4." };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ // Set insertion point at the last column and then scroll to the column 0
+ tv.InsertionPoint = new Point (26, 0);
+ tv.Viewport = tv.Viewport with { X = 0 };
+ Assert.Equal (new Point (0, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (26, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+
+ // Clear NeedsDraw to isolate the effect of CursorLeft key press
+ tv.ClearNeedsDraw ();
+ Assert.False (tv.NeedsDraw);
+
+ // Press CursorLeft - should move cursor left and scroll to make it visible
+ Assert.True (tv.NewKeyDownEvent (Key.CursorLeft));
+ Assert.Equal (new Point (16, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (25, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+ }
+
+ [Fact]
+ public void CursorDown_At_Text_Hidden_By_Scroll_Move_Cursor_Adjusts_Scroll_To_Make_Cursor_Visible ()
+ {
+ // Test that pressing CursorDown at text hidden by scroll moves cursor and adjusts scroll to make cursor visible
+ TextView tv = new () { Width = 10, Height = 3, Text = "Line1.\nLine2.\nLine3.\nLine4.\nLine5." };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ // Scroll to the line 2 and insertion point stays at the line 0
+ tv.Viewport = tv.Viewport with { Y = 2 };
+ Assert.Equal (new Point (0, 2), tv.Viewport.Location);
+ Assert.Equal (new Point (0, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+
+ // Clear NeedsDraw to isolate the effect of CursorDown key press
+ tv.ClearNeedsDraw ();
+ Assert.False (tv.NeedsDraw);
+
+ // Press CursorDown - should move cursor down and scroll to make it visible
+ Assert.True (tv.NewKeyDownEvent (Key.CursorDown));
+ Assert.Equal (new Point (0, 1), tv.Viewport.Location);
+ Assert.Equal (new Point (0, 1), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+ }
+
+ [Fact]
+ public void CursorUp_At_Text_Hidden_By_Scroll_Move_Cursor_Adjusts_Scroll_To_Make_Cursor_Visible ()
+ {
+ // Test that pressing CursorUp at text hidden by scroll moves cursor and adjusts scroll to make cursor visible
+ TextView tv = new () { Width = 10, Height = 3, Text = "Line1.\nLine2.\nLine3.\nLine4.\nLine5." };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ // Set insertion point at the line 4 and then scroll to the line 0
+ tv.InsertionPoint = new Point (0, 4);
+ tv.Viewport = tv.Viewport with { Y = 0 };
+ Assert.Equal (new Point (0, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (0, 4), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+
+ // Clear NeedsDraw to isolate the effect of CursorUp key press
+ tv.ClearNeedsDraw ();
+ Assert.False (tv.NeedsDraw);
+
+ // Press CursorUp - should move cursor up and scroll to make it visible
+ Assert.True (tv.NewKeyDownEvent (Key.CursorUp));
+ Assert.Equal (new Point (0, 1), tv.Viewport.Location);
+ Assert.Equal (new Point (0, 3), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+ }
+
+ [Fact]
+ public void CursorUp_At_Text_Hidden_By_Scroll_OnlyOneLineBelow_Move_Cursor_ButDoesNotNeeded_Adjusts_Scroll_To_Make_Cursor_Visible ()
+ {
+ // Test that pressing CursorUp at text hidden by scroll moves cursor but does not adjust scroll if the text is only one line below the scroll
+ TextView tv = new () { Width = 10, Height = 3, Text = "Line1.\nLine2.\nLine3." };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ // Set insertion point at the line 2 and then scroll to the line 0
+ tv.InsertionPoint = new Point (0, 2);
+ tv.Viewport = tv.Viewport with { Y = 0 };
+ Assert.Equal (new Point (0, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (0, 2), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+
+ // Clear NeedsDraw to isolate the effect of CursorUp key press
+ tv.ClearNeedsDraw ();
+ Assert.False (tv.NeedsDraw);
+
+ // Press CursorUp - should move cursor up but does not adjust scroll since the line 1 is still visible
+ Assert.True (tv.NewKeyDownEvent (Key.CursorUp));
+ Assert.Equal (new Point (0, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (0, 1), tv.InsertionPoint);
+ Assert.False (tv.NeedsDraw);
+ }
+
+ [Fact]
+ public void CursorUp_At_Text_Hidden_By_Scroll_OnTheFirstLineAndColumn_DoesNotMove_Cursor_And_Adjusts_Scroll_To_Make_Cursor_Visible ()
+ {
+ // Test that pressing CursorUp at text hidden by scroll on the first line and column does not move cursor but adjusts scroll to make it visible
+ TextView tv = new () { Width = 10, Height = 3, Text = "Line1.\nLine2.\nLine3." };
+ tv.BeginInit ();
+ tv.EndInit ();
+
+ // Set insertion point at the line 0 and column 0 and then scroll to the line 1
+ tv.InsertionPoint = new Point (0, 0);
+ tv.Viewport = tv.Viewport with { Y = 1 };
+ Assert.Equal (new Point (0, 1), tv.Viewport.Location);
+ Assert.Equal (new Point (0, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+
+ // Clear NeedsDraw to isolate the effect of CursorUp key press
+ tv.ClearNeedsDraw ();
+ Assert.False (tv.NeedsDraw);
+
+ // Press CursorUp - should not move cursor since it's already at the top but should adjust scroll to make it visible
+ Assert.True (tv.NewKeyDownEvent (Key.CursorUp));
+ Assert.Equal (new Point (0, 0), tv.Viewport.Location);
+ Assert.Equal (new Point (0, 0), tv.InsertionPoint);
+ Assert.True (tv.NeedsDraw);
+ }
+}
\ No newline at end of file
diff --git a/Tests/UnitTestsParallelizable/Views/TextViewPerformanceTests.cs b/Tests/UnitTestsParallelizable/Views/TextViewPerformanceTests.cs
index f7ca40b707..fd8fa857e2 100644
--- a/Tests/UnitTestsParallelizable/Views/TextViewPerformanceTests.cs
+++ b/Tests/UnitTestsParallelizable/Views/TextViewPerformanceTests.cs
@@ -354,5 +354,29 @@ public void ContentSize_Width_Updates_After_Content_Change_Then_Caches ()
Assert.Equal (size3.Width, size4.Width);
}
+ [Fact]
+ public void ContentSize_Width_Updates_Correctly_When_Text_Is_Inserted_At_Middle_Of_Longest_Line_Before_AdjustScroll ()
+ {
+ TextView tv = new () { Width = 80, Height = 10, Text = "Short line\nThis is the longest line in the document\nMedium line" };
+ tv.BeginInit ();
+ tv.EndInit ();
+ tv.LayoutSubViews ();
+
+ Size initialSize = tv.GetContentSize ();
+
+ tv.ContentsChanged += (_, _) =>
+ {
+ Size newSize = tv.GetContentSize ();
+ Assert.True (newSize.Width > initialSize.Width, $"Content width should not decrease after mutation. Initial: {initialSize.Width}, New: {newSize.Width}");
+ };
+
+ // Insert text in the middle of the longest line to make it even longer
+ tv.InsertionPoint = new Point (10, 1); // Position cursor in the middle of the longest line
+ tv.NewKeyDownEvent (Key.A); // Simulate typing 'A' to increase line length
+
+ Size afterInsertSize = tv.GetContentSize ();
+ Assert.True (afterInsertSize.Width > initialSize.Width, $"Width should increase after inserting longer text. Was {initialSize.Width}, now {afterInsertSize.Width}");
+ }
+
#endregion
}
diff --git a/Tests/UnitTestsParallelizable/Views/TextViewScrollingTests.cs b/Tests/UnitTestsParallelizable/Views/TextViewScrollingTests.cs
index 90cd82abb1..a52bdc6e85 100644
--- a/Tests/UnitTestsParallelizable/Views/TextViewScrollingTests.cs
+++ b/Tests/UnitTestsParallelizable/Views/TextViewScrollingTests.cs
@@ -203,6 +203,34 @@ public void Viewport_Change_Updates_ScrollBar_Position ()
Assert.Equal (3, tv.VerticalScrollBar.Value);
}
+ ///
+ /// Tests that setting ReadOnly to true does not change viewport position but does set NeedsDraw.
+ /// >
+ [Fact]
+ public void ReadOnly_Set_True_Keeps_ViewportX_And_Sets_NeedDraw ()
+ {
+ TextView tv = new ()
+ {
+ Width = 20,
+ Height = 5,
+ ScrollBars = true,
+ WordWrap = false,
+ Text = "Short line"
+ };
+ tv.BeginInit ();
+ tv.EndInit ();
+ tv.LayoutSubViews ();
+ tv.ClearNeedsDraw ();
+
+ Rectangle initialViewport = tv.Viewport;
+
+ tv.ReadOnly = true;
+
+ Assert.Equal (initialViewport.X, tv.Viewport.X);
+ Assert.Equal (initialViewport.Y, tv.Viewport.Y);
+ Assert.True (tv.NeedsDraw);
+ }
+
///
/// Tests that horizontal scrollbar becomes visible when line length exceeds width (WordWrap=false).
/// BUG: Same as vertical - visibility not updated when Text changes.
diff --git a/docfx/images/sample.gif b/docfx/images/sample.gif
index 813d515dfd..222d8cae01 100644
Binary files a/docfx/images/sample.gif and b/docfx/images/sample.gif differ
diff --git a/docfx/images/uicatalog.gif b/docfx/images/uicatalog.gif
new file mode 100644
index 0000000000..c91292922d
Binary files /dev/null and b/docfx/images/uicatalog.gif differ