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. -![screenshot](screenshot.png) - -## 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. +![uicatalog](../../docfx/images/uicatalog.gif) ## 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. -![screenshot](generic_screenshot.png) +## 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