diff --git a/CHANGELOG.md b/CHANGELOG.md index a8755a33c..051c68524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.7.0-beta.1 - Unreleased + +### Added + +- Directed split planning for Top, Bottom, Left, and Right relative to the focused pane. +- Compact pane-header, drag-handle, capability-aware pane-menu, and Snap-layout presentation models. +- Transaction-ready Move Pane to New Tab and Move Pane to New Window plans that retain the existing pane and session identity. +- Keyboard pane-move target cycling, accessible announcements, and configurable pane-control settings. +- Unit and source-boundary coverage for directed splits, pane menus, pane headers, handle input isolation, drag previews, moves, and keyboard movement. + +### Changed + +- Development metadata now identifies `0.7.0-beta.1`; the numeric MSIX version is `0.7.0.1`. +- Same-tab pane dragging now uses the existing `LayoutTransformer` to remove, normalize, and reinsert the pane relative to the selected target. +- Pane movement previews are derived from the proposed layout and must validate before a drop can be requested. + +### Release status + +- The existing Stable workflow remains isolated from this Beta tag. +- No `v0.7.0-beta.1` tag or Beta Release is created until the v0.7 acceptance report is complete. +- No new Stable Release is produced from this development branch. + ## 1.0.2 - Stable ### Fixed diff --git a/custom.props b/custom.props index 65fb6920c..f7c56c4da 100644 --- a/custom.props +++ b/custom.props @@ -4,8 +4,8 @@ true 2026 - 1 - 0 + 0 + 7 winTerm Windows Terminal 1033 diff --git a/docs/accessibility.md b/docs/accessibility.md new file mode 100644 index 000000000..6984f2cd6 --- /dev/null +++ b/docs/accessibility.md @@ -0,0 +1,18 @@ +# Pane Control Accessibility + +Pane controls expose a named pane header, a Button-role drag grip, focused state, visible status text, context-menu action, drag action, and keyboard alternatives. + +Examples: + +- `Move PowerShell pane` +- `Open pane menu` +- `Split pane above` +- `Split pane below` +- `Split pane to the left` +- `Split pane to the right` + +Internal pane IDs, session pointers, and Layout node IDs are never included in announcements. + +Keyboard move mode announces the source, selected target and zone, and commit/cancel instructions. For example: `Moving PowerShell pane. Target: top of Command Prompt pane. Press Enter to move. Press Escape to cancel.` + +High Contrast presentation uses system colors and retains labels/borders. Focus, running, failed-command, and read-only states are not conveyed only by color. diff --git a/docs/directed-split.md b/docs/directed-split.md new file mode 100644 index 000000000..ab3db3059 --- /dev/null +++ b/docs/directed-split.md @@ -0,0 +1,33 @@ +# Directed Split + +winTerm 0.7 replaces an implicit universal split direction with four explicit choices: + +| Command | Result relative to the focused pane | +|---|---| +| Split Pane Top | The new pane is placed above the focused pane. | +| Split Pane Bottom | The new pane is placed below the focused pane. | +| Split Pane Left | The new pane is placed to the left of the focused pane. | +| Split Pane Right | The new pane is placed to the right of the focused pane. | + +The operation targets the currently focused pane node, including when that pane is nested. It never splits the complete tab root unless the root is itself the focused pane. + +## Planning and commit + +`DirectedSplitAction` validates the requested edge, focused-pane identity, profile selection, DPI, minimum dimensions, and final geometry before a shell is launched. It then asks the existing `LayoutTransformer` for the proposed tree: + +- Top: horizontal split, new pane first. +- Bottom: horizontal split, focused pane first. +- Left: vertical split, new pane first. +- Right: vertical split, focused pane first. + +The default ratio is 0.5. A valid plan is committed through a layout transaction and recorded in layout history. A rejected plan leaves the current layout unchanged and does not create a shell session. + +## Profiles + +Every direction retains profile selection. The pane-control setting can use the current default profile, the current pane profile, or require an explicit selection. The Ask Every Time mode cannot produce a split plan until a profile is selected. + +The existing Terminal action schema continues to represent the four directions with `SplitDirection::Up`, `Down`, `Left`, and `Right`. Configurable keybindings and Command Palette entries use the same action path. + +## Focus + +By default, focus moves to the new pane after a successful split. The Pane Controls setting can retain focus on the existing pane. diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md new file mode 100644 index 000000000..8e590f183 --- /dev/null +++ b/docs/keyboard-shortcuts.md @@ -0,0 +1,19 @@ +# Keyboard Pane Commands + +winTerm exposes configurable command IDs without assigning unconditional default shortcuts: + +| Command ID | Command | +|---|---| +| `splitPaneTop` | Split Pane Top | +| `splitPaneBottom` | Split Pane Bottom | +| `splitPaneLeft` | Split Pane Left | +| `splitPaneRight` | Split Pane Right | +| `movePaneToNewTab` | Move Pane to New Tab | +| `movePaneToNewWindow` | Move Pane to New Window | +| `closeFocusedPane` | Close Focused Pane | +| `startPaneMoveMode` | Start Pane Move Mode | +| `openPaneMenu` | Open Pane Menu | + +Keyboard move mode uses Arrow keys to select a zone, Tab/Shift+Tab to change targets, Enter to request the move, and Escape to cancel. It remains available when pane headers are hidden. + +The existing Terminal action schema is retained for serialized split and move actions. No existing user keybinding is overwritten. diff --git a/docs/pane-context-menu.md b/docs/pane-context-menu.md new file mode 100644 index 000000000..e12417859 --- /dev/null +++ b/docs/pane-context-menu.md @@ -0,0 +1,37 @@ +# Pane Context Menu + +Right-clicking a pane handle or pressing the overflow button opens the same command model: + +```text +Pane +├── Split +│ ├── Top +│ ├── Bottom +│ ├── Left +│ └── Right +├── Move +│ ├── Move to New Tab +│ └── Move to New Window +├── Close Pane +└── More + ├── Focus Pane + ├── Zoom Pane + └── Pane Settings +``` + +The menu uses explicit labels. It does not use ambiguous terms such as Pop or Release. + +## Capability state + +- Move to New Tab is disabled when the pane is already the only pane in the tab. +- Move to New Window is disabled when the current window host cannot reattach a live pane in the same process. +- Split directions are disabled when the focused pane is too small for the requested axis. +- Close and layout-changing commands are disabled while a transaction protects the pane. + +Every disabled command exposes a reason as tooltip/secondary text and in its accessible name. + +## Safety + +Move operations retain the existing pane node and session ownership. The source is removed only as part of a validated transaction after the target is prepared. Close Pane delegates to the existing safe close path and does not bypass close confirmation. + +Terminal-content right-click remains governed by terminal copy/paste settings. Pane-header right-click always requests the pane menu. diff --git a/docs/pane-controls.md b/docs/pane-controls.md new file mode 100644 index 000000000..171534a12 --- /dev/null +++ b/docs/pane-controls.md @@ -0,0 +1,45 @@ +# Pane Controls + +winTerm 0.7 defines a compact pane header for every pane in a split layout. The header is outside the terminal buffer, so its pointer input is never sent to the shell. + +## Visibility + +Pane header visibility supports: + +- Automatic: hidden for a one-pane tab and shown for every pane when the tab has two or more panes. +- Always: shown even for a one-pane tab. +- Never: hidden without removing Command Palette, keyboard move mode, or the pane-menu shortcut. + +The supported logical height is 24–30 pixels; the default is 26. Showing or hiding a header changes the space offered to the terminal control, which must trigger the normal row and column recalculation. + +## Header content + +The compact header contains a drag grip, a pane title, focus/status text exposed to accessibility, and an optional overflow button. It does not contain a large toolbar. + +Title precedence is: + +1. User-defined pane title. +2. Shell-reported title. +3. Profile name. +4. Shell type. + +Absolute paths are reduced to their final component before display. Long titles are ellipsized while the accessible pane name remains meaningful. + +Focused, running, failed-command, and read-only states have text or automation state and do not rely on color alone. + +## Settings + +Pane Controls are part of Docking and Layout settings: + +- Show Pane Headers +- Pane Header Height +- Show Pane Title +- Show Profile Icon +- Show Overflow Button +- Enable Pane Handle Dragging +- Show Snap Layout Overlay +- Enable Corner Zones +- After Split +- Split Profile + +These are view/profile settings. They do not add fields to the Workspace schema. diff --git a/docs/pane-handle-dragging.md b/docs/pane-handle-dragging.md new file mode 100644 index 000000000..fb7ddd131 --- /dev/null +++ b/docs/pane-handle-dragging.md @@ -0,0 +1,30 @@ +# Pane Handle Dragging + +Pane docking starts only from the explicit drag grip in a pane header. Pointer movement in terminal content, the scrollbar, or an overflow button cannot start a pane drag. This preserves selection, mouse reporting, Vim/tmux interaction, application mouse input, and right-click copy/paste behavior. + +## Drag lifecycle + +The pane-handle source reuses the v0.5 docking components: + +1. `DragThreshold` validates mouse or touch movement. +2. `DragPayloadRegistry` creates an opaque, expiring, single-use token. +3. `DockDragStateMachine` tracks drag, target, drop, commit, and cancellation. +4. `LayoutTransformer.BuildProposedLayout()` produces the proposed layout. +5. `DockPreview` derives preview geometry from that proposed tree. +6. `LayoutTransactionCoordinator` reserves live session ownership and commits or rolls back. + +Hovering does not mutate the runtime layout, launch a shell, transfer a session, save a Workspace, write a file, or change focus. + +## Zones + +Top, Bottom, Left, and Right are always the core zones. Corner zones appear only when enabled and when the target is large enough. Center means move as a new tab; it never silently replaces or closes an existing pane. An Empty Slot is filled without adding a split. + +Same-tab movement removes the source pane, normalizes the tree, and reinserts the same pane node around the target. It does not duplicate the source pane. + +Dropping on the tab strip creates a standalone tab. Dropping outside the window requests a new same-process window. Cross-process live pane transfer is disabled with an explanation because the terminal session must not be restarted to imitate a move. + +## Cancellation and failure + +Escape, pointer-capture loss, invalid targets, closed source/target objects, display changes, expired tokens, and commit failure cancel or roll back the operation. The source remains in its original layout unless the complete target transaction succeeds. + +Mixed-DPI adapters must express pointer and target rectangles in the target window's coordinate space before building the overlay or preview. diff --git a/docs/user/accessibility.md b/docs/user/accessibility.md index 01947f879..4d84747ee 100644 --- a/docs/user/accessibility.md +++ b/docs/user/accessibility.md @@ -1,3 +1,7 @@ # Accessibility -Core flows must have a keyboard path and accessible labels. The v1.0 manual audit for Narrator, high contrast, scaling, and pseudo-localization remains open; see the [accessibility audit](../accessibility-audit-v1.0.md) before relying on an unverified path. +Core flows have a keyboard path and accessible labels. Pane handles are announced as **Move _title_ pane**, overflow buttons as **Open pane menu**, and directed split commands describe above, below, left, or right. + +Pane focus and running, failure, and read-only status are not represented only by color. Keyboard move mode announces the source, target, zone, Enter action, and Escape cancellation. + +The runtime Narrator, High Contrast, scaling, and pseudo-localization audit remains open; see the [v0.7 acceptance report](../v0.7-acceptance.md) before relying on an unverified path. diff --git a/docs/user/getting-started.md b/docs/user/getting-started.md index 64a235630..c2ca265b4 100644 --- a/docs/user/getting-started.md +++ b/docs/user/getting-started.md @@ -1,3 +1,9 @@ # Getting started -Launch winTerm, choose an available shell profile, open a tab, and split a pane using configured keybindings or the command palette. Themes, fonts, Workspaces, and safe shell helpers are described in the linked guides. Visual Docking runtime is disabled in the current beta preparation branch. +Launch winTerm, choose an available shell profile, and open a tab. + +To split the focused pane, choose **Split Pane** and then **Top**, **Bottom**, **Left**, or **Right**. The selected profile opens on the chosen side of the focused pane, not around the complete tab. + +In a split layout, each pane can show a compact header with a drag grip, title, focus/status indication, and overflow menu. Right-click the handle or select the overflow button for Split, Move to New Tab, Move to New Window, Close Pane, focus, zoom, and settings commands. Unsupported live-window moves are disabled rather than restarting the shell. + +Keyboard and Command Palette alternatives remain available when pane headers are hidden. Visual Docking runtime and live session preservation still require the evidence listed in the [v0.7 acceptance report](../v0.7-acceptance.md). diff --git a/docs/user/keyboard-shortcuts.md b/docs/user/keyboard-shortcuts.md index f6a3ca344..b6154eaa5 100644 --- a/docs/user/keyboard-shortcuts.md +++ b/docs/user/keyboard-shortcuts.md @@ -1,3 +1,7 @@ # Keyboard shortcuts Use the existing configurable Windows Terminal keybinding system. winTerm does not invent default Docking shortcuts; unassigned commands remain unassigned until the user binds them. Verify bindings in Settings or the command palette. + +Pane commands include Split Pane Top/Bottom/Left/Right, Move Pane to New Tab, Move Pane to New Window, Close Focused Pane, Start Pane Move Mode, and Open Pane Menu. See the [command ID reference](../keyboard-shortcuts.md). + +In pane move mode, use Arrow keys to select a zone, Tab or Shift+Tab to change the target, Enter to commit, and Escape to cancel. diff --git a/docs/user/visual-docking.md b/docs/user/visual-docking.md index 729104b25..1622210f5 100644 --- a/docs/user/visual-docking.md +++ b/docs/user/visual-docking.md @@ -1,3 +1,11 @@ # Visual Docking -The beta model includes edge zones, corner zones, empty slots, undo/redo, rollback planning, and keyboard descriptions. Live Visual Docking is disabled by default pending Windows runtime validation. Cross-process pane transfer is unsupported. +Start pane docking from the grip in the pane header. Dragging terminal text, the scrollbar, or the overflow button does not move a pane. + +The Snap-like overlay offers Top, Bottom, Left, and Right. Corner zones appear when enabled and when the target is large enough. Center over tab content or the tab strip means **Move to New Tab**; it never replaces a live pane. Empty Slots can be filled directly. + +The preview comes from the same proposed layout used by commit. Escape or pointer-capture loss cancels without changing the layout. A failed commit rolls back the layout and session ownership. + +Keyboard move mode uses Arrow keys for zones, Tab/Shift+Tab for targets, Enter to move, and Escape to cancel. + +Live same-process pane transfer must preserve the Shell PID, ConPTY session, buffer, running command, alternate screen, and working-directory metadata. Cross-process live transfer is unsupported and is shown as disabled. Runtime Visual Docking remains gated by the [v0.7 acceptance report](../v0.7-acceptance.md). diff --git a/docs/v0.7-acceptance.md b/docs/v0.7-acceptance.md new file mode 100644 index 000000000..b4e5a8779 --- /dev/null +++ b/docs/v0.7-acceptance.md @@ -0,0 +1,71 @@ +# winTerm 0.7 acceptance report + +Version: `0.7.0-beta.1` +Feature: Directed Split & Pane Controls +Status: **In progress — do not tag or publish** + +This report distinguishes implemented source boundaries from compiled, runtime, and manual verification. An unchecked item must not be described as passed. + +## Implemented source boundaries + +- [x] Four explicit split directions map to the correct child order and orientation. +- [x] Directed split targets the focused pane node in nested layouts. +- [x] Profile policy and explicit profile selection are retained. +- [x] Invalid directions and insufficient dimensions reject the plan before shell launch. +- [x] Pane-header Automatic, Always, and Never presentation rules are modeled. +- [x] Pane title precedence, path privacy, focus, running, failed, and read-only accessibility text are modeled. +- [x] Right-click and overflow menu invocations use one capability-aware command model. +- [x] Pane drag can start only from the explicit grip and observes a movement threshold. +- [x] Same-tab drag uses remove/normalize/reinsert semantics in the existing transformer. +- [x] Snap overlay and preview reuse the existing v0.5 models. +- [x] Preview derives from the proposed layout and invalid preview cannot request drop. +- [x] Move-to-new-tab/window plans retain the existing pane node and session identity. +- [x] Unsupported cross-process transfer is disabled with a reason. +- [x] Keyboard move mode supports arrows, Tab/Shift+Tab, Enter, Escape, and announcements. +- [x] Workspace and Docking schema versions remain unchanged. + +## Build and automated execution evidence + +- [x] Debug x64 build. +- [x] Release x64 build. +- [x] Settings Model compiled unit tests. +- [x] Terminal App compiled unit tests. +- [x] Control compiled unit tests. +- [x] Unsigned Release x64 MSIX generated and inspected. +- [ ] Full regression suite. + +Local build and automated validation completed on 2026-07-20: + +- The repository-recommended PowerShell 7, Visual Studio 2022 C++/UWP, MSBuild, Windows SDK, XAML, MSIX, MakeAppx, and SignTool environment was installed and selected by the build module. +- `build.ps1 -Configuration Debug -Platform x64 -IncludeTests` passed. +- `build.ps1 -Configuration Release -Platform x64 -IncludeTests` passed. +- The Debug and Release Relevant suites each passed 203 Settings Model, 51 Terminal App, and 30 Control tests with no failures, blocked tests, skipped tests, or tests not run. +- `test-pane-controls.ps1 -SourceOnly` passed as part of both Relevant suites. +- The layout-transformation, docking-transaction, layout-history, session-transfer, and docking-presentation source checks passed. +- The local UI automation manifest and privacy boundary validation passed; runtime UI automation was not executed. +- A local unsigned `0.7.0.1` x64 MSIX was generated. Its `HelloThisWorld.winTerm` identity, `CN=winTerm Development` publisher, `winterm.exe` alias, absence of `wt.exe`, and terminal-host product metadata passed package-output inspection. +- A self-signed development copy of the candidate passed signature and checksum verification. Installation was not used as runtime evidence because Windows correctly refused to replace an installed `1.0.2.0` package with the lower `0.7.0.1` package version; the temporary test-certificate trust was removed. +- The Release Full suite was attempted, but the runner stopped before the full test set could execute because `Conhost.Unit.Tests.dll` was not built by the current `-IncludeTests` path. Full regression therefore remains unchecked. +- Directly launching the unpackaged Release host is not an MSIX runtime substitute; it exited with `0xC0000409` outside package dependency context and is not counted as a runtime acceptance result. +- Version consistency, branding, Release Smoke, PowerShell syntax, JSON, XML, and `git diff --check` passed. +- The inspected MSIX is an unsigned local candidate only. It is not a release asset and was not installed or published. + +## Runtime and manual evidence + +- [ ] Pane headers are rendered in the running application. +- [ ] Terminal rows/columns recalculate without partial rows. +- [ ] Handle right-click and overflow open the running pane menu. +- [ ] Handle drag displays the running Snap overlay. +- [ ] Top/Bottom/Left/Right drop commits the previewed layout. +- [ ] Tab-strip drop creates a live standalone tab. +- [ ] Outside-window drop creates a live same-process window. +- [ ] Shell PID, ConPTY session, buffer, command, alternate screen, and CWD metadata remain unchanged. +- [ ] Failure rollback and Undo/Redo preserve the live session. +- [ ] Workspace restore recreates the resulting layout. +- [ ] 100%, 125%, 150%, 200%, mixed-DPI, negative-coordinate, and portrait-monitor tests. +- [ ] Narrator and High Contrast audit. +- [ ] 20/40-pane, 100-split, 500-drag, large-output, latency, and leak checks. + +## Release gate + +Do not create `v0.7.0-beta.1`, do not publish a Beta Release, and do not resume any Stable Release process until every required build/runtime/manual item above has evidence and no relevant P0/P1 issue remains. diff --git a/docs/visual-docking.md b/docs/visual-docking.md new file mode 100644 index 000000000..2b97a17b4 --- /dev/null +++ b/docs/visual-docking.md @@ -0,0 +1,9 @@ +# Visual Docking + +winTerm visual docking uses one layout model, transformer, preview path, transaction coordinator, session-ownership registry, and history implementation for tabs and panes. + +Pane Handle dragging in 0.7 adds an explicit pane drag source and a Snap-like 3×3 overlay presentation. The mandatory edge zones are Top, Bottom, Left, and Right. Optional corners use existing Empty Slot nodes. Center creates a new tab or fills an explicitly selected Empty Slot; it never replaces a live target pane. + +Preview geometry is calculated from the exact proposed layout returned by `LayoutTransformer.BuildProposedLayout()`. The commit receives the same plan. A preview that fails geometry validation cannot request a drop. + +Same-process session reattachment is the only supported live cross-window path. Cross-process pane transfer remains disabled. See [Pane Handle Dragging](pane-handle-dragging.md) and [the v0.5 architecture](visual-docking-architecture.md). diff --git a/scripts/winterm/package-shell-assets.ps1 b/scripts/winterm/package-shell-assets.ps1 index aed5db532..c7f0cd28a 100644 --- a/scripts/winterm/package-shell-assets.ps1 +++ b/scripts/winterm/package-shell-assets.ps1 @@ -32,7 +32,10 @@ foreach ($relativePath in $sourceAssets) } $version = Get-Content -LiteralPath (Join-Path $repositoryRoot 'shell\shared\version.json') -Raw | ConvertFrom-Json -if ($version.moduleVersion -ne '1.0.2' -or $version.applicationVersion -ne '1.0.2' -or $version.protocolVersion -ne 1) +if ($version.moduleVersion -ne '0.7.0' -or + $version.modulePrerelease -ne 'beta.1' -or + $version.applicationVersion -ne '0.7.0-beta.1' -or + $version.protocolVersion -ne 1) { throw 'The winTerm Shell asset version metadata is invalid.' } diff --git a/scripts/winterm/package.ps1 b/scripts/winterm/package.ps1 index 05ec9dabd..0869b71d3 100644 --- a/scripts/winterm/package.ps1 +++ b/scripts/winterm/package.ps1 @@ -22,10 +22,13 @@ function Assert-WinTermManifest { param( [Parameter(Mandatory)] - [string]$Path, + [string]$Path, + + [Parameter(Mandatory)] + [string]$Publisher, [Parameter(Mandatory)] - [string]$Publisher + [string]$Version ) [xml]$manifest = Get-Content -LiteralPath $Path -Raw @@ -48,9 +51,9 @@ function Assert-WinTermManifest { throw "Package '$Path' must claim winterm.exe and must not claim wt.exe." } - if ($identity.Version -ne '1.0.2.0') + if ($identity.Version -ne $Version) { - throw "Package '$Path' must use the winTerm 1.0.2.0 package version." + throw "Package '$Path' must use the winTerm $Version package version." } } @@ -135,11 +138,19 @@ function Assert-AppearancePayload $repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $sourceManifest = Join-Path $repositoryRoot 'src\cascadia\CascadiaPackage\Package-winTerm.appxmanifest' $packageRoot = Join-Path $repositoryRoot 'src\cascadia\CascadiaPackage\AppPackages' +$versionPath = Join-Path $repositoryRoot 'src\winterm\Branding\version.json' $temporaryDirectory = $null try { - Assert-WinTermManifest -Path $sourceManifest -Publisher $ExpectedPublisher + $versionMetadata = Get-Content -LiteralPath $versionPath -Raw | ConvertFrom-Json + $expectedVersion = [string]$versionMetadata.packageVersion + if ([string]::IsNullOrWhiteSpace($expectedVersion)) + { + throw "Version metadata '$versionPath' does not define packageVersion." + } + + Assert-WinTermManifest -Path $sourceManifest -Publisher $ExpectedPublisher -Version $expectedVersion if (-not $SkipBuild) { @@ -179,7 +190,7 @@ try throw "makeappx.exe failed to unpack '$($artifact.FullName)' with exit code $LASTEXITCODE." } - Assert-WinTermManifest -Path (Join-Path $temporaryDirectory 'AppxManifest.xml') -Publisher $ExpectedPublisher + Assert-WinTermManifest -Path (Join-Path $temporaryDirectory 'AppxManifest.xml') -Publisher $ExpectedPublisher -Version $expectedVersion Assert-AppearancePayload -Path $temporaryDirectory -RepositoryRoot $repositoryRoot & (Join-Path $PSScriptRoot 'package-shell-assets.ps1') -PackageRoot $temporaryDirectory if (-not $?) diff --git a/scripts/winterm/test-docking-ui.ps1 b/scripts/winterm/test-docking-ui.ps1 index a450ee8f7..c8346ccab 100644 --- a/scripts/winterm/test-docking-ui.ps1 +++ b/scripts/winterm/test-docking-ui.ps1 @@ -31,10 +31,35 @@ try 'drag-tab-within-window', 'drag-tab-to-another-window', 'drag-tab-to-edge', + 'split-pane-top', + 'split-pane-bottom', + 'split-pane-left', + 'split-pane-right', + 'split-focused-nested-pane', + 'split-with-selected-profile', + 'pane-header-automatic-visibility', + 'pane-header-overflow-menu', + 'pane-handle-context-menu', + 'pane-handle-drag-top', + 'pane-handle-drag-bottom', + 'pane-handle-drag-left', + 'pane-handle-drag-right', + 'pane-handle-drag-corner', + 'pane-handle-drag-tab-strip', + 'pane-handle-drag-new-window', 'drag-pane-to-empty-slot', + 'terminal-content-drag-does-not-move-pane', + 'move-pane-to-new-tab', + 'move-pane-to-new-window', + 'close-pane-preserves-confirmation', 'cancel-with-escape', + 'cancel-on-pointer-capture-loss', 'keyboard-docking', - 'undo-redo' + 'undo-redo', + 'workspace-restore', + 'mixed-dpi-pane-drag', + 'narrator-pane-controls', + 'high-contrast-pane-controls' ) foreach ($scenario in $expected) { @@ -76,7 +101,7 @@ try return } - throw 'The v0.5 XAML docking adapter is feature-disabled; runtime UI automation cannot run yet.' + throw 'The v0.7 runtime adapter has not passed the local UI automation gate yet.' } catch { diff --git a/scripts/winterm/test-pane-controls.ps1 b/scripts/winterm/test-pane-controls.ps1 new file mode 100644 index 000000000..94a36a5cc --- /dev/null +++ b/scripts/winterm/test-pane-controls.ps1 @@ -0,0 +1,210 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +[CmdletBinding()] +param( + [Parameter()] + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Debug', + + [Parameter()] + [ValidateSet('x64')] + [string]$Platform = 'x64', + + [Parameter()] + [switch]$RequireCompiled, + + [Parameter()] + [switch]$SourceOnly +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +try +{ + $root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + $requiredFiles = @( + 'src\winterm\Actions\DirectedSplitAction.cpp', + 'src\winterm\Actions\MovePaneAction.cpp', + 'src\winterm\PaneControls\PaneHeader.cpp', + 'src\winterm\PaneControls\PaneHandle.cpp', + 'src\winterm\PaneControls\PaneCommandModel.cpp', + 'src\winterm\PaneControls\PaneHeaderViewModel.cpp', + 'src\winterm\PaneControls\PaneHeaderSettings.cpp', + 'src\winterm\Docking\Drag\PaneHandleDragSource.cpp', + 'src\winterm\Docking\Overlay\PaneSnapLayoutOverlay.cpp', + 'src\cascadia\UnitTests_SettingsModel\WinTermPaneControlsTests.cpp', + 'src\cascadia\TerminalApp\Pane.cpp', + 'src\cascadia\TerminalApp\TerminalPage.cpp', + 'src\cascadia\TerminalApp\AppActionHandlers.cpp', + 'src\cascadia\TerminalSettingsModel\defaults.json' + ) + foreach ($relativePath in $requiredFiles) + { + if (-not (Test-Path -LiteralPath (Join-Path $root $relativePath) -PathType Leaf)) + { + throw "Pane control boundary '$relativePath' is missing." + } + } + + $directedSplit = Get-Content -Raw -LiteralPath (Join-Path $root $requiredFiles[0]) + foreach ($required in @( + 'DockZone::Top', + 'DockZone::Bottom', + 'DockZone::Left', + 'DockZone::Right', + 'LayoutTransformer::BuildProposedLayout', + 'focusedPaneId', + 'minimumPaneWidth', + 'minimumPaneHeight' + )) + { + if (-not $directedSplit.Contains($required)) + { + throw "Directed split boundary '$required' is missing." + } + } + + $handle = Get-Content -Raw -LiteralPath (Join-Path $root $requiredFiles[3]) + if (-not ($handle.Contains('PanePointerRegion::DragGrip') -and + $handle.Contains('PanePointerRegion::OverflowButton')) -or + $handle.Contains('TerminalContent, true')) + { + throw 'Pane Handle input isolation or overflow menu routing is incomplete.' + } + + $menu = Get-Content -Raw -LiteralPath (Join-Path $root $requiredFiles[4]) + foreach ($required in @( + 'movePaneToNewTab', + 'movePaneToNewWindow', + 'closeFocusedPane', + 'DirectedSplitAction::CommandId', + 'disabledReason' + )) + { + if (-not $menu.Contains($required)) + { + throw "Pane menu command '$required' is missing." + } + } + foreach ($required in @( + '"splitPaneTop"', + '"splitPaneBottom"', + '"splitPaneLeft"', + '"splitPaneRight"' + )) + { + if (-not $directedSplit.Contains($required)) + { + throw "Directed split command '$required' is missing." + } + } + + $drag = Get-Content -Raw -LiteralPath (Join-Path $root $requiredFiles[7]) + foreach ($required in @( + 'DragThreshold::Exceeded', + 'LayoutTransformer::BuildProposedLayout', + 'PaneSnapLayoutOverlay::Build', + 'DockPreview::Build', + 'DragCancellationReason' + )) + { + if (-not $drag.Contains($required)) + { + throw "Pane Handle drag boundary '$required' is missing." + } + } + + $runtimeHeader = Get-Content -Raw -LiteralPath (Join-Path $root $requiredFiles[10]) + foreach ($required in @( + '_AttachLeafVisual', + 'PaneHeaderHeight', + 'SetPaneHeadersVisible', + 'ContextFlyout', + 'Move ', + 'Open pane menu' + )) + { + if (-not $runtimeHeader.Contains($required)) + { + throw "Runtime Pane Header boundary '$required' is missing." + } + } + + $terminalPage = Get-Content -Raw -LiteralPath (Join-Path $root $requiredFiles[11]) + foreach ($required in @( + 'SplitDirection::Up', + 'SplitDirection::Down', + 'SplitDirection::Left', + 'SplitDirection::Right', + 'MovePaneToNewTabText', + 'MovePaneToNewWindowDisabledText' + )) + { + if (-not $terminalPage.Contains($required)) + { + throw "Runtime directed split or Pane menu boundary '$required' is missing." + } + } + + $actionHandlers = Get-Content -Raw -LiteralPath (Join-Path $root $requiredFiles[12]) + $handlerStart = $actionHandlers.IndexOf('void TerminalPage::_HandleSplitPane') + $handlerEnd = $actionHandlers.IndexOf('void TerminalPage::_HandleToggleSplitOrientation', $handlerStart) + if ($handlerStart -lt 0 -or $handlerEnd -le $handlerStart) + { + throw 'The runtime Split Pane handler is missing.' + } + $splitHandler = $actionHandlers.Substring($handlerStart, $handlerEnd - $handlerStart) + if ($splitHandler.IndexOf('PreCalculateCanSplit') -lt 0 -or + $splitHandler.IndexOf('PreCalculateCanSplit') -gt $splitHandler.IndexOf('_MakePane(realArgs.ContentArgs()')) + { + throw 'Split capability validation must run before the new shell pane is created.' + } + + $defaultCommands = Get-Content -Raw -LiteralPath (Join-Path $root $requiredFiles[13]) + foreach ($required in @( + '"id": "splitPaneTop"', + '"id": "splitPaneBottom"', + '"id": "splitPaneLeft"', + '"id": "splitPaneRight"', + '"id": "movePaneToNewTab"', + '"id": "movePaneToNewWindow"', + '"id": "closeFocusedPane"', + '"id": "openPaneMenu"' + )) + { + if (-not $defaultCommands.Contains($required)) + { + throw "Command Palette command $required is missing." + } + } + + $testBinary = Join-Path $root "bin\$Platform\$Configuration\UnitTests_SettingsModel\SettingsModel.Unit.Tests.dll" + if ($SourceOnly) + { + Write-Host 'SKIP: compiled pane control tests are handled by the parent suite.' -ForegroundColor Yellow + } + elseif (Test-Path -LiteralPath $testBinary -PathType Leaf) + { + & (Join-Path $PSScriptRoot 'test.ps1') -Suite Relevant -Configuration $Configuration -Platform $Platform + if (-not $?) + { + throw 'Compiled pane control tests failed.' + } + } + elseif ($RequireCompiled) + { + throw "Compiled Settings Model tests were not found at '$testBinary'." + } + else + { + Write-Host 'SKIP: compiled pane control tests are unavailable.' -ForegroundColor Yellow + } + Write-Host 'PASS: directed split and pane control source boundaries.' -ForegroundColor Green +} +catch +{ + Write-Error "Pane control tests failed: $($_.Exception.Message)" + exit 1 +} diff --git a/scripts/winterm/test.ps1 b/scripts/winterm/test.ps1 index 6a3ccee46..6c8eeb26a 100644 --- a/scripts/winterm/test.ps1 +++ b/scripts/winterm/test.ps1 @@ -101,7 +101,9 @@ function Test-ShellExperienceFoundations } $manifest = Import-PowerShellDataFile -LiteralPath $moduleManifest - if ($manifest.ModuleVersion -ne '1.0.2' -or $manifest.PowerShellVersion -ne '5.1') + if ($manifest.ModuleVersion -ne '0.7.0' -or + $manifest.PrivateData.PSData.Prerelease -ne 'beta.1' -or + $manifest.PowerShellVersion -ne '5.1') { throw 'The winTerm PowerShell module manifest does not declare the supported version boundary.' } @@ -240,6 +242,12 @@ try Test-ShellExperienceFoundations -RepositoryRoot $repositoryRoot Test-WorkspaceFoundations + & (Join-Path $PSScriptRoot 'test-pane-controls.ps1') -Configuration $Configuration -Platform $Platform -SourceOnly + if (-not $?) + { + throw 'Directed split and pane control source validation failed.' + } + & (Join-Path $PSScriptRoot 'test-diagnostics.ps1') if (-not $?) { diff --git a/scripts/winterm/verify-branding.ps1 b/scripts/winterm/verify-branding.ps1 index 949c1d4cc..3c5379b06 100644 --- a/scripts/winterm/verify-branding.ps1 +++ b/scripts/winterm/verify-branding.ps1 @@ -77,7 +77,7 @@ function Test-Manifest Test-Requirement -Condition ($null -ne $identity -and $identity.Name -eq 'HelloThisWorld.winTerm') -Message "$Path uses package identity HelloThisWorld.winTerm" Test-Requirement -Condition ($null -ne $identity -and $identity.Name -notmatch '^Microsoft\.') -Message "$Path does not use a Microsoft package name" Test-Requirement -Condition ($null -ne $identity -and $identity.Publisher -ceq $ExpectedPublisher) -Message "$Path uses the expected non-Microsoft publisher" - Test-Requirement -Condition ($null -ne $identity -and $identity.Version -eq '1.0.2.0') -Message "$Path uses package version 1.0.2.0" + Test-Requirement -Condition ($null -ne $identity -and $identity.Version -eq '0.7.0.1') -Message "$Path uses package version 0.7.0.1" Test-Requirement -Condition ($null -ne $properties -and $properties.DisplayName -eq 'winTerm') -Message "$Path package display name is winTerm" Test-Requirement -Condition ($null -ne $application -and $application.Id -eq 'winTerm') -Message "$Path application ID is winTerm" Test-Requirement -Condition ($null -ne $visualElements -and $visualElements.DisplayName -eq 'winTerm') -Message "$Path application display name is winTerm" @@ -129,15 +129,15 @@ function Test-BuildOutput $launcher = Get-ChildItem -LiteralPath $inspectionRoot -Recurse -File -Filter 'winterm.exe' | Select-Object -First 1 $forbiddenLauncher = Get-ChildItem -LiteralPath $inspectionRoot -Recurse -File -Filter 'wt.exe' | Select-Object -First 1 - $host = Get-ChildItem -LiteralPath $inspectionRoot -Recurse -File -Filter 'WindowsTerminal.exe' | Select-Object -First 1 + $hostExecutable = Get-ChildItem -LiteralPath $inspectionRoot -Recurse -File -Filter 'WindowsTerminal.exe' | Select-Object -First 1 Test-Requirement -Condition ($null -ne $launcher) -Message 'Build output contains winterm.exe' Test-Requirement -Condition ($null -eq $forbiddenLauncher) -Message 'Build output does not contain wt.exe' - Test-Requirement -Condition ($null -ne $host) -Message 'Build output contains the internal terminal host executable' - if ($null -ne $host) + Test-Requirement -Condition ($null -ne $hostExecutable) -Message 'Build output contains the internal terminal host executable' + if ($null -ne $hostExecutable) { - Test-Requirement -Condition ($host.VersionInfo.FileDescription -eq 'winTerm Terminal Host') -Message 'Terminal host file description is winTerm Terminal Host' - Test-Requirement -Condition ($host.VersionInfo.ProductName -eq 'winTerm') -Message 'Terminal host product name is winTerm' + Test-Requirement -Condition ($hostExecutable.VersionInfo.FileDescription -eq 'winTerm Terminal Host') -Message 'Terminal host file description is winTerm Terminal Host' + Test-Requirement -Condition ($hostExecutable.VersionInfo.ProductName -eq 'winTerm') -Message 'Terminal host product name is winTerm' } } diff --git a/scripts/winterm/verify-version.ps1 b/scripts/winterm/verify-version.ps1 index 067e1ae17..4966d032d 100644 --- a/scripts/winterm/verify-version.ps1 +++ b/scripts/winterm/verify-version.ps1 @@ -49,11 +49,12 @@ try $versionPath = Join-Path $repositoryRoot 'src\winterm\Branding\version.json' $version = Get-Content -LiteralPath $versionPath -Raw | ConvertFrom-Json - Assert-Condition ($version.applicationVersion -eq '1.0.2') 'Application version is 1.0.2' - Assert-Condition ($version.packageVersion -eq '1.0.2.0') 'Package version is 1.0.2.0' - Assert-Condition ($version.moduleVersion -eq '1.0.2') 'PowerShell module version is 1.0.2' - Assert-Condition ($version.channel -eq 'stable') 'Release channel is stable' - Assert-Condition ($version.tag -eq 'v1.0.2') 'Release tag is v1.0.2' + Assert-Condition ($version.applicationVersion -eq '0.7.0-beta.1') 'Application version is 0.7.0-beta.1' + Assert-Condition ($version.packageVersion -eq '0.7.0.1') 'Package version is 0.7.0.1' + Assert-Condition ($version.moduleVersion -eq '0.7.0') 'PowerShell module version is 0.7.0' + Assert-Condition ($version.modulePrerelease -eq 'beta.1') 'PowerShell module prerelease is beta.1' + Assert-Condition ($version.channel -eq 'beta') 'Release channel is beta' + Assert-Condition ($version.tag -eq 'v0.7.0-beta.1') 'Beta tag is v0.7.0-beta.1' Assert-Condition ($version.workspaceSchemaVersion -eq 2) 'Workspace Schema version remains 2' Assert-Condition ($version.dockingModelVersion -eq 1) 'Docking Model version remains 1' Assert-Condition ($version.shellProtocolVersion -eq 1) 'Shell Protocol version remains 1' @@ -72,11 +73,12 @@ try $propsNamespace.AddNamespace('m', 'http://schemas.microsoft.com/developer/msbuild/2003') $major = $customProps.SelectSingleNode('//m:VersionMajor', $propsNamespace).'#text' $minor = $customProps.SelectSingleNode('//m:VersionMinor', $propsNamespace).'#text' - Assert-Condition ("$major.$minor" -eq '1.0') 'Executable metadata major and minor versions match 1.0' + Assert-Condition ("$major.$minor" -eq '0.7') 'Executable metadata major and minor versions match 0.7' $moduleManifest = Import-PowerShellDataFile -LiteralPath (Join-Path $repositoryRoot 'shell\powershell\winTerm.Shell\winTerm.Shell.psd1') Assert-Condition ($moduleManifest.ModuleVersion.ToString() -eq $version.moduleVersion) 'PowerShell manifest version matches release metadata' - Assert-Condition ((Get-Text 'shell\powershell\winTerm.Shell\winTerm.Shell.psm1').Contains("`$script:WinTermModuleVersion = '1.0.2'")) 'PowerShell module runtime version matches release metadata' + Assert-Condition ($moduleManifest.PrivateData.PSData.Prerelease -eq $version.modulePrerelease) 'PowerShell manifest prerelease matches release metadata' + Assert-Condition ((Get-Text 'shell\powershell\winTerm.Shell\winTerm.Shell.psm1').Contains("`$script:WinTermModuleVersion = '0.7.0-beta.1'")) 'PowerShell module runtime version matches release metadata' $shellVersion = Get-Text 'shell\shared\version.json' | ConvertFrom-Json Assert-Condition ($shellVersion.applicationVersion -eq $version.applicationVersion) 'Shell asset application version matches release metadata' @@ -84,33 +86,36 @@ try Assert-Condition ($shellVersion.protocolVersion -eq $version.shellProtocolVersion) 'Shell asset protocol version matches release metadata' $releaseHeader = Get-Text 'src\winterm\Branding\ReleaseMetadata.h' - Assert-Condition ($releaseHeader.Contains('ApplicationVersion{ L"1.0.2" }')) 'About metadata application version is 1.0.2' - Assert-Condition ($releaseHeader.Contains('ReleaseChannel{ L"Stable" }')) 'About metadata channel is Stable' + Assert-Condition ($releaseHeader.Contains('ApplicationVersion{ L"0.7.0-beta.1" }')) 'About metadata application version is 0.7.0-beta.1' + Assert-Condition ($releaseHeader.Contains('ReleaseChannel{ L"Beta" }')) 'About metadata channel is Beta' Assert-Condition ($releaseHeader.Contains($version.microsoftTerminalUpstreamRevision)) 'About metadata contains the Microsoft Terminal upstream revision' Assert-Condition ($releaseHeader.Contains('WorkspaceSchemaVersion{ 2 }')) 'About metadata contains Workspace Schema version 2' Assert-Condition ($releaseHeader.Contains('DockingModelVersion{ 1 }')) 'About metadata contains Docking Model version 1' Assert-Condition ($releaseHeader.Contains('ShellProtocolVersion{ 1 }')) 'About metadata contains Shell Protocol version 1' Assert-Condition ($releaseHeader.Contains('ThemeSchemaVersion{ 1 }')) 'About metadata contains Theme Schema version 1' + $hostResource = Get-Text 'src\cascadia\WindowsTerminal\WindowsTerminal.rc' + $packageVersionTuple = $version.packageVersion.Replace('.', ',') + Assert-Condition ($hostResource.Contains("FILEVERSION $packageVersionTuple")) 'Terminal host file version matches release metadata' + Assert-Condition ($hostResource.Contains("PRODUCTVERSION $packageVersionTuple")) 'Terminal host numeric product version matches release metadata' + Assert-Condition ($hostResource.Contains("`"ProductVersion`", `"$($version.applicationVersion)\0`"")) 'Terminal host display product version matches release metadata' + Assert-Condition ((Get-Text 'src\winterm\Workspaces\Model\WorkspaceDescriptor.h').Contains('WorkspaceSchemaVersion{ 2 }')) 'Workspace model remains at Schema version 2' Assert-Condition ((Get-Text 'src\winterm\Workspaces\Model\WorkspaceDescriptor.h').Contains('DockingModelVersion{ 1 }')) 'Workspace model remains at Docking version 1' Assert-Condition ((Get-Text 'src\winterm\Shell\Protocol\ShellIntegrationProtocol.h').Contains('ShellProtocolVersion{ 1 }')) 'Shell protocol remains at version 1' Assert-Condition ((Get-Text 'src\winterm\Appearance\Themes\ThemeDescriptor.h').Contains('CurrentThemeSchemaVersion{ 1 }')) 'Theme Schema remains at version 1' - Assert-Condition ((Get-Text 'src\winterm\Workspaces\Persistence\WorkspaceSerializer.cpp').Contains('"1.0.2"')) 'Workspace application-version fallback is 1.0.2' + Assert-Condition ((Get-Text 'src\winterm\Workspaces\Persistence\WorkspaceSerializer.cpp').Contains('"0.7.0-beta.1"')) 'Workspace application-version fallback is 0.7.0-beta.1' $releaseWorkflow = Get-Text '.github\workflows\release.yml' - Assert-Condition ($releaseWorkflow.Contains('v1.0.2')) 'Release workflow targets v1.0.2' - Assert-Condition ($releaseWorkflow.Contains('winTerm 1.0.2')) 'Release workflow title is winTerm 1.0.2' - Assert-Condition ($releaseWorkflow.Contains('winTerm-1.0.2-x64.msix')) 'Release workflow uses the required x64 artifact name' + Assert-Condition (-not $releaseWorkflow.Contains($version.tag)) 'Beta development tag cannot trigger the Stable workflow' + Assert-Condition ($releaseWorkflow.Contains('v1.0.2')) 'Existing Stable workflow remains isolated on v1.0.2' - $releaseNotes = Get-Text 'docs\releases\1.0.2.md' - Assert-Condition ($releaseNotes.Contains('# winTerm 1.0.2')) 'Release notes title is winTerm 1.0.2' - Assert-Condition ((Get-Text 'CHANGELOG.md').Contains('## 1.0.2')) 'Changelog contains 1.0.2' + Assert-Condition ((Get-Text 'CHANGELOG.md').Contains('## 0.7.0-beta.1')) 'Changelog contains 0.7.0-beta.1' if ($RequireTag) { $tag = (& git describe --tags --exact-match 2>$null).Trim() - Assert-Condition ($LASTEXITCODE -eq 0 -and $tag -eq $version.tag) 'Checked-out commit is exactly tagged v1.0.2' + Assert-Condition ($LASTEXITCODE -eq 0 -and $tag -eq $version.tag) 'Checked-out commit is exactly tagged v0.7.0-beta.1' } Write-Host 'winTerm version consistency verification passed.' -ForegroundColor Green diff --git a/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 b/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 index f28f1ab31..4fd018230 100644 --- a/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 +++ b/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'winTerm.Shell.psm1' - ModuleVersion = '1.0.2' + ModuleVersion = '0.7.0' GUID = 'f65cd8f4-5d25-4a2a-a0d4-58df1ab3dc5a' Author = 'winTerm contributors' CompanyName = 'winTerm' @@ -12,6 +12,7 @@ AliasesToExport = @() PrivateData = @{ PSData = @{ + Prerelease = 'beta.1' Tags = @('winTerm', 'terminal', 'shell-integration') ProjectUri = 'https://github.com/HelloThisWorld/winTerm' LicenseUri = 'https://github.com/HelloThisWorld/winTerm/blob/main/LICENSE' diff --git a/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 b/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 index ba00841b5..cae1c1ea3 100644 --- a/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 +++ b/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 @@ -3,7 +3,7 @@ Set-StrictMode -Version Latest -$script:WinTermModuleVersion = '1.0.2' +$script:WinTermModuleVersion = '0.7.0-beta.1' $script:WinTermProtocolVersion = 1 $script:WinTermIntegrationEnabled = $false $script:WinTermPromptWrapped = $false diff --git a/shell/shared/version.json b/shell/shared/version.json index 98806c4a5..846a334e9 100644 --- a/shell/shared/version.json +++ b/shell/shared/version.json @@ -1,5 +1,6 @@ { - "applicationVersion": "1.0.2", - "moduleVersion": "1.0.2", + "applicationVersion": "0.7.0-beta.1", + "moduleVersion": "0.7.0", + "modulePrerelease": "beta.1", "protocolVersion": 1 } diff --git a/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest b/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest index b40f97655..d43bbf3b6 100644 --- a/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest +++ b/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest @@ -18,7 +18,7 @@ + Version="0.7.0.1" /> winTerm diff --git a/src/cascadia/TerminalApp/AppActionHandlers.cpp b/src/cascadia/TerminalApp/AppActionHandlers.cpp index cf9ee2f7d..f3abeb2a6 100644 --- a/src/cascadia/TerminalApp/AppActionHandlers.cpp +++ b/src/cascadia/TerminalApp/AppActionHandlers.cpp @@ -280,11 +280,36 @@ namespace winrt::TerminalApp::implementation return; } - const auto& duplicateFromTab{ realArgs.SplitMode() == SplitType::Duplicate ? _GetFocusedTab() : nullptr }; - - const auto& activeTab{ _senderOrFocusedTab(sender) }; - - _SplitPane(activeTab, + const auto& duplicateFromTab{ realArgs.SplitMode() == SplitType::Duplicate ? _GetFocusedTab() : nullptr }; + + const auto& activeTab{ _senderOrFocusedTab(sender) }; + if (activeTab) + { + const auto availableSpace = winrt::Windows::Foundation::Size{ + static_cast(_tabContent.ActualWidth()), + static_cast(_tabContent.ActualHeight()), + }; + if (!activeTab->PreCalculateCanSplit( + realArgs.SplitDirection(), + realArgs.SplitSize(), + availableSpace)) + { + // Validate before _MakePane so an impossible split never + // starts a shell session that must immediately be discarded. + if (auto autoPeer = Automation::Peers::FrameworkElementAutomationPeer::FromElement(*this)) + { + autoPeer.RaiseNotificationEvent( + Automation::Peers::AutomationNotificationKind::ActionAborted, + Automation::Peers::AutomationNotificationProcessing::ImportantMostRecent, + RS_(L"SplitPaneUnavailableText"), + L"TerminalPageSplitPaneUnavailable"); + } + args.Handled(true); + return; + } + } + + _SplitPane(activeTab, realArgs.SplitDirection(), // This is safe, we're already filtering so the value is (0, 1) realArgs.SplitSize(), diff --git a/src/cascadia/TerminalApp/Pane.cpp b/src/cascadia/TerminalApp/Pane.cpp index ed02e5250..f25c3689e 100644 --- a/src/cascadia/TerminalApp/Pane.cpp +++ b/src/cascadia/TerminalApp/Pane.cpp @@ -4,8 +4,8 @@ #include "pch.h" #include "Pane.h" -using namespace winrt::Windows::Foundation; -using namespace winrt::Windows::Graphics::Display; +using namespace winrt::Windows::Foundation; +using namespace winrt::Windows::Graphics::Display; using namespace winrt::Windows::UI; using namespace winrt::Windows::UI::Xaml; using namespace winrt::Windows::UI::Core; @@ -15,8 +15,12 @@ using namespace winrt::Microsoft::Terminal::Control; using namespace winrt::Microsoft::Terminal::TerminalConnection; using namespace winrt::TerminalApp; -static const int PaneBorderSize = 2; -static const int CombinedPaneBorderSize = 2 * PaneBorderSize; +static const int PaneBorderSize = 2; +static const int CombinedPaneBorderSize = 2 * PaneBorderSize; +static constexpr double PaneHeaderHeight = 26.0; +static constexpr double PaneHeaderButtonWidth = 32.0; +static constexpr uint64_t PaneTaskbarStateError = 2; +static constexpr uint64_t PaneTaskbarStateIndeterminate = 3; // WARNING: Don't do this! This won't work // Duration duration{ std::chrono::milliseconds{ 200 } }; @@ -29,14 +33,12 @@ static const Duration AnimationDuration = DurationHelper::FromTimeSpan(winrt::Wi Pane::Pane(IPaneContent content, const bool lastFocused) : _lastActive{ lastFocused } -{ - _setPaneContent(std::move(content)); - _root.Children().Append(_borderFirst); - - const auto& control{ _content.GetRoot() }; - _borderFirst.Child(control); - - // Register an event with the control to have it inform us when it gains focus. +{ + _setPaneContent(std::move(content)); + _AttachLeafVisual(); + + // Register an event with the control to have it inform us when it gains focus. + const auto& control{ _content.GetRoot() }; if (control) { _gotFocusRevoker = control.GotFocus(winrt::auto_revoke, { this, &Pane::_ContentGotFocusHandler }); @@ -1144,11 +1146,30 @@ void Pane::ClearActive() // - // Return Value: // - -void Pane::SetActive() -{ - _lastActive = true; - UpdateVisuals(); -} +void Pane::SetActive() +{ + _lastActive = true; + UpdateVisuals(); +} + +void Pane::SetPaneHeadersVisible(const bool visible) +{ + _paneHeadersVisible = visible; + if (_IsLeaf()) + { + if (_paneHeader && _paneHeaderRow) + { + _paneHeader.Visibility(visible ? Visibility::Visible : Visibility::Collapsed); + _paneHeaderRow.Height(GridLengthHelper::FromValueAndType( + visible ? PaneHeaderHeight : 0.0, + GridUnitType::Pixel)); + _UpdatePaneHeader(); + } + return; + } + _firstChild->SetPaneHeadersVisible(visible); + _secondChild->SetPaneHeadersVisible(visible); +} // Method Description: // - Returns nullptr if no children of this pane were the last control to be @@ -1225,9 +1246,14 @@ void Pane::UpdateVisuals() { _UpdateBorders(); } - const auto& brush{ _ComputeBorderColor() }; - _borderFirst.BorderBrush(brush); - _borderSecond.BorderBrush(brush); + const auto& brush{ _ComputeBorderColor() }; + _borderFirst.BorderBrush(brush); + _borderSecond.BorderBrush(brush); + if (_paneHeader) + { + _paneHeader.Background(brush); + _UpdatePaneHeader(); + } } // Method Description: @@ -1449,16 +1475,14 @@ void Pane::_CloseChild(const bool closeFirst) _root.ColumnDefinitions().Clear(); _root.RowDefinitions().Clear(); - // Reattach the TermControl to our grid. - _root.Children().Append(_borderFirst); - const auto& control{ _content.GetRoot() }; - _borderFirst.Child(control); - - // Make sure to set our _splitState before focusing the control. If you + const auto& control{ _content.GetRoot() }; + + // Make sure to set our _splitState before focusing the control. If you // fail to do this, when the tab handles the GotFocus event and asks us // what our active control is, we won't technically be a "leaf", and - // GetTerminalControl will return null. - _splitState = SplitState::None; + // GetTerminalControl will return null. + _splitState = SplitState::None; + _AttachLeafVisual(); // re-attach our handler for the control's GotFocus event. if (control) @@ -1729,11 +1753,18 @@ void Pane::_SetupChildCloseHandlers() // With this method you take ownership of the control from this Pane. // Assign it to another Pane with _setPaneContent() or Close() it. -IPaneContent Pane::_takePaneContent() -{ - _closeRequestedRevoker.revoke(); - return std::move(_content); -} +IPaneContent Pane::_takePaneContent() +{ + _closeRequestedRevoker.revoke(); + _paneTitleChangedRevoker.revoke(); + _paneTaskbarProgressChangedRevoker.revoke(); + _paneReadOnlyChangedRevoker.revoke(); + if (_contentHost) + { + _contentHost.Child(nullptr); + } + return std::move(_content); +} // This method safely sets the content of the Pane. It'll ensure to revoke and // assign event handlers, and to Close() the existing content if there's any. @@ -1747,12 +1778,211 @@ void Pane::_setPaneContent(IPaneContent content) c.Close(); } - if (content) - { - _content = std::move(content); - _closeRequestedRevoker = _content.CloseRequested(winrt::auto_revoke, [this](auto&&, auto&&) { Close(); }); - } -} + if (content) + { + _content = std::move(content); + _closeRequestedRevoker = _content.CloseRequested(winrt::auto_revoke, [this](auto&&, auto&&) { Close(); }); + _paneTitleChangedRevoker = _content.TitleChanged( + winrt::auto_revoke, + [this](auto&&, auto&&) { _UpdatePaneHeader(); }); + _paneTaskbarProgressChangedRevoker = _content.TaskbarProgressChanged( + winrt::auto_revoke, + [this](auto&&, auto&&) { _UpdatePaneHeader(); }); + _paneReadOnlyChangedRevoker = _content.ReadOnlyChanged( + winrt::auto_revoke, + [this](auto&&, auto&&) { _UpdatePaneHeader(); }); + } +} + +void Pane::_AttachLeafVisual() +{ + _root.Children().Clear(); + _borderFirst.Child(nullptr); + + _leafLayout = Controls::Grid{}; + _paneHeader = Controls::Grid{}; + _contentHost = Controls::Border{}; + _paneGrip = Controls::Button{}; + _paneOverflow = Controls::Button{}; + _paneTitle = Controls::TextBlock{}; + _paneStatus = Controls::TextBlock{}; + _paneHeaderRow = Controls::RowDefinition{}; + + _paneHeaderRow.Height(GridLengthHelper::FromValueAndType( + _paneHeadersVisible ? PaneHeaderHeight : 0.0, + GridUnitType::Pixel)); + auto contentRow = Controls::RowDefinition{}; + contentRow.Height(GridLengthHelper::FromValueAndType(1.0, GridUnitType::Star)); + _leafLayout.RowDefinitions().Append(_paneHeaderRow); + _leafLayout.RowDefinitions().Append(contentRow); + + auto gripColumn = Controls::ColumnDefinition{}; + gripColumn.Width(GridLengthHelper::FromValueAndType(PaneHeaderButtonWidth, GridUnitType::Pixel)); + auto titleColumn = Controls::ColumnDefinition{}; + titleColumn.Width(GridLengthHelper::FromValueAndType(1.0, GridUnitType::Star)); + auto statusColumn = Controls::ColumnDefinition{}; + statusColumn.Width(GridLengthHelper::Auto()); + auto overflowColumn = Controls::ColumnDefinition{}; + overflowColumn.Width(GridLengthHelper::FromValueAndType(PaneHeaderButtonWidth, GridUnitType::Pixel)); + _paneHeader.ColumnDefinitions().Append(gripColumn); + _paneHeader.ColumnDefinitions().Append(titleColumn); + _paneHeader.ColumnDefinitions().Append(statusColumn); + _paneHeader.ColumnDefinitions().Append(overflowColumn); + + Controls::FontIcon gripIcon{}; + gripIcon.Glyph(L"\u2807"); + _paneGrip.Content(gripIcon); + _paneGrip.Width(PaneHeaderButtonWidth); + _paneGrip.Height(PaneHeaderHeight); + _paneGrip.Padding(ThicknessHelper::FromLengths(0, 0, 0, 0)); + Automation::AutomationProperties::SetHelpText( + _paneGrip, + L"Drag to move this pane. Use Start Pane Move Mode for keyboard docking."); + + _paneTitle.VerticalAlignment(VerticalAlignment::Center); + _paneTitle.TextTrimming(TextTrimming::CharacterEllipsis); + _paneTitle.Margin(ThicknessHelper::FromLengths(4, 0, 4, 0)); + + _paneStatus.VerticalAlignment(VerticalAlignment::Center); + _paneStatus.Margin(ThicknessHelper::FromLengths(4, 0, 4, 0)); + + Controls::FontIcon overflowIcon{}; + overflowIcon.Glyph(L"\xE712"); + _paneOverflow.Content(overflowIcon); + _paneOverflow.Width(PaneHeaderButtonWidth); + _paneOverflow.Height(PaneHeaderHeight); + _paneOverflow.Padding(ThicknessHelper::FromLengths(0, 0, 0, 0)); + Automation::AutomationProperties::SetName(_paneOverflow, L"Open pane menu"); + + Controls::Grid::SetColumn(_paneGrip, 0); + Controls::Grid::SetColumn(_paneTitle, 1); + Controls::Grid::SetColumn(_paneStatus, 2); + Controls::Grid::SetColumn(_paneOverflow, 3); + _paneHeader.Children().Append(_paneGrip); + _paneHeader.Children().Append(_paneTitle); + _paneHeader.Children().Append(_paneStatus); + _paneHeader.Children().Append(_paneOverflow); + _paneHeader.Visibility(_paneHeadersVisible ? Visibility::Visible : Visibility::Collapsed); + + Controls::Grid::SetRow(_paneHeader, 0); + Controls::Grid::SetRow(_contentHost, 1); + _leafLayout.Children().Append(_paneHeader); + _leafLayout.Children().Append(_contentHost); + _contentHost.Child(_content.GetRoot()); + + if (const auto terminal = GetTerminalControl()) + { + const auto paneMenu = terminal.ContextMenu(); + _paneHeader.ContextFlyout(paneMenu); + _paneGrip.ContextFlyout(paneMenu); + _paneOverflow.Flyout(paneMenu); + } + + _paneHeader.PointerPressed([this](auto&&, auto&&) { _Focus(); }); + _paneHeader.Tapped([this](auto&&, auto&&) { _Focus(); }); + _paneHeader.RightTapped([this](auto&&, auto&&) { _Focus(); }); + _paneOverflow.Click([this](auto&&, auto&&) { _Focus(); }); + _borderFirst.Child(_leafLayout); + _root.Children().Append(_borderFirst); + _UpdatePaneHeader(); +} + +void Pane::_UpdatePaneHeader() +{ + if (!_paneHeader || !_paneTitle || !_paneGrip || !_paneStatus) + { + return; + } + const auto title = _PaneHeaderTitle(); + const auto accessibleTitle = _PaneHeaderAccessibleTitle(); + _paneTitle.Text(title); + std::wstring status; + if (_lastActive) + { + status = L"Active"; + } + if (_content && _content.ReadOnly()) + { + if (!status.empty()) + { + status += L" · "; + } + status += L"Read-only"; + } + switch (_content ? _content.TaskbarState() : 0) + { + case PaneTaskbarStateError: + if (!status.empty()) + { + status += L" · "; + } + status += L"Error"; + break; + case PaneTaskbarStateIndeterminate: + if (!status.empty()) + { + status += L" · "; + } + status += L"Running"; + break; + default: + break; + } + _paneStatus.Text(winrt::hstring{ status }); + auto headerName = std::wstring{ accessibleTitle.c_str() }; + headerName += L" pane"; + auto gripName = std::wstring{ L"Move " }; + gripName += accessibleTitle.c_str(); + gripName += L" pane"; + Automation::AutomationProperties::SetName(_paneHeader, winrt::hstring{ headerName }); + Automation::AutomationProperties::SetName(_paneGrip, winrt::hstring{ gripName }); + const auto accessibleStatus = status.empty() ? + std::wstring{ _lastActive ? L"Focused pane" : L"Unfocused pane" } : + status; + Automation::AutomationProperties::SetName(_paneStatus, winrt::hstring{ accessibleStatus }); +} + +winrt::hstring Pane::_PaneHeaderTitle() const +{ + auto title = std::wstring{ _PaneHeaderAccessibleTitle().c_str() }; + constexpr size_t MaximumPaneHeaderTitleLength = 80; + if (title.size() > MaximumPaneHeaderTitleLength) + { + title.resize(MaximumPaneHeaderTitleLength - 3); + title += L"..."; + } + return winrt::hstring{ title }; +} + +winrt::hstring Pane::_PaneHeaderAccessibleTitle() const +{ + std::wstring title; + if (_content) + { + title = _content.Title().c_str(); + } + if (title.empty()) + { + if (const auto profile = GetProfile()) + { + title = profile.Name().c_str(); + } + } + if (title.empty()) + { + title = L"Terminal"; + } + const auto pathSeparator = title.find_last_of(L"\\/"); + const auto looksLikePath = + (title.size() > 2 && title[1] == L':' && + (title[2] == L'\\' || title[2] == L'/')) || + (!title.empty() && (title.front() == L'\\' || title.front() == L'/')); + if (looksLikePath && pathSeparator != std::wstring::npos && pathSeparator + 1 < title.size()) + { + title = title.substr(pathSeparator + 1); + } + return winrt::hstring{ title }; +} // Method Description: // - Sets up row/column definitions for this pane. There are three total @@ -2652,18 +2882,24 @@ Pane::SnapSizeResult Pane::_CalcSnappedDimension(const bool widthOrHeight, const return { minDimension, minDimension }; } - auto lower = snappable.SnapDownToGrid(widthOrHeight ? PaneSnapDirection::Width : PaneSnapDirection::Height, - dimension); + const auto headerHeight = !widthOrHeight && _paneHeadersVisible ? + static_cast(PaneHeaderHeight) : + 0.0f; + const auto contentDimension = std::max(0.0f, dimension - headerHeight); + auto lower = snappable.SnapDownToGrid( + widthOrHeight ? PaneSnapDirection::Width : PaneSnapDirection::Height, + contentDimension); if (direction == PaneSnapDirection::Width) { lower += WI_IsFlagSet(_borders, Borders::Left) ? PaneBorderSize : 0; lower += WI_IsFlagSet(_borders, Borders::Right) ? PaneBorderSize : 0; } - else - { - lower += WI_IsFlagSet(_borders, Borders::Top) ? PaneBorderSize : 0; - lower += WI_IsFlagSet(_borders, Borders::Bottom) ? PaneBorderSize : 0; + else + { + lower += WI_IsFlagSet(_borders, Borders::Top) ? PaneBorderSize : 0; + lower += WI_IsFlagSet(_borders, Borders::Bottom) ? PaneBorderSize : 0; + lower += headerHeight; } if (lower == dimension) @@ -2861,8 +3097,9 @@ Size Pane::_GetMinSize() const newWidth += WI_IsFlagSet(_borders, Borders::Left) ? PaneBorderSize : 0; newWidth += WI_IsFlagSet(_borders, Borders::Right) ? PaneBorderSize : 0; - newHeight += WI_IsFlagSet(_borders, Borders::Top) ? PaneBorderSize : 0; - newHeight += WI_IsFlagSet(_borders, Borders::Bottom) ? PaneBorderSize : 0; + newHeight += WI_IsFlagSet(_borders, Borders::Top) ? PaneBorderSize : 0; + newHeight += WI_IsFlagSet(_borders, Borders::Bottom) ? PaneBorderSize : 0; + newHeight += _paneHeadersVisible ? static_cast(PaneHeaderHeight) : 0.0f; return { newWidth, newHeight }; } diff --git a/src/cascadia/TerminalApp/Pane.h b/src/cascadia/TerminalApp/Pane.h index ecc81fad8..754ba6d0c 100644 --- a/src/cascadia/TerminalApp/Pane.h +++ b/src/cascadia/TerminalApp/Pane.h @@ -93,10 +93,11 @@ class Pane : public std::enable_shared_from_this winrt::Windows::UI::Xaml::Controls::Grid GetRootElement(); winrt::TerminalApp::IPaneContent GetContent() const noexcept { return _IsLeaf() ? _content : nullptr; } - bool WasLastFocused() const noexcept; - void UpdateVisuals(); - void ClearActive(); - void SetActive(); + bool WasLastFocused() const noexcept; + void UpdateVisuals(); + void ClearActive(); + void SetActive(); + void SetPaneHeadersVisible(bool visible); struct BuildStartupState { @@ -231,9 +232,17 @@ class Pane : public std::enable_shared_from_this struct SnapChildrenSizeResult; struct LayoutSizeNode; - winrt::Windows::UI::Xaml::Controls::Grid _root{}; - winrt::Windows::UI::Xaml::Controls::Border _borderFirst{}; - winrt::Windows::UI::Xaml::Controls::Border _borderSecond{}; + winrt::Windows::UI::Xaml::Controls::Grid _root{}; + winrt::Windows::UI::Xaml::Controls::Border _borderFirst{}; + winrt::Windows::UI::Xaml::Controls::Border _borderSecond{}; + winrt::Windows::UI::Xaml::Controls::Grid _leafLayout{ nullptr }; + winrt::Windows::UI::Xaml::Controls::Grid _paneHeader{ nullptr }; + winrt::Windows::UI::Xaml::Controls::Border _contentHost{ nullptr }; + winrt::Windows::UI::Xaml::Controls::Button _paneGrip{ nullptr }; + winrt::Windows::UI::Xaml::Controls::Button _paneOverflow{ nullptr }; + winrt::Windows::UI::Xaml::Controls::TextBlock _paneTitle{ nullptr }; + winrt::Windows::UI::Xaml::Controls::TextBlock _paneStatus{ nullptr }; + winrt::Windows::UI::Xaml::Controls::RowDefinition _paneHeaderRow{ nullptr }; PaneResources _themeResources; @@ -252,20 +261,28 @@ class Pane : public std::enable_shared_from_this winrt::event_token _firstClosedToken{ 0 }; winrt::event_token _secondClosedToken{ 0 }; - winrt::Windows::UI::Xaml::UIElement::GotFocus_revoker _gotFocusRevoker; - winrt::Windows::UI::Xaml::UIElement::LostFocus_revoker _lostFocusRevoker; - winrt::TerminalApp::IPaneContent::CloseRequested_revoker _closeRequestedRevoker; + winrt::Windows::UI::Xaml::UIElement::GotFocus_revoker _gotFocusRevoker; + winrt::Windows::UI::Xaml::UIElement::LostFocus_revoker _lostFocusRevoker; + winrt::TerminalApp::IPaneContent::CloseRequested_revoker _closeRequestedRevoker; + winrt::TerminalApp::IPaneContent::TitleChanged_revoker _paneTitleChangedRevoker; + winrt::TerminalApp::IPaneContent::TaskbarProgressChanged_revoker _paneTaskbarProgressChangedRevoker; + winrt::TerminalApp::IPaneContent::ReadOnlyChanged_revoker _paneReadOnlyChangedRevoker; Borders _borders{ Borders::None }; - bool _zoomed{ false }; - bool _broadcastEnabled{ false }; + bool _zoomed{ false }; + bool _broadcastEnabled{ false }; + bool _paneHeadersVisible{ false }; bool _IsLeaf() const noexcept; bool _HasFocusedChild() const noexcept; void _SetupChildCloseHandlers(); winrt::TerminalApp::IPaneContent _takePaneContent(); - void _setPaneContent(winrt::TerminalApp::IPaneContent content); + void _setPaneContent(winrt::TerminalApp::IPaneContent content); + void _AttachLeafVisual(); + void _UpdatePaneHeader(); + winrt::hstring _PaneHeaderTitle() const; + winrt::hstring _PaneHeaderAccessibleTitle() const; bool _HasChild(const std::shared_ptr child); winrt::TerminalApp::TerminalPaneContent _getTerminalContent() const; diff --git a/src/cascadia/TerminalApp/Resources/en-US/Resources.resw b/src/cascadia/TerminalApp/Resources/en-US/Resources.resw index aae9701a9..f4b648891 100644 --- a/src/cascadia/TerminalApp/Resources/en-US/Resources.resw +++ b/src/cascadia/TerminalApp/Resources/en-US/Resources.resw @@ -192,21 +192,36 @@ Split pane - - Right click for split directions - right/down/up/left - - - Split pane down - - - Split pane right - - - Split pane up - - - Split pane left - + + Choose a direction and profile for the focused pane + + + The focused pane is too small to split in this direction. + + + Bottom + + + Right + + + Top + + + Left + + + Move + + + Move to New Tab + + + Move to New Window + + + Live pane transfer to a new window is unavailable until the transactional runtime adapter is verified. + Duplicate diff --git a/src/cascadia/TerminalApp/Tab.cpp b/src/cascadia/TerminalApp/Tab.cpp index 16791b9f9..d25ccd773 100644 --- a/src/cascadia/TerminalApp/Tab.cpp +++ b/src/cascadia/TerminalApp/Tab.cpp @@ -76,9 +76,10 @@ namespace winrt::TerminalApp::implementation // - // Return Value: // - - void Tab::_Setup() - { - _rootClosedToken = _rootPane->Closed([=](auto&& /*s*/, auto&& /*e*/) { + void Tab::_Setup() + { + _rootPane->SetPaneHeadersVisible(_rootPane->GetLeafPaneCount() >= 2); + _rootClosedToken = _rootPane->Closed([=](auto&& /*s*/, auto&& /*e*/) { Closed.raise(nullptr, nullptr); }); @@ -641,7 +642,8 @@ namespace winrt::TerminalApp::implementation // Depending on which direction will be split, the new pane can be // either the first or second child, but this will always return the // original pane first. - auto [original, newPane] = _activePane->Split(splitType, splitSize, pane); + auto [original, newPane] = _activePane->Split(splitType, splitSize, pane); + _rootPane->SetPaneHeadersVisible(true); // After split, Close Pane Menu Item should be visible _closePaneMenuItem.Visibility(WUX::Visibility::Visible); @@ -686,9 +688,10 @@ namespace winrt::TerminalApp::implementation } // Attempt to remove the active pane from the tree - if (const auto pane = _rootPane->DetachPane(_activePane)) - { - // Just make sure that the remaining pane is marked active + if (const auto pane = _rootPane->DetachPane(_activePane)) + { + _rootPane->SetPaneHeadersVisible(_rootPane->GetLeafPaneCount() >= 2); + // Just make sure that the remaining pane is marked active _UpdateActivePane(_rootPane->GetActivePane()); return pane; @@ -769,9 +772,10 @@ namespace winrt::TerminalApp::implementation _AttachEventHandlersToPane(first); // Make sure that we have the right pane set as the active pane - if (const auto focus = pane->GetActivePane()) - { - _UpdateActivePane(focus); + if (const auto focus = pane->GetActivePane()) + { + _rootPane->SetPaneHeadersVisible(true); + _UpdateActivePane(focus); } } @@ -1321,7 +1325,9 @@ namespace winrt::TerminalApp::implementation _mruPanes.insert(_mruPanes.begin(), paneId.value()); } - if (_rootPane->GetLeafPaneCount() == 1) + const auto paneCount = _rootPane->GetLeafPaneCount(); + _rootPane->SetPaneHeadersVisible(paneCount >= 2); + if (paneCount == 1) { _closePaneMenuItem.Visibility(WUX::Visibility::Collapsed); } diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 9bb612225..4ed7d4a96 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -11,6 +11,8 @@ #include #include +#include + #include "../../types/inc/ColorFix.hpp" #include "../../types/inc/utils.hpp" #include "../TerminalSettingsAppAdapterLib/TerminalSettings.h" @@ -958,6 +960,74 @@ namespace winrt::TerminalApp::implementation newTabFlyout.Items().Append(item); } + // Directed split is explicit at this common entry point. Each + // direction retains the complete active-profile selection. + if (_settings.ActiveProfiles().Size() > 0) + { + auto splitPaneItem = WUX::Controls::MenuFlyoutSubItem{}; + splitPaneItem.Text(RS_(L"SplitPaneText")); + WUX::Controls::FontIcon splitIcon{}; + splitIcon.Glyph(L"\xF246"); + splitIcon.FontFamily(Media::FontFamily{ L"Segoe Fluent Icons, Segoe MDL2 Assets" }); + splitPaneItem.Icon(splitIcon); + + const std::array directions{ + std::pair{ SplitDirection::Up, RS_(L"SplitPaneUpText") }, + std::pair{ SplitDirection::Down, RS_(L"SplitPaneDownText") }, + std::pair{ SplitDirection::Left, RS_(L"SplitPaneLeftText") }, + std::pair{ SplitDirection::Right, RS_(L"SplitPaneRightText") }, + }; + for (const auto& [direction, directionText] : directions) + { + auto directionItem = WUX::Controls::MenuFlyoutSubItem{}; + directionItem.Text(directionText); + Automation::AutomationProperties::SetName(directionItem, directionText); + if (const auto activeTab = _GetFocusedTabImpl()) + { + const auto availableSpace = winrt::Windows::Foundation::Size{ + static_cast(_tabContent.ActualWidth()), + static_cast(_tabContent.ActualHeight()), + }; + if (!activeTab->PreCalculateCanSplit(direction, .5f, availableSpace)) + { + const auto reason = RS_(L"SplitPaneUnavailableText"); + directionItem.IsEnabled(false); + WUX::Controls::ToolTipService::SetToolTip(directionItem, box_value(reason)); + Automation::AutomationProperties::SetHelpText(directionItem, reason); + } + } + + for (const auto& profile : _settings.ActiveProfiles()) + { + auto profileItem = WUX::Controls::MenuFlyoutItem{}; + profileItem.Text(profile.Name()); + if (!profile.Icon().Resolved().empty()) + { + profileItem.Icon(_CreateNewTabFlyoutIcon(profile.Icon().Resolved())); + } + Automation::AutomationProperties::SetName( + profileItem, + fmt::format(FMT_COMPILE(L"{}, {}"), directionText, profile.Name())); + + NewTerminalArgs terminalArgs{}; + terminalArgs.Profile(profile.Name()); + const auto action = ActionAndArgs{ + ShortcutAction::SplitPane, + SplitPaneArgs{ SplitType::Manual, direction, .5f, terminalArgs }, + }; + profileItem.Click([weakThis{ get_weak() }, action](auto&&, auto&&) { + if (const auto page = weakThis.get()) + { + page->_actionDispatch->DoAction(action); + } + }); + directionItem.Items().Append(profileItem); + } + splitPaneItem.Items().Append(directionItem); + } + newTabFlyout.Items().Append(splitPaneItem); + } + // add menu separator auto separatorItem = WUX::Controls::MenuFlyoutSeparator{}; newTabFlyout.Items().Append(separatorItem); @@ -5382,6 +5452,7 @@ namespace winrt::TerminalApp::implementation button.Label(label); button.Click(makeCallback(action)); targetMenu.SecondaryCommands().Append(button); + return button; }; auto makeMenuItem = [](const winrt::hstring& label, @@ -5400,28 +5471,7 @@ namespace winrt::TerminalApp::implementation button.Label(label); button.Flyout(subMenu); targetMenu.SecondaryCommands().Append(button); - }; - - auto makeContextItem = [&makeCallback](const winrt::hstring& label, - const winrt::hstring& icon, - const winrt::hstring& tooltip, - const auto& action, - const auto& subMenu, - auto& targetMenu) { - AppBarButton button{}; - - if (!icon.empty()) - { - auto iconElement = UI::IconPathConverter::IconWUX(icon); - Automation::AutomationProperties::SetAccessibilityView(iconElement, Automation::Peers::AccessibilityView::Raw); - button.Icon(iconElement); - } - - button.Label(label); - button.Click(makeCallback(action)); - WUX::Controls::ToolTipService::SetToolTip(button, box_value(tooltip)); - button.ContextFlyout(subMenu); - targetMenu.SecondaryCommands().Append(button); + return button; }; const auto focusedProfile = _GetFocusedTabImpl()->GetFocusedProfile(); @@ -5445,37 +5495,58 @@ namespace winrt::TerminalApp::implementation const auto splitPaneDownText = RS_(L"SplitPaneDownText"); const auto splitPaneUpText = RS_(L"SplitPaneUpText"); const auto splitPaneLeftText = RS_(L"SplitPaneLeftText"); - const auto splitPaneToolTipText = RS_(L"SplitPaneToolTipText"); - - MUX::Controls::CommandBarFlyout splitPaneContextMenu{}; - makeItem(splitPaneRightText, focusedProfileIcon, ActionAndArgs{ ShortcutAction::SplitPane, SplitPaneArgs{ SplitType::Duplicate, SplitDirection::Right, .5, nullptr } }, splitPaneContextMenu); - makeItem(splitPaneDownText, focusedProfileIcon, ActionAndArgs{ ShortcutAction::SplitPane, SplitPaneArgs{ SplitType::Duplicate, SplitDirection::Down, .5, nullptr } }, splitPaneContextMenu); - makeItem(splitPaneUpText, focusedProfileIcon, ActionAndArgs{ ShortcutAction::SplitPane, SplitPaneArgs{ SplitType::Duplicate, SplitDirection::Up, .5, nullptr } }, splitPaneContextMenu); - makeItem(splitPaneLeftText, focusedProfileIcon, ActionAndArgs{ ShortcutAction::SplitPane, SplitPaneArgs{ SplitType::Duplicate, SplitDirection::Left, .5, nullptr } }, splitPaneContextMenu); - - makeContextItem(splitPaneDuplicateText, focusedProfileIcon, splitPaneToolTipText, ActionAndArgs{ ShortcutAction::SplitPane, SplitPaneArgs{ SplitType::Duplicate, SplitDirection::Automatic, .5, nullptr } }, splitPaneContextMenu, splitPaneMenu); - - // add menu separator - const auto separatorAutoItem = AppBarSeparator{}; - - splitPaneMenu.SecondaryCommands().Append(separatorAutoItem); - - for (auto profileIndex = 0; profileIndex < activeProfileCount; profileIndex++) - { - const auto profile = activeProfiles.GetAt(profileIndex); - const auto profileName = profile.Name(); - const auto profileIcon = profile.Icon().Resolved(); - - NewTerminalArgs args{}; - args.Profile(profileName); - - MUX::Controls::CommandBarFlyout splitPaneContextMenu{}; - makeItem(splitPaneRightText, profileIcon, ActionAndArgs{ ShortcutAction::SplitPane, SplitPaneArgs{ SplitType::Manual, SplitDirection::Right, .5, args } }, splitPaneContextMenu); - makeItem(splitPaneDownText, profileIcon, ActionAndArgs{ ShortcutAction::SplitPane, SplitPaneArgs{ SplitType::Manual, SplitDirection::Down, .5, args } }, splitPaneContextMenu); - makeItem(splitPaneUpText, profileIcon, ActionAndArgs{ ShortcutAction::SplitPane, SplitPaneArgs{ SplitType::Manual, SplitDirection::Up, .5, args } }, splitPaneContextMenu); - makeItem(splitPaneLeftText, profileIcon, ActionAndArgs{ ShortcutAction::SplitPane, SplitPaneArgs{ SplitType::Manual, SplitDirection::Left, .5, args } }, splitPaneContextMenu); - - makeContextItem(profileName, profileIcon, splitPaneToolTipText, ActionAndArgs{ ShortcutAction::SplitPane, SplitPaneArgs{ SplitType::Manual, SplitDirection::Automatic, .5, args } }, splitPaneContextMenu, splitPaneMenu); + const std::array splitDirections{ + std::pair{ SplitDirection::Up, splitPaneUpText }, + std::pair{ SplitDirection::Down, splitPaneDownText }, + std::pair{ SplitDirection::Left, splitPaneLeftText }, + std::pair{ SplitDirection::Right, splitPaneRightText }, + }; + for (const auto& [direction, directionText] : splitDirections) + { + MUX::Controls::CommandBarFlyout profileMenu{}; + makeItem( + splitPaneDuplicateText, + focusedProfileIcon, + ActionAndArgs{ + ShortcutAction::SplitPane, + SplitPaneArgs{ SplitType::Duplicate, direction, .5, nullptr }, + }, + profileMenu); + profileMenu.SecondaryCommands().Append(AppBarSeparator{}); + + for (auto profileIndex = 0; profileIndex < activeProfileCount; profileIndex++) + { + const auto profile = activeProfiles.GetAt(profileIndex); + const auto profileName = profile.Name(); + const auto profileIcon = profile.Icon().Resolved(); + NewTerminalArgs terminalArgs{}; + terminalArgs.Profile(profileName); + makeItem( + profileName, + profileIcon, + ActionAndArgs{ + ShortcutAction::SplitPane, + SplitPaneArgs{ SplitType::Manual, direction, .5, terminalArgs }, + }, + profileMenu); + } + const auto directionItem = makeMenuItem( + directionText, + winrt::hstring{}, + profileMenu, + splitPaneMenu); + const auto activeTab = _GetFocusedTabImpl(); + const auto availableSpace = winrt::Windows::Foundation::Size{ + static_cast(_tabContent.ActualWidth()), + static_cast(_tabContent.ActualHeight()), + }; + if (!activeTab || !activeTab->PreCalculateCanSplit(direction, .5f, availableSpace)) + { + const auto reason = RS_(L"SplitPaneUnavailableText"); + directionItem.IsEnabled(false); + WUX::Controls::ToolTipService::SetToolTip(directionItem, box_value(reason)); + Automation::AutomationProperties::SetHelpText(directionItem, reason); + } } makeMenuItem(RS_(L"SplitPaneText"), L"\xF246", splitPaneMenu, menu); @@ -5483,6 +5554,29 @@ namespace winrt::TerminalApp::implementation // Only wire up "Close Pane" if there's multiple panes. if (_GetFocusedTabImpl()->GetLeafPaneCount() > 1) { + MUX::Controls::CommandBarFlyout movePaneMenu{}; + makeItem( + RS_(L"MovePaneToNewTabText"), + L"\xE8A7", + ActionAndArgs{ + ShortcutAction::MovePane, + MovePaneArgs{ std::numeric_limits::max(), L"" }, + }, + movePaneMenu); + const auto moveToNewWindow = makeItem( + RS_(L"MovePaneToNewWindowText"), + L"\xE737", + ActionAndArgs{ + ShortcutAction::MovePane, + MovePaneArgs{ 0, L"new" }, + }, + movePaneMenu); + const auto moveToNewWindowReason = RS_(L"MovePaneToNewWindowDisabledText"); + moveToNewWindow.IsEnabled(false); + WUX::Controls::ToolTipService::SetToolTip(moveToNewWindow, box_value(moveToNewWindowReason)); + Automation::AutomationProperties::SetHelpText(moveToNewWindow, moveToNewWindowReason); + makeMenuItem(RS_(L"MovePaneText"), L"\xE8AB", movePaneMenu, menu); + MUX::Controls::CommandBarFlyout swapPaneMenu{}; const auto rootPane = _GetFocusedTabImpl()->GetRootPane(); const auto mruPanes = _GetFocusedTabImpl()->GetMruPanes(); diff --git a/src/cascadia/TerminalSettingsModel/Microsoft.Terminal.Settings.ModelLib.vcxproj b/src/cascadia/TerminalSettingsModel/Microsoft.Terminal.Settings.ModelLib.vcxproj index 08c10edff..9c328d6df 100644 --- a/src/cascadia/TerminalSettingsModel/Microsoft.Terminal.Settings.ModelLib.vcxproj +++ b/src/cascadia/TerminalSettingsModel/Microsoft.Terminal.Settings.ModelLib.vcxproj @@ -251,9 +251,22 @@ + + + + + + + + + + + + + @@ -311,9 +324,18 @@ + + + + + + + + + diff --git a/src/cascadia/TerminalSettingsModel/defaults.json b/src/cascadia/TerminalSettingsModel/defaults.json index aeec357a1..75da756f5 100644 --- a/src/cascadia/TerminalSettingsModel/defaults.json +++ b/src/cascadia/TerminalSettingsModel/defaults.json @@ -879,9 +879,13 @@ { "command": "closeOtherPanes", "id": "Terminal.CloseOtherPanes" }, { "command": "closePane", "id": "Terminal.ClosePane" }, { "command": { "action": "splitPane", "split": "up" }, "id": "Terminal.SplitPaneUp" }, - { "command": { "action": "splitPane", "split": "down" }, "id": "Terminal.SplitPaneDown" }, - { "command": { "action": "splitPane", "split": "left" }, "id": "Terminal.SplitPaneLeft" }, - { "command": { "action": "splitPane", "split": "right" }, "id": "Terminal.SplitPaneRight" }, + { "command": { "action": "splitPane", "split": "down" }, "id": "Terminal.SplitPaneDown" }, + { "command": { "action": "splitPane", "split": "left" }, "id": "Terminal.SplitPaneLeft" }, + { "command": { "action": "splitPane", "split": "right" }, "id": "Terminal.SplitPaneRight" }, + { "command": { "action": "splitPane", "split": "up" }, "id": "splitPaneTop" }, + { "command": { "action": "splitPane", "split": "down" }, "id": "splitPaneBottom" }, + { "command": { "action": "splitPane", "split": "left" }, "id": "splitPaneLeft" }, + { "command": { "action": "splitPane", "split": "right" }, "id": "splitPaneRight" }, { "command": { "action": "splitPane", "splitMode": "duplicate", "split": "down" }, "id": "Terminal.DuplicatePaneDown" }, { "command": { "action": "splitPane", "splitMode": "duplicate", "split": "right" }, "id": "Terminal.DuplicatePaneRight" }, { "command": { "action": "splitPane", "splitMode": "duplicate", "split": "auto" }, "id": "Terminal.DuplicatePaneAuto" }, @@ -921,8 +925,12 @@ { "command": { "action": "movePane", "index": 5 }, "id": "Terminal.MovePaneToTab5" }, { "command": { "action": "movePane", "index": 6 }, "id": "Terminal.MovePaneToTab6" }, { "command": { "action": "movePane", "index": 7 }, "id": "Terminal.MovePaneToTab7" }, - { "command": { "action": "movePane", "index": 8 }, "id": "Terminal.MovePaneToTab8" }, - { "command": { "action": "movePane", "window": "new" }, "id": "Terminal.MovePaneToNewWindow" }, + { "command": { "action": "movePane", "index": 8 }, "id": "Terminal.MovePaneToTab8" }, + { "command": { "action": "movePane", "window": "new" }, "id": "Terminal.MovePaneToNewWindow" }, + { "command": { "action": "movePane", "index": 4294967295 }, "id": "movePaneToNewTab" }, + { "command": { "action": "movePane", "window": "new" }, "id": "movePaneToNewWindow" }, + { "command": "closePane", "id": "closeFocusedPane" }, + { "command": "showContextMenu", "id": "openPaneMenu" }, { "command": "restartConnection", "id": "Terminal.RestartConnection" }, { "command": { "action": "splitPane", "type": "snippets" }, "id": "Terminal.OpenSnippetsPane", "name": { "key": "SnippetsPaneCommandName" } }, diff --git a/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj b/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj index f1b375166..290cfc21a 100644 --- a/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj +++ b/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj @@ -52,6 +52,7 @@ + diff --git a/src/cascadia/UnitTests_SettingsModel/WinTermDockingPresentationTests.cpp b/src/cascadia/UnitTests_SettingsModel/WinTermDockingPresentationTests.cpp index 31e77a0e3..719ac77ba 100644 --- a/src/cascadia/UnitTests_SettingsModel/WinTermDockingPresentationTests.cpp +++ b/src/cascadia/UnitTests_SettingsModel/WinTermDockingPresentationTests.cpp @@ -149,13 +149,13 @@ namespace SettingsModelUnitTests { DockZone::Center, DockZone::Left, DockZone::Right }, DockZone::Center)); VERIFY_IS_TRUE(controller.Handle(DockingNavigationKey::Right)); - VERIFY_IS_TRUE(controller.Announcement().find("right side of Titan Mind window") != std::string::npos); + VERIFY_IS_TRUE(controller.Announcement().find("right of Titan Mind window") != std::string::npos); VERIFY_IS_TRUE(controller.Handle(DockingNavigationKey::Escape)); VERIFY_ARE_EQUAL( static_cast(KeyboardDockingState::Cancelled), static_cast(controller.State())); VERIFY_IS_TRUE(controller.Announcement().find("original layout is unchanged") != std::string::npos); - VERIFY_ARE_EQUAL(size_t{ 19 }, KeyboardDockingController::Commands().size()); + VERIFY_ARE_EQUAL(size_t{ 26 }, KeyboardDockingController::Commands().size()); } void WinTermDockingPresentationTests::SettingsKeepRuntimeDockingBehindReadinessGate() diff --git a/src/cascadia/UnitTests_SettingsModel/WinTermDockingSafetyTests.cpp b/src/cascadia/UnitTests_SettingsModel/WinTermDockingSafetyTests.cpp index 444e9b309..f87fb61ea 100644 --- a/src/cascadia/UnitTests_SettingsModel/WinTermDockingSafetyTests.cpp +++ b/src/cascadia/UnitTests_SettingsModel/WinTermDockingSafetyTests.cpp @@ -114,15 +114,20 @@ namespace SettingsModelUnitTests dirty = true; return true; }; + request.callbacks.recordHistory = [&](const auto&, const auto&) { + order.emplace_back("history"); + return true; + }; request.callbacks.invalidateDragToken = [&] { tokenInvalidated = true; }; const auto result = coordinator.Execute(std::move(request)); VERIFY_ARE_EQUAL(static_cast(DockingStatus::Committed), static_cast(result.docking.status)); - VERIFY_ARE_EQUAL(size_t{ 4 }, order.size()); + VERIFY_ARE_EQUAL(size_t{ 5 }, order.size()); VERIFY_ARE_EQUAL(std::string{ "prepare" }, order[0]); VERIFY_ARE_EQUAL(std::string{ "model" }, order[1]); VERIFY_ARE_EQUAL(std::string{ "visual" }, order[2]); VERIFY_ARE_EQUAL(std::string{ "dirty" }, order[3]); + VERIFY_ARE_EQUAL(std::string{ "history" }, order[4]); VERIFY_IS_TRUE(dirty); VERIFY_IS_TRUE(tokenInvalidated); VERIFY_IS_TRUE(ownership.Owner("session-1") == std::optional{ Owner("window-2", "tab-2", "source-pane") }); diff --git a/src/cascadia/UnitTests_SettingsModel/WinTermPaneControlsTests.cpp b/src/cascadia/UnitTests_SettingsModel/WinTermPaneControlsTests.cpp new file mode 100644 index 000000000..2420ef0e7 --- /dev/null +++ b/src/cascadia/UnitTests_SettingsModel/WinTermPaneControlsTests.cpp @@ -0,0 +1,262 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" + +#include "../../winterm/Accessibility/KeyboardDockingController.h" +#include "../../winterm/Actions/DirectedSplitAction.h" +#include "../../winterm/Actions/MovePaneAction.h" +#include "../../winterm/Docking/Drag/PaneHandleDragSource.h" +#include "../../winterm/Docking/Layout/LayoutTree.h" +#include "../../winterm/PaneControls/PaneCommandMenu.h" +#include "../../winterm/PaneControls/PaneHeader.h" + +using namespace WEX::TestExecution; +using namespace winTerm::Accessibility; +using namespace winTerm::Actions; +using namespace winTerm::Docking; +using namespace winTerm::PaneControls; +using namespace winTerm::Workspaces; + +namespace +{ + LayoutNodePtr Pane(const std::string& id) + { + return LayoutNodeDescriptor::Pane(id); + } + + DirectedSplitContext SplitContext(const DockZone direction) + { + DirectedSplitContext context; + context.layout = LayoutNodeDescriptor::Split( + SplitOrientation::Vertical, + 0.5, + Pane("pane-a"), + Pane("pane-b")); + context.windowId = "window"; + context.tabId = "tab"; + context.focusedPaneId = "pane-b"; + context.newPaneId = "pane-new"; + context.direction = direction; + context.tabBounds = { 0, 0, 1000, 600 }; + context.focusedPaneBounds = { 500, 0, 500, 600 }; + context.defaultProfileId = "default-profile"; + return context; + } +} + +namespace SettingsModelUnitTests +{ + class WinTermPaneControlsTests + { + TEST_CLASS(WinTermPaneControlsTests); + + TEST_METHOD(DirectedSplitPlacesNewPaneOnEverySide); + TEST_METHOD(DirectedSplitTargetsFocusedNestedPane); + TEST_METHOD(DirectedSplitResolvesProfileAndRejectsSmallPane); + TEST_METHOD(PaneHeaderVisibilityTitleAndStatusAreAccessible); + TEST_METHOD(PaneMenuUsesOneCapabilityAwareCommandModel); + TEST_METHOD(PaneHandleDoesNotDragTerminalContent); + TEST_METHOD(MovePanePlansPreserveThePaneNode); + TEST_METHOD(PaneHandleDragUsesTransformerPreview); + TEST_METHOD(KeyboardMoveModeCyclesTargets); + }; + + void WinTermPaneControlsTests::DirectedSplitPlacesNewPaneOnEverySide() + { + for (const auto direction : { DockZone::Top, DockZone::Bottom, DockZone::Left, DockZone::Right }) + { + const auto result = DirectedSplitAction::BuildPlan(SplitContext(direction)); + VERIFY_IS_TRUE(result.Succeeded()); + const auto focusedBranch = result.proposedLayout->second; + VERIFY_ARE_EQUAL(static_cast(LayoutNodeType::Split), static_cast(focusedBranch->type)); + const auto sourceFirst = direction == DockZone::Top || direction == DockZone::Left; + VERIFY_ARE_EQUAL( + std::string{ sourceFirst ? "pane-new" : "pane-b" }, + focusedBranch->first->paneId); + VERIFY_ARE_EQUAL( + std::string{ sourceFirst ? "pane-b" : "pane-new" }, + focusedBranch->second->paneId); + const auto expectedOrientation = + direction == DockZone::Top || direction == DockZone::Bottom ? + SplitOrientation::Horizontal : + SplitOrientation::Vertical; + VERIFY_ARE_EQUAL( + static_cast(expectedOrientation), + static_cast(focusedBranch->orientation)); + } + } + + void WinTermPaneControlsTests::DirectedSplitTargetsFocusedNestedPane() + { + auto context = SplitContext(DockZone::Top); + const auto result = DirectedSplitAction::BuildPlan(context); + VERIFY_IS_TRUE(result.Succeeded()); + VERIFY_ARE_EQUAL(std::string{ "pane-a" }, result.proposedLayout->first->paneId); + VERIFY_ARE_EQUAL(static_cast(LayoutNodeType::Split), static_cast(result.proposedLayout->second->type)); + VERIFY_ARE_EQUAL(size_t{ 3 }, LayoutTree{ result.proposedLayout }.PaneCount()); + } + + void WinTermPaneControlsTests::DirectedSplitResolvesProfileAndRejectsSmallPane() + { + auto selected = SplitContext(DockZone::Right); + selected.profilePolicy = SplitProfilePolicy::AskEveryTime; + selected.selectedProfileId = "selected-profile"; + const auto selectedResult = DirectedSplitAction::BuildPlan(selected); + VERIFY_IS_TRUE(selectedResult.Succeeded()); + VERIFY_ARE_EQUAL(std::string{ "selected-profile" }, selectedResult.profileId); + + auto tooSmall = SplitContext(DockZone::Left); + tooSmall.focusedPaneBounds.width = 200; + const auto rejected = DirectedSplitAction::BuildPlan(tooSmall); + VERIFY_IS_FALSE(rejected.Succeeded()); + VERIFY_IS_TRUE(rejected.invalidReason.find("too narrow") != std::string::npos); + VERIFY_ARE_EQUAL(size_t{ 2 }, LayoutTree{ tooSmall.layout }.PaneCount()); + } + + void WinTermPaneControlsTests::PaneHeaderVisibilityTitleAndStatusAreAccessible() + { + PaneHeaderSettings settings; + PaneHeader header{ settings }; + PaneHeaderState state; + state.paneCount = 2; + state.focused = true; + state.readOnly = true; + state.execution = PaneExecutionState::Running; + state.titles.shellTitle = "C:\\Users\\private\\project"; + const auto presentation = header.Present(state); + VERIFY_IS_TRUE(presentation.visible); + VERIFY_ARE_EQUAL(std::string{ "project" }, presentation.title); + VERIFY_IS_TRUE(presentation.accessibleName.find("Focused") != std::string::npos); + VERIFY_IS_TRUE(presentation.accessibleName.find("Running command") != std::string::npos); + VERIFY_IS_TRUE(presentation.accessibleName.find("Read-only") != std::string::npos); + + settings.visibility = PaneHeaderVisibility::Never; + header.Settings(settings); + VERIFY_IS_FALSE(header.Present(state).visible); + VERIFY_IS_TRUE(settings.HasKeyboardAlternative(true, true)); + + state.titles.shellTitle = + "C:\\private\\" + "a-very-long-pane-title-that-must-be-truncated-visually-but-remain-complete-for-accessibility"; + settings.visibility = PaneHeaderVisibility::Always; + header.Settings(settings); + const auto longTitle = header.Present(state); + VERIFY_IS_TRUE(longTitle.title.size() <= size_t{ 80 }); + VERIFY_IS_TRUE( + longTitle.accessibleName.find("remain-complete-for-accessibility") != std::string::npos); + } + + void WinTermPaneControlsTests::PaneMenuUsesOneCapabilityAwareCommandModel() + { + PaneCommandContext context; + context.paneCount = 1; + context.focusedPaneBounds = { 0, 0, 1000, 600 }; + context.livePaneTransferSupported = false; + const auto rightClick = PaneCommandMenu::Build(context, PaneMenuInvocation::HandleRightClick); + const auto overflow = PaneCommandMenu::Build(context, PaneMenuInvocation::OverflowButton); + VERIFY_ARE_EQUAL(rightClick.size(), overflow.size()); + VERIFY_IS_TRUE(PaneCommandModel::Find(rightClick, "splitPaneTop") != nullptr); + VERIFY_IS_TRUE(PaneCommandModel::Find(rightClick, "splitPaneBottom") != nullptr); + VERIFY_IS_TRUE(PaneCommandModel::Find(rightClick, "splitPaneLeft") != nullptr); + VERIFY_IS_TRUE(PaneCommandModel::Find(rightClick, "splitPaneRight") != nullptr); + VERIFY_IS_FALSE(PaneCommandModel::Find(rightClick, "movePaneToNewTab")->enabled); + VERIFY_IS_FALSE(PaneCommandModel::Find(rightClick, "movePaneToNewWindow")->enabled); + VERIFY_IS_TRUE(PaneCommandModel::Find(rightClick, "closeFocusedPane")->enabled); + } + + void WinTermPaneControlsTests::PaneHandleDoesNotDragTerminalContent() + { + size_t menuOpenCount{}; + PaneHandle handle{ [&](const auto) { ++menuOpenCount; } }; + VERIFY_IS_TRUE(handle.CanStartDrag(PanePointerRegion::DragGrip, true)); + VERIFY_IS_FALSE(handle.CanStartDrag(PanePointerRegion::TerminalContent, true)); + VERIFY_IS_FALSE(handle.CanStartDrag(PanePointerRegion::Scrollbar, true)); + VERIFY_IS_TRUE(handle.HandleRightClick(PanePointerRegion::DragGrip)); + VERIFY_IS_TRUE(handle.HandlePrimaryClick(PanePointerRegion::OverflowButton)); + VERIFY_ARE_EQUAL(size_t{ 2 }, menuOpenCount); + } + + void WinTermPaneControlsTests::MovePanePlansPreserveThePaneNode() + { + MovePaneContext context; + context.sourceLayout = LayoutNodeDescriptor::Split( + SplitOrientation::Vertical, + 0.5, + Pane("pane-source"), + Pane("pane-remaining")); + context.sourceWindowId = "window"; + context.sourceTabId = "tab"; + context.sourcePaneId = "pane-source"; + context.targetWindowId = "window-new"; + context.capabilities.sourcePaneCount = 1; + + const auto tabPlan = MovePaneAction::ToNewTab(context); + VERIFY_IS_TRUE(tabPlan.Succeeded()); + VERIFY_ARE_EQUAL(std::string{ "pane-source" }, tabPlan.targetRoot->paneId); + VERIFY_ARE_EQUAL(std::string{ "pane-remaining" }, tabPlan.sourceAfter->paneId); + + const auto windowPlan = MovePaneAction::ToNewWindow(context); + VERIFY_IS_TRUE(windowPlan.Succeeded()); + VERIFY_ARE_EQUAL(std::string{ "pane-source" }, windowPlan.targetRoot->paneId); + VERIFY_ARE_EQUAL(size_t{ 1 }, windowPlan.sessionPaneIds.size()); + + context.sameProcess = false; + VERIFY_IS_FALSE(MovePaneAction::ToNewWindow(context).Succeeded()); + } + + void WinTermPaneControlsTests::PaneHandleDragUsesTransformerPreview() + { + DragPayloadRegistry registry; + PaneHandleDragSource drag{ registry }; + PaneHandleDragContext context; + context.processInstanceId = "process"; + context.source.type = DockSourceType::Pane; + context.source.windowId = "window"; + context.source.tabId = "tab"; + context.source.paneId = "pane-source"; + context.sourcePaneLayout = Pane("pane-source"); + context.sourceTabLayout = LayoutNodeDescriptor::Split( + SplitOrientation::Vertical, + 0.5, + Pane("pane-source"), + Pane("pane-target")); + context.pressedPoint = { 0, 0 }; + VERIFY_IS_TRUE(drag.PointerPressed(PanePointerRegion::DragGrip, context)); + VERIFY_IS_TRUE(drag.PointerMoved({ 10, 0 }, std::chrono::milliseconds{ 20 })); + + PaneHandleDragPreviewRequest request; + request.target.type = DockTargetType::Pane; + request.target.windowId = "window"; + request.target.tabId = "tab"; + request.target.nodeId = "pane-target"; + request.zone = DockZone::Top; + request.targetLayout = context.sourceTabLayout; + request.capabilities.sourcePaneCount = 1; + request.targetBounds = { 0, 0, 1000, 600 }; + const auto preview = drag.Preview(request); + VERIFY_IS_TRUE(preview.Succeeded()); + VERIFY_ARE_EQUAL(size_t{ 2 }, LayoutTree{ preview.plan.proposedTargetLayout }.PaneCount()); + VERIFY_IS_TRUE(drag.RequestDrop()); + VERIFY_IS_TRUE(drag.BeginCommit()); + VERIFY_IS_TRUE(drag.Complete()); + VERIFY_IS_TRUE(drag.Token().empty()); + } + + void WinTermPaneControlsTests::KeyboardMoveModeCyclesTargets() + { + KeyboardDockingController controller; + const std::vector targets{ + KeyboardDockingTarget{ "one", "Command Prompt pane", { DockZone::Top, DockZone::Bottom } }, + KeyboardDockingTarget{ "two", "PowerShell pane", { DockZone::Left, DockZone::Right } }, + }; + VERIFY_IS_TRUE(controller.StartMoveMode("PowerShell pane", targets, 0, DockZone::Top)); + VERIFY_IS_TRUE(controller.Announcement().find("top of Command Prompt pane") != std::string::npos); + VERIFY_IS_TRUE(controller.Handle(DockingNavigationKey::Tab)); + VERIFY_ARE_EQUAL(size_t{ 1 }, controller.SelectedTarget().value()); + VERIFY_IS_TRUE(controller.Handle(DockingNavigationKey::Right)); + VERIFY_IS_TRUE(controller.Handle(DockingNavigationKey::Enter)); + VERIFY_IS_TRUE(controller.Announcement().find("Move requested") != std::string::npos); + VERIFY_ARE_EQUAL(size_t{ 26 }, KeyboardDockingController::Commands().size()); + } +} diff --git a/src/cascadia/WindowsTerminal/WindowsTerminal.rc b/src/cascadia/WindowsTerminal/WindowsTerminal.rc index ae8b413ad..15edc5d74 100644 --- a/src/cascadia/WindowsTerminal/WindowsTerminal.rc +++ b/src/cascadia/WindowsTerminal/WindowsTerminal.rc @@ -76,6 +76,46 @@ IDI_APPICON_HC_WHITE ICON "..\\..\\..\\res\\terminal\\imag #endif +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(WT_BRANDING_WINTERM) +1 VERSIONINFO + FILEVERSION 0,7,0,1 + PRODUCTVERSION 0,7,0,1 + FILEFLAGSMASK 0x3fL +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS_NT_WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "winTerm Contributors\0" + VALUE "FileDescription", "winTerm Terminal Host\0" + VALUE "FileVersion", "0.7.0.1\0" + VALUE "InternalName", "WindowsTerminal\0" + VALUE "LegalCopyright", "Copyright (c) winTerm contributors. Portions copyright Microsoft Corporation.\0" + VALUE "OriginalFilename", "WindowsTerminal.exe\0" + VALUE "ProductName", "winTerm\0" + VALUE "ProductVersion", "0.7.0-beta.1\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END +#endif + ///////////////////////////////////////////////////////////////////////////// // // String Table diff --git a/src/winterm/Accessibility/KeyboardDockingController.cpp b/src/winterm/Accessibility/KeyboardDockingController.cpp index a5ea93795..dee22e2f6 100644 --- a/src/winterm/Accessibility/KeyboardDockingController.cpp +++ b/src/winterm/Accessibility/KeyboardDockingController.cpp @@ -47,28 +47,51 @@ bool KeyboardDockingController::Start( std::string targetName, std::vector availableZones, const DockZone initialZone) +{ + KeyboardDockingTarget target; + target.id = "target"; + target.name = std::move(targetName); + target.availableZones = std::move(availableZones); + return StartMoveMode( + std::move(sourceName), + { std::move(target) }, + 0, + initialZone); +} + +bool KeyboardDockingController::StartMoveMode( + std::string sourceName, + std::vector targets, + const size_t initialTarget, + const DockZone initialZone) { Reset(); - if (sourceName.empty() || targetName.empty() || availableZones.empty()) + if (sourceName.empty() || targets.empty() || initialTarget >= targets.size()) { return false; } - std::sort( - availableZones.begin(), - availableZones.end(), - [](const auto first, const auto second) { - return static_cast(first) < static_cast(second); - }); - availableZones.erase(std::unique(availableZones.begin(), availableZones.end()), availableZones.end()); + for (auto& target : targets) + { + if (target.id.empty() || target.name.empty() || target.availableZones.empty()) + { + Reset(); + return false; + } + std::sort( + target.availableZones.begin(), + target.availableZones.end(), + [](const auto first, const auto second) { + return static_cast(first) < static_cast(second); + }); + target.availableZones.erase( + std::unique(target.availableZones.begin(), target.availableZones.end()), + target.availableZones.end()); + } _sourceName = std::move(sourceName); - _targetName = std::move(targetName); - _availableZones = std::move(availableZones); + _targets = std::move(targets); _state = KeyboardDockingState::SelectingZone; - if (!_select(initialZone)) - { - _selectedZone = _availableZones.front(); - } + _activateTarget(initialTarget, initialZone); return true; } @@ -84,6 +107,8 @@ bool KeyboardDockingController::Handle(const DockingNavigationKey key) case DockingNavigationKey::Right: return _move(1, 0); case DockingNavigationKey::Up: return _move(0, -1); case DockingNavigationKey::Down: return _move(0, 1); + case DockingNavigationKey::Tab: return _changeTarget(1); + case DockingNavigationKey::ShiftTab: return _changeTarget(-1); case DockingNavigationKey::Enter: _state = KeyboardDockingState::CommitRequested; return true; @@ -100,6 +125,8 @@ void KeyboardDockingController::Reset() noexcept _state = KeyboardDockingState::Inactive; _sourceName.clear(); _targetName.clear(); + _targets.clear(); + _selectedTarget.reset(); _availableZones.clear(); _selectedZone.reset(); } @@ -114,6 +141,11 @@ std::optional KeyboardDockingController::SelectedZone() const noexcept return _selectedZone; } +std::optional KeyboardDockingController::SelectedTarget() const noexcept +{ + return _selectedTarget; +} + std::string KeyboardDockingController::Announcement() const { if (_state == KeyboardDockingState::Inactive) @@ -133,11 +165,11 @@ std::string KeyboardDockingController::Announcement() const std::string{ AccessibleZoneName(*_selectedZone) } + " of " + _targetName + "."; if (_state == KeyboardDockingState::CommitRequested) { - result += " Docking requested."; + result += " Move requested."; } else { - result += " Press Enter to dock. Press Escape to cancel."; + result += " Press Enter to move. Press Escape to cancel."; } return result; } @@ -147,10 +179,10 @@ std::string_view KeyboardDockingController::AccessibleZoneName(const DockZone zo switch (zone) { case DockZone::Center: return "new tab area"; - case DockZone::Left: return "left side"; - case DockZone::Right: return "right side"; - case DockZone::Top: return "top side"; - case DockZone::Bottom: return "bottom side"; + case DockZone::Left: return "left"; + case DockZone::Right: return "right"; + case DockZone::Top: return "top"; + case DockZone::Bottom: return "bottom"; case DockZone::TopLeft: return "top-left corner"; case DockZone::TopRight: return "top-right corner"; case DockZone::BottomLeft: return "bottom-left corner"; @@ -179,6 +211,13 @@ const std::vector& KeyboardDockingController::Commands { "winTerm.dockPaneRight", "Dock pane right", DockZone::Right, DockSourceType::Pane }, { "winTerm.dockPaneTop", "Dock pane top", DockZone::Top, DockSourceType::Pane }, { "winTerm.dockPaneBottom", "Dock pane bottom", DockZone::Bottom, DockSourceType::Pane }, + { "splitPaneTop", "Split pane top", DockZone::Top, DockSourceType::Pane }, + { "splitPaneBottom", "Split pane bottom", DockZone::Bottom, DockSourceType::Pane }, + { "splitPaneLeft", "Split pane left", DockZone::Left, DockSourceType::Pane }, + { "splitPaneRight", "Split pane right", DockZone::Right, DockSourceType::Pane }, + { "closeFocusedPane", "Close focused pane", std::nullopt, DockSourceType::Pane }, + { "startPaneMoveMode", "Start pane move mode", std::nullopt, DockSourceType::Pane }, + { "openPaneMenu", "Open pane menu", std::nullopt, DockSourceType::Pane }, { "winTerm.undoLayoutChange", "Undo layout change", std::nullopt, DockSourceType::LayoutSubtree }, { "winTerm.redoLayoutChange", "Redo layout change", std::nullopt, DockSourceType::LayoutSubtree }, }; @@ -241,3 +280,30 @@ bool KeyboardDockingController::_select(const DockZone zone) _selectedZone = zone; return true; } + +bool KeyboardDockingController::_changeTarget(const int delta) +{ + if (!_selectedTarget || _targets.size() < 2 || delta == 0) + { + return false; + } + const auto count = static_cast(_targets.size()); + const auto current = static_cast(*_selectedTarget); + const auto next = static_cast((current + delta + count) % count); + _activateTarget(next, _selectedZone.value_or(DockZone::Center)); + return true; +} + +void KeyboardDockingController::_activateTarget( + const size_t index, + const DockZone preferredZone) +{ + _selectedTarget = index; + _targetName = _targets[index].name; + _availableZones = _targets[index].availableZones; + _selectedZone.reset(); + if (!_select(preferredZone)) + { + _selectedZone = _availableZones.front(); + } +} diff --git a/src/winterm/Accessibility/KeyboardDockingController.h b/src/winterm/Accessibility/KeyboardDockingController.h index 46637614c..b6f029aa3 100644 --- a/src/winterm/Accessibility/KeyboardDockingController.h +++ b/src/winterm/Accessibility/KeyboardDockingController.h @@ -26,10 +26,19 @@ namespace winTerm::Accessibility Right, Up, Down, + Tab, + ShiftTab, Enter, Escape, }; + struct KeyboardDockingTarget + { + std::string id; + std::string name; + std::vector availableZones; + }; + struct DockingCommandDescriptor { std::string id; @@ -46,11 +55,17 @@ namespace winTerm::Accessibility std::string targetName, std::vector availableZones, Docking::DockZone initialZone = Docking::DockZone::Center); + bool StartMoveMode( + std::string sourceName, + std::vector targets, + size_t initialTarget = 0, + Docking::DockZone initialZone = Docking::DockZone::Center); bool Handle(DockingNavigationKey key); void Reset() noexcept; KeyboardDockingState State() const noexcept; std::optional SelectedZone() const noexcept; + std::optional SelectedTarget() const noexcept; std::string Announcement() const; static std::string_view AccessibleZoneName(Docking::DockZone zone) noexcept; @@ -59,10 +74,14 @@ namespace winTerm::Accessibility private: bool _move(int columnDelta, int rowDelta); bool _select(Docking::DockZone zone); + bool _changeTarget(int delta); + void _activateTarget(size_t index, Docking::DockZone preferredZone); KeyboardDockingState _state{ KeyboardDockingState::Inactive }; std::string _sourceName; std::string _targetName; + std::vector _targets; + std::optional _selectedTarget; std::vector _availableZones; std::optional _selectedZone; }; diff --git a/src/winterm/Actions/DirectedSplitAction.cpp b/src/winterm/Actions/DirectedSplitAction.cpp new file mode 100644 index 000000000..93afbc932 --- /dev/null +++ b/src/winterm/Actions/DirectedSplitAction.cpp @@ -0,0 +1,214 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" +#include "DirectedSplitAction.h" + +#include "../Docking/Layout/LayoutTree.h" + +#include +#include + +using namespace winTerm::Actions; +using namespace winTerm::Docking; +using namespace winTerm::Workspaces; + +namespace +{ + std::string ResolveProfile(const DirectedSplitContext& context) + { + if (context.selectedProfileId && !context.selectedProfileId->empty()) + { + return *context.selectedProfileId; + } + switch (context.profilePolicy) + { + case SplitProfilePolicy::DefaultProfile: + return context.defaultProfileId; + case SplitProfilePolicy::CurrentPaneProfile: + return context.currentPaneProfileId; + case SplitProfilePolicy::AskEveryTime: + default: + return {}; + } + } + + std::string HistoryDescription(const DockZone direction) + { + return "Split pane " + std::string{ ToString(direction) }; + } +} + +bool DirectedSplitPlan::Succeeded() const noexcept +{ + return valid && invalidReason.empty() && proposedLayout != nullptr; +} + +DirectedSplitPlan DirectedSplitAction::BuildPlan(const DirectedSplitContext& context) +{ + DirectedSplitPlan result; + result.focusNewPane = context.focusNewPane; + if (!context.layout) + { + result.invalidReason = "The current tab layout is unavailable."; + return result; + } + if (context.focusedPaneId.empty() || context.newPaneId.empty()) + { + result.invalidReason = "The focused pane or new pane ID is missing."; + return result; + } + const LayoutTree tree{ context.layout }; + const auto focused = tree.Find(context.focusedPaneId); + if (!focused || focused->type != LayoutNodeType::Pane) + { + result.invalidReason = "The focused pane no longer exists."; + return result; + } + if (tree.Find(context.newPaneId)) + { + result.invalidReason = "The new pane ID is already in use."; + return result; + } + if (!CanSplit(context.direction, context.focusedPaneBounds, context.geometrySettings, &result.invalidReason)) + { + return result; + } + result.profileId = ResolveProfile(context); + if (result.profileId.empty()) + { + result.invalidReason = context.profilePolicy == SplitProfilePolicy::AskEveryTime ? + "Select a profile before splitting the pane." : + "The profile used for the split is unavailable."; + return result; + } + + DockSource source; + source.type = DockSourceType::Pane; + source.windowId = context.windowId; + // A directed split plans a new pane that is not in the current tab yet. + // Leaving source.tabId empty prevents same-tab move semantics. + source.paneId = context.newPaneId; + source.root = LayoutNodeDescriptor::Pane(context.newPaneId); + source.paneIds = { context.newPaneId }; + source.activePaneId = context.newPaneId; + + DockTarget target; + target.type = DockTargetType::Pane; + target.windowId = context.windowId; + target.tabId = context.tabId; + target.nodeId = context.focusedPaneId; + + DockingCapabilities capabilities; + capabilities.sourcePaneCount = 1; + capabilities.currentPaneCount = tree.PaneCount(); + capabilities.canDockCorners = false; + + LayoutTransformSettings transformSettings; + transformSettings.defaultSplitRatio = std::clamp(context.splitRatio, 0.2, 0.8); + result.docking = LayoutTransformer::BuildProposedLayout( + std::move(source), + std::move(target), + context.direction, + LayoutNodeDescriptor::Pane(context.newPaneId), + context.layout, + {}, + capabilities, + true, + transformSettings); + result.docking.operation = DockOperation::Split; + if (result.docking.status != DockingStatus::Ready) + { + result.invalidReason = result.docking.invalidReason; + return result; + } + + const auto geometry = LayoutGeometry::Calculate( + result.docking.proposedTargetLayout, + context.tabBounds, + context.geometrySettings); + if (!geometry.valid) + { + result.invalidReason = geometry.invalidReason; + result.docking.status = DockingStatus::Invalid; + result.docking.invalidReason = result.invalidReason; + return result; + } + + result.proposedLayout = LayoutTree::Clone(result.docking.proposedTargetLayout); + result.historyDescription = HistoryDescription(context.direction); + result.valid = true; + return result; +} + +bool DirectedSplitAction::IsDirection(const DockZone direction) noexcept +{ + return direction == DockZone::Top || + direction == DockZone::Bottom || + direction == DockZone::Left || + direction == DockZone::Right; +} + +bool DirectedSplitAction::CanSplit( + const DockZone direction, + const LayoutRect focusedPaneBounds, + const LayoutGeometrySettings& geometrySettings, + std::string* disabledReason) noexcept +{ + auto reject = [&](const std::string_view reason) { + if (disabledReason) + { + *disabledReason = reason; + } + return false; + }; + if (!IsDirection(direction)) + { + return reject("Select Top, Bottom, Left, or Right."); + } + if (!std::isfinite(geometrySettings.dpiScale) || geometrySettings.dpiScale <= 0 || + !std::isfinite(focusedPaneBounds.width) || !std::isfinite(focusedPaneBounds.height)) + { + return reject("The pane geometry or DPI is invalid."); + } + const auto scale = geometrySettings.dpiScale; + if ((direction == DockZone::Left || direction == DockZone::Right) && + focusedPaneBounds.width < geometrySettings.minimumPaneWidth * scale * 2.0) + { + return reject("The focused pane is too narrow for this split."); + } + if ((direction == DockZone::Top || direction == DockZone::Bottom) && + focusedPaneBounds.height < geometrySettings.minimumPaneHeight * scale * 2.0) + { + return reject("The focused pane is too short for this split."); + } + if (disabledReason) + { + disabledReason->clear(); + } + return true; +} + +std::string_view DirectedSplitAction::CommandId(const DockZone direction) noexcept +{ + switch (direction) + { + case DockZone::Top: return "splitPaneTop"; + case DockZone::Bottom: return "splitPaneBottom"; + case DockZone::Left: return "splitPaneLeft"; + case DockZone::Right: return "splitPaneRight"; + default: return {}; + } +} + +std::string_view DirectedSplitAction::AccessibleName(const DockZone direction) noexcept +{ + switch (direction) + { + case DockZone::Top: return "Split pane above"; + case DockZone::Bottom: return "Split pane below"; + case DockZone::Left: return "Split pane to the left"; + case DockZone::Right: return "Split pane to the right"; + default: return "Split pane"; + } +} diff --git a/src/winterm/Actions/DirectedSplitAction.h b/src/winterm/Actions/DirectedSplitAction.h new file mode 100644 index 000000000..a4d586753 --- /dev/null +++ b/src/winterm/Actions/DirectedSplitAction.h @@ -0,0 +1,67 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include "../Docking/Layout/LayoutGeometry.h" +#include "../Docking/Layout/LayoutTransformer.h" + +#include +#include +#include + +namespace winTerm::Actions +{ + enum class SplitProfilePolicy + { + DefaultProfile, + CurrentPaneProfile, + AskEveryTime, + }; + + struct DirectedSplitContext + { + Docking::LayoutNodePtr layout; + std::string windowId; + std::string tabId; + std::string focusedPaneId; + std::string newPaneId; + Docking::DockZone direction{ Docking::DockZone::Center }; + Docking::LayoutRect tabBounds; + Docking::LayoutRect focusedPaneBounds; + Docking::LayoutGeometrySettings geometrySettings; + double splitRatio{ Workspaces::DefaultSplitRatio }; + SplitProfilePolicy profilePolicy{ SplitProfilePolicy::DefaultProfile }; + std::string defaultProfileId; + std::string currentPaneProfileId; + std::optional selectedProfileId; + bool focusNewPane{ true }; + }; + + struct DirectedSplitPlan + { + bool valid{ false }; + std::string invalidReason; + std::string profileId; + Docking::DockingPlan docking; + Docking::LayoutNodePtr proposedLayout; + std::string historyDescription; + bool focusNewPane{ true }; + + bool Succeeded() const noexcept; + }; + + class DirectedSplitAction + { + public: + static DirectedSplitPlan BuildPlan(const DirectedSplitContext& context); + static bool IsDirection(Docking::DockZone direction) noexcept; + static bool CanSplit( + Docking::DockZone direction, + Docking::LayoutRect focusedPaneBounds, + const Docking::LayoutGeometrySettings& geometrySettings, + std::string* disabledReason = nullptr) noexcept; + static std::string_view CommandId(Docking::DockZone direction) noexcept; + static std::string_view AccessibleName(Docking::DockZone direction) noexcept; + }; +} diff --git a/src/winterm/Actions/MovePaneAction.cpp b/src/winterm/Actions/MovePaneAction.cpp new file mode 100644 index 000000000..fa3cd6595 --- /dev/null +++ b/src/winterm/Actions/MovePaneAction.cpp @@ -0,0 +1,104 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" +#include "MovePaneAction.h" + +#include "../Docking/Layout/LayoutTree.h" + +using namespace winTerm::Actions; +using namespace winTerm::Docking; +using namespace winTerm::Workspaces; + +bool MovePanePlan::Succeeded() const noexcept +{ + return valid && invalidReason.empty() && targetRoot != nullptr; +} + +MovePanePlan MovePaneAction::ToNewTab(const MovePaneContext& context) +{ + return _build(context, MovePaneDestination::NewTab); +} + +MovePanePlan MovePaneAction::ToNewWindow(const MovePaneContext& context) +{ + return _build(context, MovePaneDestination::NewWindow); +} + +MovePanePlan MovePaneAction::_build( + const MovePaneContext& context, + const MovePaneDestination destination) +{ + MovePanePlan result; + result.destination = destination; + if (!context.sourceLayout || context.sourcePaneId.empty()) + { + result.invalidReason = "The source pane is unavailable."; + return result; + } + const LayoutTree tree{ context.sourceLayout }; + const auto sourcePane = tree.Find(context.sourcePaneId); + if (!sourcePane || sourcePane->type != LayoutNodeType::Pane) + { + result.invalidReason = "The source pane no longer exists."; + return result; + } + if (destination == MovePaneDestination::NewTab && tree.PaneCount() <= 1) + { + result.invalidReason = "This pane is already the only pane in its tab."; + return result; + } + + DockSource source; + source.type = DockSourceType::Pane; + source.windowId = context.sourceWindowId; + source.tabId = context.sourceTabId; + source.paneId = context.sourcePaneId; + source.root = LayoutTree::Clone(sourcePane); + source.paneIds = { context.sourcePaneId }; + source.activePaneId = context.sourcePaneId; + + DockTarget target; + target.type = destination == MovePaneDestination::NewWindow ? + DockTargetType::NewWindow : + DockTargetType::TabStrip; + target.windowId = destination == MovePaneDestination::NewWindow ? + context.targetWindowId : + context.sourceWindowId; + target.tabId = context.targetTabId; + + auto capabilities = context.capabilities; + capabilities.sourcePaneCount = 1; + capabilities.currentPaneCount = 0; + if (!capabilities.Supports(source, target, DockZone::Center, context.sameProcess)) + { + result.invalidReason = capabilities.DisabledReason(source, target, DockZone::Center, context.sameProcess); + return result; + } + + const auto removed = LayoutTransformer::Remove(context.sourceLayout, context.sourcePaneId); + if (!removed.Succeeded()) + { + result.invalidReason = removed.error; + return result; + } + + result.docking.source = std::move(source); + result.docking.target = std::move(target); + result.docking.zone = DockZone::Center; + result.docking.operation = destination == MovePaneDestination::NewWindow ? + DockOperation::MoveToNewWindow : + DockOperation::MoveToNewTab; + result.docking.status = DockingStatus::Ready; + result.docking.proposedSourceLayout = LayoutTree::Clone(removed.root); + result.docking.proposedTargetLayout = LayoutTree::Clone(sourcePane); + result.docking.movedPaneIds = { context.sourcePaneId }; + result.sourceAfter = LayoutTree::Clone(removed.root); + result.targetRoot = LayoutTree::Clone(sourcePane); + result.sessionPaneIds = { context.sourcePaneId }; + result.historyDescription = destination == MovePaneDestination::NewWindow ? + "Move pane to new window" : + "Move pane to new tab"; + result.valid = true; + return result; +} diff --git a/src/winterm/Actions/MovePaneAction.h b/src/winterm/Actions/MovePaneAction.h new file mode 100644 index 000000000..c066b04a2 --- /dev/null +++ b/src/winterm/Actions/MovePaneAction.h @@ -0,0 +1,55 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include "../Docking/History/LayoutHistory.h" +#include "../Docking/Layout/LayoutTransformer.h" + +#include +#include + +namespace winTerm::Actions +{ + enum class MovePaneDestination + { + NewTab, + NewWindow, + }; + + struct MovePaneContext + { + Docking::LayoutNodePtr sourceLayout; + std::string sourceWindowId; + std::string sourceTabId; + std::string sourcePaneId; + std::string targetWindowId; + std::string targetTabId; + bool sameProcess{ true }; + Docking::DockingCapabilities capabilities; + }; + + struct MovePanePlan + { + bool valid{ false }; + std::string invalidReason; + MovePaneDestination destination{ MovePaneDestination::NewTab }; + Docking::DockingPlan docking; + Docking::LayoutNodePtr sourceAfter; + Docking::LayoutNodePtr targetRoot; + std::vector sessionPaneIds; + std::string historyDescription; + + bool Succeeded() const noexcept; + }; + + class MovePaneAction + { + public: + static MovePanePlan ToNewTab(const MovePaneContext& context); + static MovePanePlan ToNewWindow(const MovePaneContext& context); + + private: + static MovePanePlan _build(const MovePaneContext& context, MovePaneDestination destination); + }; +} diff --git a/src/winterm/Actions/MovePaneToTabAction.h b/src/winterm/Actions/MovePaneToTabAction.h new file mode 100644 index 000000000..d3b13e189 --- /dev/null +++ b/src/winterm/Actions/MovePaneToTabAction.h @@ -0,0 +1,18 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include "MovePaneAction.h" + +namespace winTerm::Actions +{ + class MovePaneToTabAction + { + public: + static MovePanePlan BuildPlan(const MovePaneContext& context) + { + return MovePaneAction::ToNewTab(context); + } + }; +} diff --git a/src/winterm/Actions/MovePaneToWindowAction.h b/src/winterm/Actions/MovePaneToWindowAction.h new file mode 100644 index 000000000..48f4cd5d7 --- /dev/null +++ b/src/winterm/Actions/MovePaneToWindowAction.h @@ -0,0 +1,18 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include "MovePaneAction.h" + +namespace winTerm::Actions +{ + class MovePaneToWindowAction + { + public: + static MovePanePlan BuildPlan(const MovePaneContext& context) + { + return MovePaneAction::ToNewWindow(context); + } + }; +} diff --git a/src/winterm/Actions/OpenPaneMenuAction.h b/src/winterm/Actions/OpenPaneMenuAction.h new file mode 100644 index 000000000..06739ab21 --- /dev/null +++ b/src/winterm/Actions/OpenPaneMenuAction.h @@ -0,0 +1,15 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include + +namespace winTerm::Actions +{ + struct OpenPaneMenuAction + { + static constexpr std::string_view CommandId{ "openPaneMenu" }; + static constexpr std::string_view AccessibleName{ "Open pane menu" }; + }; +} diff --git a/src/winterm/Branding/ReleaseMetadata.h b/src/winterm/Branding/ReleaseMetadata.h index 423a692af..14b45333c 100644 --- a/src/winterm/Branding/ReleaseMetadata.h +++ b/src/winterm/Branding/ReleaseMetadata.h @@ -24,8 +24,8 @@ namespace winTerm::Branding { - inline constexpr std::wstring_view ApplicationVersion{ L"1.0.2" }; - inline constexpr std::wstring_view ReleaseChannel{ L"Stable" }; + inline constexpr std::wstring_view ApplicationVersion{ L"0.7.0-beta.1" }; + inline constexpr std::wstring_view ReleaseChannel{ L"Beta" }; inline constexpr std::wstring_view CommitSha{ WINTERM_BUILD_COMMIT_SHA }; inline constexpr std::wstring_view BuildTimestamp{ WINTERM_BUILD_TIMESTAMP }; inline constexpr std::wstring_view WorkflowRunId{ WINTERM_BUILD_WORKFLOW_RUN_ID }; diff --git a/src/winterm/Branding/version.json b/src/winterm/Branding/version.json index 403b4926c..cd4656129 100644 --- a/src/winterm/Branding/version.json +++ b/src/winterm/Branding/version.json @@ -1,9 +1,10 @@ { - "applicationVersion": "1.0.2", - "packageVersion": "1.0.2.0", - "moduleVersion": "1.0.2", - "channel": "stable", - "tag": "v1.0.2", + "applicationVersion": "0.7.0-beta.1", + "packageVersion": "0.7.0.1", + "moduleVersion": "0.7.0", + "modulePrerelease": "beta.1", + "channel": "beta", + "tag": "v0.7.0-beta.1", "workspaceSchemaVersion": 2, "dockingModelVersion": 1, "shellProtocolVersion": 1, diff --git a/src/winterm/Docking/Drag/PaneHandleDragSource.cpp b/src/winterm/Docking/Drag/PaneHandleDragSource.cpp new file mode 100644 index 000000000..09a72f868 --- /dev/null +++ b/src/winterm/Docking/Drag/PaneHandleDragSource.cpp @@ -0,0 +1,186 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" +#include "PaneHandleDragSource.h" + +#include "../Layout/LayoutTree.h" + +#include + +using namespace winTerm::Docking; +using namespace winTerm::PaneControls; + +bool PaneHandleDragPreview::Succeeded() const noexcept +{ + return plan.status == DockingStatus::Ready && preview.valid; +} + +PaneHandleDragSource::PaneHandleDragSource(DragPayloadRegistry& registry) : + _registry{ registry } +{ +} + +bool PaneHandleDragSource::PointerPressed( + const PanePointerRegion region, + PaneHandleDragContext context) +{ + if (region != PanePointerRegion::DragGrip || + !context.draggingEnabled || + context.processInstanceId.empty() || + !context.source.paneId || + !context.sourcePaneLayout || + !context.sourceTabLayout || + _state.State() != DockDragState::Idle) + { + return false; + } + if (!_state.Transition(DockDragState::PointerPressed) || + !_state.Transition(DockDragState::DragPending)) + { + return false; + } + _context = std::move(context); + return true; +} + +bool PaneHandleDragSource::PointerMoved( + const DragPoint current, + const std::chrono::milliseconds held) +{ + if (!_context || _state.State() != DockDragState::DragPending) + { + return false; + } + if (!DragThreshold::Exceeded( + _context->pressedPoint, + current, + held, + _context->threshold)) + { + return false; + } + _token = _registry.Register( + _context->processInstanceId, + _context->source, + DragCapability::Pane); + if (_token.empty()) + { + _state.Cancel(DragCancellationReason::TokenExpired); + return false; + } + return _state.Transition(DockDragState::Dragging); +} + +PaneHandleDragPreview PaneHandleDragSource::Preview( + const PaneHandleDragPreviewRequest& request) +{ + PaneHandleDragPreview result; + if (!_context || + (_state.State() != DockDragState::Dragging && + _state.State() != DockDragState::TargetAcquired)) + { + result.plan.invalidReason = "A pane handle drag is not active."; + result.preview.invalidReason = result.plan.invalidReason; + return result; + } + + result.plan = LayoutTransformer::BuildProposedLayout( + _context->source, + request.target, + request.zone, + _context->sourcePaneLayout, + request.targetLayout, + request.emptySlotId, + request.capabilities, + request.sameProcess); + result.overlay = PaneSnapLayoutOverlay::Build( + request.targetBounds, + _context->source, + request.target, + request.capabilities, + request.sameProcess, + request.zone); + result.preview = DockPreview::Build( + result.plan, + request.targetBounds, + request.geometrySettings); + + if (result.plan.status == DockingStatus::Ready && result.preview.valid) + { + _lastPlan = result.plan; + if (_state.State() == DockDragState::Dragging) + { + _state.Transition(DockDragState::TargetAcquired); + } + } + else if (_state.State() == DockDragState::TargetAcquired) + { + _state.Transition(DockDragState::Dragging); + _lastPlan.reset(); + } + return result; +} + +bool PaneHandleDragSource::RequestDrop() +{ + return _lastPlan && + _lastPlan->status == DockingStatus::Ready && + _state.Transition(DockDragState::DropPending); +} + +bool PaneHandleDragSource::BeginCommit() +{ + return _state.Transition(DockDragState::Committing); +} + +bool PaneHandleDragSource::Complete() +{ + if (!_state.Transition(DockDragState::Completed)) + { + return false; + } + _invalidateToken(); + return true; +} + +bool PaneHandleDragSource::Cancel(const DragCancellationReason reason) +{ + const auto cancelled = _state.Cancel(reason); + if (cancelled) + { + _invalidateToken(); + } + return cancelled; +} + +bool PaneHandleDragSource::Reset() +{ + if (!_state.Reset()) + { + return false; + } + _context.reset(); + _lastPlan.reset(); + _token.clear(); + return true; +} + +DockDragState PaneHandleDragSource::State() const noexcept +{ + return _state.State(); +} + +std::string_view PaneHandleDragSource::Token() const noexcept +{ + return _token; +} + +void PaneHandleDragSource::_invalidateToken() noexcept +{ + if (!_token.empty()) + { + _registry.Invalidate(_token); + _token.clear(); + } +} diff --git a/src/winterm/Docking/Drag/PaneHandleDragSource.h b/src/winterm/Docking/Drag/PaneHandleDragSource.h new file mode 100644 index 000000000..1a8696043 --- /dev/null +++ b/src/winterm/Docking/Drag/PaneHandleDragSource.h @@ -0,0 +1,79 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include "../../PaneControls/PaneHandle.h" +#include "../Layout/LayoutTransformer.h" +#include "../Overlay/PaneSnapLayoutOverlay.h" +#include "DockDragSession.h" + +#include +#include +#include + +namespace winTerm::Docking +{ + struct PaneHandleDragContext + { + std::string processInstanceId; + DockSource source; + LayoutNodePtr sourcePaneLayout; + LayoutNodePtr sourceTabLayout; + DragPoint pressedPoint; + DragThresholdSettings threshold; + bool draggingEnabled{ true }; + }; + + struct PaneHandleDragPreviewRequest + { + DockTarget target; + DockZone zone{ DockZone::Center }; + LayoutNodePtr targetLayout; + std::string emptySlotId; + DockingCapabilities capabilities; + bool sameProcess{ true }; + LayoutRect targetBounds; + LayoutGeometrySettings geometrySettings; + }; + + struct PaneHandleDragPreview + { + DockingPlan plan; + DockPreviewModel preview; + std::vector overlay; + + bool Succeeded() const noexcept; + }; + + class PaneHandleDragSource + { + public: + explicit PaneHandleDragSource(DragPayloadRegistry& registry); + + bool PointerPressed( + PaneControls::PanePointerRegion region, + PaneHandleDragContext context); + bool PointerMoved( + DragPoint current, + std::chrono::milliseconds held); + PaneHandleDragPreview Preview(const PaneHandleDragPreviewRequest& request); + bool RequestDrop(); + bool BeginCommit(); + bool Complete(); + bool Cancel(DragCancellationReason reason); + bool Reset(); + + DockDragState State() const noexcept; + std::string_view Token() const noexcept; + + private: + void _invalidateToken() noexcept; + + DragPayloadRegistry& _registry; + DockDragStateMachine _state; + std::optional _context; + std::string _token; + std::optional _lastPlan; + }; +} diff --git a/src/winterm/Docking/Layout/LayoutTransformer.cpp b/src/winterm/Docking/Layout/LayoutTransformer.cpp index 1a18ed602..1bee6dc3f 100644 --- a/src/winterm/Docking/Layout/LayoutTransformer.cpp +++ b/src/winterm/Docking/Layout/LayoutTransformer.cpp @@ -294,6 +294,37 @@ DockingPlan LayoutTransformer::BuildProposedLayout( plan.invalidReason = capabilities.DisabledReason(plan.source, plan.target, zone, sameProcess); return plan; } + const auto sameTabMove = + plan.source.type == DockSourceType::Pane && + plan.source.paneId && + !plan.source.tabId.empty() && + plan.target.tabId && + plan.source.windowId == plan.target.windowId && + plan.source.tabId == *plan.target.tabId && + plan.target.nodeId && + (plan.target.type == DockTargetType::Pane || + plan.target.type == DockTargetType::EmptySlot) && + zone != DockZone::Center; + if (sameTabMove) + { + const auto transformed = MoveWithin( + targetLayout, + *plan.source.paneId, + *plan.target.nodeId, + zone, + std::move(emptySlotId), + {}, + settings); + if (!transformed.Succeeded()) + { + plan.invalidReason = transformed.error; + return plan; + } + plan.proposedSourceLayout = LayoutTree::Clone(transformed.root); + plan.proposedTargetLayout = LayoutTree::Clone(transformed.root); + plan.status = DockingStatus::Ready; + return plan; + } if (plan.target.type == DockTargetType::TabStrip || plan.target.type == DockTargetType::NewWindow || zone == DockZone::Center) diff --git a/src/winterm/Docking/Model/DockingModel.h b/src/winterm/Docking/Model/DockingModel.h index 17fc09fb3..458955554 100644 --- a/src/winterm/Docking/Model/DockingModel.h +++ b/src/winterm/Docking/Model/DockingModel.h @@ -44,6 +44,9 @@ namespace winTerm::Docking enum class DockOperation { Move, + Split, + MoveToNewTab, + MoveToNewWindow, }; enum class DockingStatus diff --git a/src/winterm/Docking/Overlay/PaneSnapLayoutOverlay.cpp b/src/winterm/Docking/Overlay/PaneSnapLayoutOverlay.cpp new file mode 100644 index 000000000..a8b829501 --- /dev/null +++ b/src/winterm/Docking/Overlay/PaneSnapLayoutOverlay.cpp @@ -0,0 +1,29 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" +#include "PaneSnapLayoutOverlay.h" + +using namespace winTerm::Docking; + +std::vector PaneSnapLayoutOverlay::Build( + const LayoutRect contentBounds, + const DockSource& source, + const DockTarget& target, + DockingCapabilities capabilities, + const bool sameProcess, + const std::optional selected, + const bool cornerZonesEnabled, + const DockingOverlaySettings& settings) +{ + capabilities.canDockCorners = capabilities.canDockCorners && cornerZonesEnabled; + capabilities.canUseEmptySlots = capabilities.canUseEmptySlots && cornerZonesEnabled; + return DockingOverlayModel::Build( + contentBounds, + source, + target, + capabilities, + sameProcess, + selected, + settings); +} diff --git a/src/winterm/Docking/Overlay/PaneSnapLayoutOverlay.h b/src/winterm/Docking/Overlay/PaneSnapLayoutOverlay.h new file mode 100644 index 000000000..3e9fbdfeb --- /dev/null +++ b/src/winterm/Docking/Overlay/PaneSnapLayoutOverlay.h @@ -0,0 +1,22 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include "DockingOverlayModel.h" + +namespace winTerm::Docking +{ + struct PaneSnapLayoutOverlay + { + static std::vector Build( + LayoutRect contentBounds, + const DockSource& source, + const DockTarget& target, + DockingCapabilities capabilities, + bool sameProcess, + std::optional selected, + bool cornerZonesEnabled = true, + const DockingOverlaySettings& settings = {}); + }; +} diff --git a/src/winterm/Docking/Transactions/LayoutTransaction.cpp b/src/winterm/Docking/Transactions/LayoutTransaction.cpp index dc0dd7cbe..e0a8550ef 100644 --- a/src/winterm/Docking/Transactions/LayoutTransaction.cpp +++ b/src/winterm/Docking/Transactions/LayoutTransaction.cpp @@ -127,6 +127,14 @@ LayoutTransactionResult LayoutTransactionCoordinator::Execute(LayoutTransactionR { Fail("The Workspace coordinator could not accept the layout change."); } + if (request.callbacks.recordHistory) + { + if (!request.callbacks.recordHistory(request.snapshot, request.plan)) + { + Fail("The layout history could not record the committed change."); + } + result.state = LayoutTransactionState::HistoryRecorded; + } _invoke(request.callbacks.restoreFocus); _invoke(request.callbacks.closeOverlay); diff --git a/src/winterm/Docking/Transactions/LayoutTransaction.h b/src/winterm/Docking/Transactions/LayoutTransaction.h index 910a707b6..c835af573 100644 --- a/src/winterm/Docking/Transactions/LayoutTransaction.h +++ b/src/winterm/Docking/Transactions/LayoutTransaction.h @@ -22,6 +22,7 @@ namespace winTerm::Docking OwnershipReserved, ModelCommitted, VisualCommitted, + HistoryRecorded, Completed, RollingBack, RolledBack, @@ -51,6 +52,7 @@ namespace winTerm::Docking std::function prepareTarget; std::function commitModel; std::function commitVisualTree; + std::function recordHistory; std::function rollback; std::function recoverSessions; std::function markWorkspaceDirty; diff --git a/src/winterm/PaneControls/PaneCommandMenu.h b/src/winterm/PaneControls/PaneCommandMenu.h new file mode 100644 index 000000000..4de67e952 --- /dev/null +++ b/src/winterm/PaneControls/PaneCommandMenu.h @@ -0,0 +1,27 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include "PaneCommandModel.h" + +namespace winTerm::PaneControls +{ + enum class PaneMenuInvocation + { + HandleRightClick, + OverflowButton, + Keyboard, + }; + + struct PaneCommandMenu + { + static std::vector Build( + const PaneCommandContext& context, + PaneMenuInvocation) + { + // All invocation paths intentionally use one command model. + return PaneCommandModel::Build(context); + } + }; +} diff --git a/src/winterm/PaneControls/PaneCommandModel.cpp b/src/winterm/PaneControls/PaneCommandModel.cpp new file mode 100644 index 000000000..bd62e0500 --- /dev/null +++ b/src/winterm/PaneControls/PaneCommandModel.cpp @@ -0,0 +1,103 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" +#include "PaneCommandModel.h" + +#include + +using namespace winTerm::Actions; +using namespace winTerm::Docking; +using namespace winTerm::PaneControls; + +std::vector PaneCommandModel::Build(const PaneCommandContext& context) +{ + std::vector result; + result.reserve(12); + + const std::array directions{ + std::pair{ PaneCommand::SplitTop, DockZone::Top }, + std::pair{ PaneCommand::SplitBottom, DockZone::Bottom }, + std::pair{ PaneCommand::SplitLeft, DockZone::Left }, + std::pair{ PaneCommand::SplitRight, DockZone::Right }, + }; + for (const auto [command, direction] : directions) + { + std::string disabledReason; + const auto canSplit = DirectedSplitAction::CanSplit( + direction, + context.focusedPaneBounds, + context.geometrySettings, + &disabledReason); + result.emplace_back(PaneCommandDescriptor{ + command, + std::string{ DirectedSplitAction::CommandId(direction) }, + "split", + std::string{ ToString(direction) }, + std::string{ DirectedSplitAction::AccessibleName(direction) }, + std::string{ ToString(direction) }, + canSplit && !context.transactionProtected, + context.transactionProtected ? "The pane is protected by an active layout transaction." : disabledReason, + direction, + }); + } + + result.emplace_back(PaneCommandDescriptor{ + PaneCommand::MoveToNewTab, + "movePaneToNewTab", + "move", + "Move to New Tab", + "Move pane to a new tab", + "newTab", + context.paneCount > 1 && !context.transactionProtected, + context.transactionProtected ? + "The pane is protected by an active layout transaction." : + context.paneCount <= 1 ? "This pane is already the only pane in its tab." : std::string{}, + }); + result.emplace_back(PaneCommandDescriptor{ + PaneCommand::MoveToNewWindow, + "movePaneToNewWindow", + "move", + "Move to New Window", + "Move pane to a new window", + "newWindow", + context.livePaneTransferSupported && + context.sameProcessWindowHosting && + !context.transactionProtected, + context.transactionProtected ? + "The pane is protected by an active layout transaction." : + !context.livePaneTransferSupported || !context.sameProcessWindowHosting ? + "Live pane transfer is not supported by this window host." : + std::string{}, + }); + result.emplace_back(PaneCommandDescriptor{ + PaneCommand::ClosePane, + "closeFocusedPane", + {}, + "Close Pane", + "Close pane", + "close", + !context.transactionProtected, + context.transactionProtected ? "The pane is protected by an active layout transaction." : std::string{}, + }); + result.emplace_back(PaneCommandDescriptor{ PaneCommand::FocusPane, "focusPane", "more", "Focus Pane", "Focus pane", "focus" }); + result.emplace_back(PaneCommandDescriptor{ PaneCommand::ZoomPane, "zoomPane", "more", "Zoom Pane", "Zoom pane", "zoom" }); + result.emplace_back(PaneCommandDescriptor{ PaneCommand::PaneSettings, "paneSettings", "more", "Pane Settings", "Open pane settings", "settings" }); + result.emplace_back(PaneCommandDescriptor{ PaneCommand::StartMoveMode, "startPaneMoveMode", {}, "Move Pane", "Start pane move mode", "move" }); + result.emplace_back(PaneCommandDescriptor{ PaneCommand::OpenPaneMenu, "openPaneMenu", {}, "Open Pane Menu", "Open pane menu", "more" }); + return result; +} + +const PaneCommandDescriptor* PaneCommandModel::Find( + const std::vector& commands, + const std::string_view id) noexcept +{ + for (const auto& command : commands) + { + if (command.id == id) + { + return &command; + } + } + return nullptr; +} diff --git a/src/winterm/PaneControls/PaneCommandModel.h b/src/winterm/PaneControls/PaneCommandModel.h new file mode 100644 index 000000000..bfc2c0489 --- /dev/null +++ b/src/winterm/PaneControls/PaneCommandModel.h @@ -0,0 +1,64 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include "../Actions/DirectedSplitAction.h" +#include "../Docking/Model/DockingModel.h" + +#include +#include +#include +#include +#include + +namespace winTerm::PaneControls +{ + enum class PaneCommand + { + SplitTop, + SplitBottom, + SplitLeft, + SplitRight, + MoveToNewTab, + MoveToNewWindow, + ClosePane, + FocusPane, + ZoomPane, + PaneSettings, + StartMoveMode, + OpenPaneMenu, + }; + + struct PaneCommandContext + { + size_t paneCount{ 1 }; + bool transactionProtected{ false }; + bool livePaneTransferSupported{ true }; + bool sameProcessWindowHosting{ true }; + Docking::LayoutRect focusedPaneBounds; + Docking::LayoutGeometrySettings geometrySettings; + }; + + struct PaneCommandDescriptor + { + PaneCommand command{ PaneCommand::OpenPaneMenu }; + std::string id; + std::string parentId; + std::string label; + std::string accessibleName; + std::string icon; + bool enabled{ true }; + std::string disabledReason; + std::optional direction; + }; + + class PaneCommandModel + { + public: + static std::vector Build(const PaneCommandContext& context); + static const PaneCommandDescriptor* Find( + const std::vector& commands, + std::string_view id) noexcept; + }; +} diff --git a/src/winterm/PaneControls/PaneHandle.cpp b/src/winterm/PaneControls/PaneHandle.cpp new file mode 100644 index 000000000..7aa12340b --- /dev/null +++ b/src/winterm/PaneControls/PaneHandle.cpp @@ -0,0 +1,47 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" +#include "PaneHandle.h" + +#include + +using namespace winTerm::PaneControls; + +PaneHandle::PaneHandle(OpenMenuCallback openMenu) : + _openMenu{ std::move(openMenu) } +{ +} + +bool PaneHandle::CanStartDrag( + const PanePointerRegion region, + const bool draggingEnabled) const noexcept +{ + return draggingEnabled && region == PanePointerRegion::DragGrip; +} + +bool PaneHandle::HandleRightClick(const PanePointerRegion region) const +{ + if (region != PanePointerRegion::DragGrip && region != PanePointerRegion::HeaderBody) + { + return false; + } + if (_openMenu) + { + _openMenu(PaneMenuInvocation::HandleRightClick); + } + return true; +} + +bool PaneHandle::HandlePrimaryClick(const PanePointerRegion region) const +{ + if (region != PanePointerRegion::OverflowButton) + { + return false; + } + if (_openMenu) + { + _openMenu(PaneMenuInvocation::OverflowButton); + } + return true; +} diff --git a/src/winterm/PaneControls/PaneHandle.h b/src/winterm/PaneControls/PaneHandle.h new file mode 100644 index 000000000..a0a8fc5be --- /dev/null +++ b/src/winterm/PaneControls/PaneHandle.h @@ -0,0 +1,35 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include "PaneCommandMenu.h" + +#include + +namespace winTerm::PaneControls +{ + enum class PanePointerRegion + { + DragGrip, + HeaderBody, + OverflowButton, + TerminalContent, + Scrollbar, + }; + + class PaneHandle + { + public: + using OpenMenuCallback = std::function; + + explicit PaneHandle(OpenMenuCallback openMenu = {}); + + bool CanStartDrag(PanePointerRegion region, bool draggingEnabled) const noexcept; + bool HandleRightClick(PanePointerRegion region) const; + bool HandlePrimaryClick(PanePointerRegion region) const; + + private: + OpenMenuCallback _openMenu; + }; +} diff --git a/src/winterm/PaneControls/PaneHeader.cpp b/src/winterm/PaneControls/PaneHeader.cpp new file mode 100644 index 000000000..d51d9ccde --- /dev/null +++ b/src/winterm/PaneControls/PaneHeader.cpp @@ -0,0 +1,39 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" +#include "PaneHeader.h" + +#include + +using namespace winTerm::PaneControls; + +PaneHeader::PaneHeader( + PaneHeaderSettings settings, + PaneHandle::OpenMenuCallback openMenu) : + _settings{ std::move(settings) }, + _handle{ std::move(openMenu) } +{ + _settings.Normalize(); +} + +PaneHeaderPresentation PaneHeader::Present(const PaneHeaderState& state) const +{ + return PaneHeaderViewModel::Build(state, _settings); +} + +PaneHandle& PaneHeader::Handle() noexcept +{ + return _handle; +} + +const PaneHeaderSettings& PaneHeader::Settings() const noexcept +{ + return _settings; +} + +void PaneHeader::Settings(PaneHeaderSettings settings) +{ + settings.Normalize(); + _settings = std::move(settings); +} diff --git a/src/winterm/PaneControls/PaneHeader.h b/src/winterm/PaneControls/PaneHeader.h new file mode 100644 index 000000000..6b3f29137 --- /dev/null +++ b/src/winterm/PaneControls/PaneHeader.h @@ -0,0 +1,25 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include "PaneHandle.h" +#include "PaneHeaderViewModel.h" + +namespace winTerm::PaneControls +{ + class PaneHeader + { + public: + PaneHeader(PaneHeaderSettings settings, PaneHandle::OpenMenuCallback openMenu = {}); + + PaneHeaderPresentation Present(const PaneHeaderState& state) const; + PaneHandle& Handle() noexcept; + const PaneHeaderSettings& Settings() const noexcept; + void Settings(PaneHeaderSettings settings); + + private: + PaneHeaderSettings _settings; + PaneHandle _handle; + }; +} diff --git a/src/winterm/PaneControls/PaneHeaderSettings.cpp b/src/winterm/PaneControls/PaneHeaderSettings.cpp new file mode 100644 index 000000000..59f4fa054 --- /dev/null +++ b/src/winterm/PaneControls/PaneHeaderSettings.cpp @@ -0,0 +1,52 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" +#include "PaneHeaderSettings.h" + +#include +#include + +using namespace winTerm::PaneControls; + +void PaneHeaderSettings::Normalize() noexcept +{ + if (!std::isfinite(height)) + { + height = 26.0; + } + height = std::clamp(height, 24.0, 30.0); +} + +std::vector PaneHeaderSettings::Validate() const +{ + std::vector errors; + if (!std::isfinite(height) || height < 24.0 || height > 30.0) + { + errors.emplace_back("Pane header height must be between 24 and 30 logical pixels."); + } + return errors; +} + +bool PaneHeaderSettings::ShouldShow(const size_t paneCount) const noexcept +{ + switch (visibility) + { + case PaneHeaderVisibility::Always: + return true; + case PaneHeaderVisibility::Never: + return false; + case PaneHeaderVisibility::Automatic: + default: + return paneCount >= 2; + } +} + +bool PaneHeaderSettings::HasKeyboardAlternative( + const bool commandPaletteAvailable, + const bool keyboardDockingAvailable) const noexcept +{ + return visibility != PaneHeaderVisibility::Never || + commandPaletteAvailable || + keyboardDockingAvailable; +} diff --git a/src/winterm/PaneControls/PaneHeaderSettings.h b/src/winterm/PaneControls/PaneHeaderSettings.h new file mode 100644 index 000000000..44f42b2b2 --- /dev/null +++ b/src/winterm/PaneControls/PaneHeaderSettings.h @@ -0,0 +1,45 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include "../Actions/DirectedSplitAction.h" + +#include +#include +#include + +namespace winTerm::PaneControls +{ + enum class PaneHeaderVisibility + { + Automatic, + Always, + Never, + }; + + enum class AfterSplitFocus + { + FocusNewPane, + KeepCurrentFocus, + }; + + struct PaneHeaderSettings + { + PaneHeaderVisibility visibility{ PaneHeaderVisibility::Automatic }; + double height{ 26.0 }; + bool showPaneTitle{ true }; + bool showProfileIcon{ true }; + bool showOverflowButton{ true }; + bool enablePaneHandleDragging{ true }; + bool showSnapLayoutOverlay{ true }; + bool enableCornerZones{ true }; + AfterSplitFocus afterSplit{ AfterSplitFocus::FocusNewPane }; + Actions::SplitProfilePolicy splitProfile{ Actions::SplitProfilePolicy::DefaultProfile }; + + void Normalize() noexcept; + std::vector Validate() const; + bool ShouldShow(size_t paneCount) const noexcept; + bool HasKeyboardAlternative(bool commandPaletteAvailable, bool keyboardDockingAvailable) const noexcept; + }; +} diff --git a/src/winterm/PaneControls/PaneHeaderViewModel.cpp b/src/winterm/PaneControls/PaneHeaderViewModel.cpp new file mode 100644 index 000000000..e2d263060 --- /dev/null +++ b/src/winterm/PaneControls/PaneHeaderViewModel.cpp @@ -0,0 +1,111 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" +#include "PaneHeaderViewModel.h" + +#include +#include + +using namespace winTerm::PaneControls; + +namespace +{ + bool LooksLikeAbsolutePath(const std::string& value) + { + return (value.size() >= 3 && + std::isalpha(static_cast(value[0])) && + value[1] == ':' && + (value[2] == '\\' || value[2] == '/')) || + (!value.empty() && (value[0] == '/' || value[0] == '\\')); + } + + std::string ResolveAccessibleTitle(const PaneTitleSources& sources) + { + for (const auto* candidate : { + &sources.userTitle, + &sources.shellTitle, + &sources.profileName, + &sources.shellType }) + { + if (!candidate->empty()) + { + return PaneHeaderViewModel::SanitizeTitle(*candidate); + } + } + return "Terminal"; + } +} + +PaneHeaderPresentation PaneHeaderViewModel::Build( + const PaneHeaderState& state, + const PaneHeaderSettings& settings) +{ + PaneHeaderPresentation result; + result.visible = settings.ShouldShow(state.paneCount); + result.height = settings.height; + const auto accessibleTitle = ResolveAccessibleTitle(state.titles); + result.title = settings.showPaneTitle ? ResolveTitle(state.titles) : std::string{}; + + if (state.execution == PaneExecutionState::Running) + { + result.statusText = "Running command"; + } + else if (state.execution == PaneExecutionState::Failed) + { + result.statusText = "Last command failed"; + } + if (state.readOnly) + { + if (!result.statusText.empty()) + { + result.statusText += ". "; + } + result.statusText += "Read-only"; + } + + result.accessibleName = accessibleTitle + " pane"; + result.accessibleName += state.focused ? ". Focused" : ". Not focused"; + if (!result.statusText.empty()) + { + result.accessibleName += ". " + result.statusText; + } + result.gripAccessibleName = "Move " + accessibleTitle + " pane"; + result.showProfileIcon = settings.showProfileIcon && !state.profileIcon.empty(); + result.showOverflowButton = settings.showOverflowButton; + result.dragEnabled = settings.enablePaneHandleDragging; + return result; +} + +std::string PaneHeaderViewModel::ResolveTitle(const PaneTitleSources& sources) +{ + for (const auto* candidate : { + &sources.userTitle, + &sources.shellTitle, + &sources.profileName, + &sources.shellType }) + { + if (!candidate->empty()) + { + auto title = SanitizeTitle(*candidate); + constexpr size_t MaximumTitleLength = 80; + if (title.size() > MaximumTitleLength) + { + title.resize(MaximumTitleLength - 3); + title += "..."; + } + return title; + } + } + return "Terminal"; +} + +std::string PaneHeaderViewModel::SanitizeTitle(std::string title) +{ + if (LooksLikeAbsolutePath(title)) + { + const auto separator = title.find_last_of("\\/"); + title = separator == std::string::npos ? "Terminal" : title.substr(separator + 1); + } + return title; +} diff --git a/src/winterm/PaneControls/PaneHeaderViewModel.h b/src/winterm/PaneControls/PaneHeaderViewModel.h new file mode 100644 index 000000000..a578dd4b7 --- /dev/null +++ b/src/winterm/PaneControls/PaneHeaderViewModel.h @@ -0,0 +1,61 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include "PaneHeaderSettings.h" + +#include +#include + +namespace winTerm::PaneControls +{ + enum class PaneExecutionState + { + Idle, + Running, + Failed, + }; + + struct PaneTitleSources + { + std::string userTitle; + std::string shellTitle; + std::string profileName; + std::string shellType; + }; + + struct PaneHeaderState + { + size_t paneCount{ 1 }; + bool focused{ false }; + bool readOnly{ false }; + PaneExecutionState execution{ PaneExecutionState::Idle }; + PaneTitleSources titles; + std::string profileIcon; + }; + + struct PaneHeaderPresentation + { + bool visible{ false }; + double height{}; + std::string title; + std::string accessibleName; + std::string gripAccessibleName; + std::string overflowAccessibleName{ "Open pane menu" }; + std::string statusText; + bool showProfileIcon{ false }; + bool showOverflowButton{ false }; + bool dragEnabled{ false }; + }; + + class PaneHeaderViewModel + { + public: + static PaneHeaderPresentation Build( + const PaneHeaderState& state, + const PaneHeaderSettings& settings); + static std::string ResolveTitle(const PaneTitleSources& sources); + static std::string SanitizeTitle(std::string title); + }; +} diff --git a/src/winterm/Settings/Docking/DockingSettingsModel.cpp b/src/winterm/Settings/Docking/DockingSettingsModel.cpp index b763faae8..564b769f1 100644 --- a/src/winterm/Settings/Docking/DockingSettingsModel.cpp +++ b/src/winterm/Settings/Docking/DockingSettingsModel.cpp @@ -22,6 +22,7 @@ void DockingSettingsModel::Normalize() noexcept cornerWidthRatio = std::clamp(cornerWidthRatio, 0.2, 0.45); defaultSplitRatio = std::clamp(defaultSplitRatio, 0.2, 0.8); layoutHistorySize = std::clamp(layoutHistorySize, 1, 100); + paneControls.Normalize(); } std::vector DockingSettingsModel::Validate() const @@ -43,6 +44,12 @@ std::vector DockingSettingsModel::Validate() const { errors.emplace_back("Corner docking requires empty layout slots."); } + const auto paneControlErrors = paneControls.Validate(); + errors.insert(errors.end(), paneControlErrors.begin(), paneControlErrors.end()); + if (!paneControls.HasKeyboardAlternative(true, enableKeyboardDockingMode)) + { + errors.emplace_back("Hidden pane headers require a keyboard or Command Palette alternative."); + } return errors; } diff --git a/src/winterm/Settings/Docking/DockingSettingsModel.h b/src/winterm/Settings/Docking/DockingSettingsModel.h index 15c9332d7..7497834c0 100644 --- a/src/winterm/Settings/Docking/DockingSettingsModel.h +++ b/src/winterm/Settings/Docking/DockingSettingsModel.h @@ -3,6 +3,8 @@ #pragma once +#include "../../PaneControls/PaneHeaderSettings.h" + #include #include #include @@ -46,6 +48,8 @@ namespace winTerm::Settings bool showZoneLabels{ true }; bool useHighContrastOverlay{ false }; + PaneControls::PaneHeaderSettings paneControls; + // The UI adapter must opt in only after runtime build and rollback verification. bool enableRuntimeDocking{ false }; diff --git a/src/winterm/Workspaces/Model/WorkspaceDescriptor.h b/src/winterm/Workspaces/Model/WorkspaceDescriptor.h index 19be8a185..3d4cef666 100644 --- a/src/winterm/Workspaces/Model/WorkspaceDescriptor.h +++ b/src/winterm/Workspaces/Model/WorkspaceDescriptor.h @@ -219,7 +219,7 @@ namespace winTerm::Workspaces std::string createdAt; std::string updatedAt; WorkspaceSource source{ WorkspaceSource::User }; - std::string applicationVersion{ "1.0.2" }; + std::string applicationVersion{ "0.7.0-beta.1" }; uint32_t protocolVersion{ 1 }; uint32_t dockingModelVersion{ DockingModelVersion }; WorkspaceStartupBehavior startupBehavior; diff --git a/src/winterm/Workspaces/Persistence/WorkspaceMigration.cpp b/src/winterm/Workspaces/Persistence/WorkspaceMigration.cpp index 1b898e345..04da6061f 100644 --- a/src/winterm/Workspaces/Persistence/WorkspaceMigration.cpp +++ b/src/winterm/Workspaces/Persistence/WorkspaceMigration.cpp @@ -36,7 +36,7 @@ WorkspaceMigrationResult WorkspaceMigration::Migrate(const Json::Value& document result.document.removeMember("workspaceId"); } if (result.document["source"].isNull()) result.document["source"] = "user"; - if (result.document["applicationVersion"].isNull()) result.document["applicationVersion"] = "1.0.2"; + if (result.document["applicationVersion"].isNull()) result.document["applicationVersion"] = "0.7.0-beta.1"; if (result.document["protocolVersion"].isNull()) result.document["protocolVersion"] = 1; if (result.document["description"].isNull()) result.document["description"] = ""; if (result.document["startupBehavior"].isNull()) diff --git a/src/winterm/Workspaces/Persistence/WorkspaceSerializer.cpp b/src/winterm/Workspaces/Persistence/WorkspaceSerializer.cpp index 0920b24c9..e84fb05f7 100644 --- a/src/winterm/Workspaces/Persistence/WorkspaceSerializer.cpp +++ b/src/winterm/Workspaces/Persistence/WorkspaceSerializer.cpp @@ -618,7 +618,7 @@ WorkspaceDescriptor WorkspaceSerializer::FromJson(const Json::Value& json, const throw std::runtime_error("The workspace source is not supported."); } workspace.source = *source; - workspace.applicationVersion = StringOrDefault(json, "applicationVersion", "1.0.2"); + workspace.applicationVersion = StringOrDefault(json, "applicationVersion", "0.7.0-beta.1"); workspace.protocolVersion = UIntOrDefault(json, "protocolVersion", 1); workspace.dockingModelVersion = UIntOrDefault(json, "dockingModelVersion", DockingModelVersion); if (const auto& startup = json["startupBehavior"]; !startup.isNull()) diff --git a/tests/winterm/Docking/ui-automation.scenarios.json b/tests/winterm/Docking/ui-automation.scenarios.json index f78287413..40707baf3 100644 --- a/tests/winterm/Docking/ui-automation.scenarios.json +++ b/tests/winterm/Docking/ui-automation.scenarios.json @@ -1,15 +1,40 @@ { "schemaVersion": 1, - "suite": "winTerm v0.5 local docking UI automation", + "suite": "winTerm v0.7 directed split and pane controls UI automation", "execution": "manual-local", "scenarios": [ "drag-tab-within-window", "drag-tab-to-another-window", "drag-tab-to-edge", + "split-pane-top", + "split-pane-bottom", + "split-pane-left", + "split-pane-right", + "split-focused-nested-pane", + "split-with-selected-profile", + "pane-header-automatic-visibility", + "pane-header-overflow-menu", + "pane-handle-context-menu", + "pane-handle-drag-top", + "pane-handle-drag-bottom", + "pane-handle-drag-left", + "pane-handle-drag-right", + "pane-handle-drag-corner", + "pane-handle-drag-tab-strip", + "pane-handle-drag-new-window", "drag-pane-to-empty-slot", + "terminal-content-drag-does-not-move-pane", + "move-pane-to-new-tab", + "move-pane-to-new-window", + "close-pane-preserves-confirmation", "cancel-with-escape", + "cancel-on-pointer-capture-loss", "keyboard-docking", - "undo-redo" + "undo-redo", + "workspace-restore", + "mixed-dpi-pane-drag", + "narrator-pane-controls", + "high-contrast-pane-controls" ], "privacy": { "captureTerminalOutput": false,