diff --git a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 index 021dec00..cc815ed8 100644 --- a/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 +++ b/Tools/windows/Tests/UiCaptureDriver.Tests.ps1 @@ -3344,7 +3344,7 @@ function Invoke-CapMatchedTabContracts { 'self.appendAccessibilityElement(&elements, &owned_identities, "workspace-tab-close", close_key, "Close tab", 4, .{ .physical = close_bounds }, false, workspace.canCloseTab())') -and $appSource.Contains('const tab_bounds = workspace.visibleTabBounds(tab_index) orelse continue;') -and $appSource.Contains('if (workspace.tabCloseBounds(tab_index) == null or !workspace.canCloseTab()) return false;') -and - $terminalSource.Contains('bounds.right - Dpi.scale(24, self.dpi)') -and + $terminalSource.Contains('bounds.right - Dpi.scale(Tokens.tab_control_width, self.dpi)') -and $terminalSource.Contains('const first = if (selected >= count) selected - count + 1 else 0;') -and $terminalSource -cmatch '(?s)pub fn canCloseTab\(self: \*const Workspace\) bool \{\s*return self\.layout\.tabs\.items\.len > 1;\s*\}' -and $accessibilitySource.Contains('sidebar_scroll: bool = false,') -and @@ -3390,7 +3390,11 @@ function Invoke-CapMatchedTabContracts { ExpectedDpi=192;DpiBase=96} if (-not $isFailedManyFirst) {$arguments.VisibleIndexes=$shape.visible} $proof=Get-CapMatchedTabObservation @arguments - Assert-CapTest ($proof.visible.Count -eq $shape.visible.Count -and $proof.selected -eq $shape.selected) 'Tab shape omitted a visible state.' + Assert-CapTest ($proof.visible.Count -eq $shape.visible.Count -and $proof.selected -eq $shape.selected -and + @($proof.visible | Where-Object {$_.physicalWidth -ne 106 -or $_.physicalHeight -ne 24 -or + $_.logicalWidth -ne 53 -or $_.logicalHeight -ne 12}).Count -eq 0 -and + $proof.geometryContract -ceq 'OWNED_UIA_PHYSICAL_BOUNDS_NORMALIZED_BY_DECLARED_DPI') ` + 'Tab shape omitted a visible state or exact physical/logical geometry.' if ($isFailedSeed) { Assert-CapTest ($proof.visible[0].closeEnabled -and $proof.visible[0].lastTabGuard -ceq 'SOURCE_BOUND_WORKSPACE_CAN_CLOSE_TAB_GT_ONE') 'Exact failed C8R seed did not preserve its source-bound last-tab guard.' @@ -3399,6 +3403,8 @@ function Invoke-CapMatchedTabContracts { selected=$proof.visible[0].selected;closeEnabled=$proof.visible[0].closeEnabled lastTabGuard=$proof.visible[0].lastTabGuard;dpi=192;dpiBase=96 bounds=$proof.visible[0].bounds;closeBounds=$proof.visible[0].closeBounds + physicalWidth=$proof.visible[0].physicalWidth;physicalHeight=$proof.visible[0].physicalHeight + logicalWidth=$proof.visible[0].logicalWidth;logicalHeight=$proof.visible[0].logicalHeight closeBoundsContract=$proof.closeBoundsContract} } if ($isFailedManyFirst) { diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 6f9619a0..8ad1c3b8 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -13,7 +13,9 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate "Test-RetryableUiaError", "Test-UpdateBannerClickTarget", "Get-DynamicProjectCardSnapshotDecision", - "Get-ConnectorDragSnapshotDecision" + "Get-ConnectorDragSnapshotDecision", + "Get-WorkspaceTabMountDecision", + "Get-UiaSelectionEventDecision" )) }, $true)) { . ([scriptblock]::Create($definition.Extent.Text)) } } @@ -42,6 +44,67 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate } throw "RED: multi-project expected actual $prefix rejection" } + $tab = { + param([string] $id, [string] $name, [bool] $selected) + return [ordered]@{ automationId = $id; name = $name; selected = $selected } + } + $initialTab = & $tab "workspace-tab-initial" "Goal" $true + $newTab = & $tab "workspace-tab-new" "Shell" $true + $initialUnselected = & $tab "workspace-tab-initial" "Goal" $false + $newUnselected = & $tab "workspace-tab-new" "Shell" $false + Invoke-MultiCase "DPI192 narrow New Tab overflow still proves both mounted stable tabs" { + $decision = Get-WorkspaceTabMountDecision ` + @($initialTab) @($newTab) @($initialUnselected, $newTab) ` + @($initialTab, $newUnselected) @($initialUnselected, $newTab) + Assert-MultiCase ($decision.ready -and $decision.overflowObserved -and + $decision.initialId -ceq "workspace-tab-initial" -and + $decision.newId -ceq "workspace-tab-new") "narrow mounted-tab round trip was rejected" + return "One visible Shell at expanded DPI192 width, then stable Goal/Shell identities after bounded panel collapse and both selection round trips" + } + Invoke-MultiCase "wide New Tab roster proves the same mounted stable tabs without overflow" { + $decision = Get-WorkspaceTabMountDecision ` + @($initialTab) @($initialUnselected, $newTab) @($initialUnselected, $newTab) ` + @($initialTab, $newUnselected) @($initialUnselected, $newTab) + Assert-MultiCase ($decision.ready -and -not $decision.overflowObserved) ` + "wide mounted-tab round trip was rejected" + return "Two visible tabs before collapse retain the same stable identities and selected roles" + } + foreach ($case in @( + @{ name = "same-id"; after = @(& $tab "workspace-tab-initial" "Shell" $true); mounted = @($initialUnselected, $newTab); first = @($initialTab, $newUnselected); second = @($initialUnselected, $newTab); failure = "new-tab" }, + @{ name = "missing-mounted-initial"; after = @($newTab); mounted = @($newTab); first = @($initialTab, $newUnselected); second = @($initialUnselected, $newTab); failure = "mounted-pair" }, + @{ name = "renamed-mounted-initial"; after = @($newTab); mounted = @((& $tab "workspace-tab-initial" "Agent" $false), $newTab); first = @($initialTab, $newUnselected); second = @($initialUnselected, $newTab); failure = "mounted-pair" }, + @{ name = "initial-switch-did-not-select"; after = @($newTab); mounted = @($initialUnselected, $newTab); first = @($initialUnselected, $newTab); second = @($initialUnselected, $newTab); failure = "initial-switch" }, + @{ name = "new-switch-changed-id"; after = @($newTab); mounted = @($initialUnselected, $newTab); first = @($initialTab, $newUnselected); second = @($initialUnselected, (& $tab "workspace-tab-replaced" "Shell" $true)); failure = "new-switch" }, + @{ name = "duplicate-mounted-selection"; after = @($newTab); mounted = @($initialTab, $newTab); first = @($initialTab, $newUnselected); second = @($initialUnselected, $newTab); failure = "mounted-pair" } + )) { + Invoke-MultiCase "mounted tab refuses permanent $($case.name)" -Negative { + $decision = Get-WorkspaceTabMountDecision ` + @($initialTab) @($case.after) @($case.mounted) @($case.first) @($case.second) + Assert-MultiCase (-not $decision.ready -and $decision.failure -ceq $case.failure) ` + "permanent $($case.name) returned '$($decision.failure)'" + return $decision.failure + } + } + Invoke-MultiCase "selection event accepts exactly one stable owned source" { + $decision = Get-UiaSelectionEventDecision 1 "worktree-row-safe" "42,7,9" ` + "worktree-row-safe" "42,7,9" + Assert-MultiCase $decision.ready "exact stable selection event was rejected" + return "One event, exact automation id, exact runtime id" + } + foreach ($case in @( + @{name="zero";count=0;id="worktree-row-safe";runtime="42,7,9";failure="zero-events"}, + @{name="duplicate";count=2;id="worktree-row-safe";runtime="42,7,9";failure="duplicate-events"}, + @{name="wrong-element";count=1;id="worktree-row-foreign";runtime="42,7,9";failure="wrong-automation-id"}, + @{name="wrong-generation";count=1;id="worktree-row-safe";runtime="42,7,10";failure="wrong-runtime-id"} + )) { + Invoke-MultiCase "selection event refuses $($case.name)" -Negative { + $decision = Get-UiaSelectionEventDecision $case.count $case.id $case.runtime ` + "worktree-row-safe" "42,7,9" + Assert-MultiCase (-not $decision.ready -and $decision.failure -ceq $case.failure) ` + "selection $($case.name) returned '$($decision.failure)'" + return $decision.failure + } + } Invoke-MultiCase "compact overview narrow client retains the Mac lane and requires real pan" { $geometry = Get-MultiProjectLaneGeometry @(220,34,1028,900) @(382,134,632,230) Assert-MultiCase (($geometry.band -join ",") -ceq "300,96,1822,250" -and @@ -4646,7 +4709,8 @@ Start-Sleep -Seconds 60 $uiaLiveGateSource -notmatch 'Write-UiaWaitAttribution "sketch/custody modal: \$title"' -or $uiaLiveGateSource -notmatch 'Write-UiaWaitAttribution "multi-project observation: \$surface"' -or $uiaLiveGateSource -notmatch 'Write-UiaWaitAttribution "gate failure: \$failureStep"' -or - $uiaLiveGateSource -notmatch 'Write-UiaLateEventAttribution "ElementSelected after Select"') { + $uiaLiveGateSource -notmatch 'Write-UiaLateEventAttribution "ElementSelected after Select"' -or + $uiaLiveGateSource -notmatch '(?s)RemoveFromSelection\(\).*?GetSelection\(\)\.Count -eq 0.*?RawView Worktrees event subscription.*?SelectionSourceRuntimeId = \$null.*?\$selectionEventRow = \$safeRow.*?AddAutomationEventHandler\(.*?\$selectionEventDeadlineUtc.*?Get-UiaSelectionEventDecision \$selectedOnTime.*?\$safeRowId \$safeRowRuntimeId.*?Require \$selectedDecision\.ready.*?\$selectedAfterStableDrain.*?RemoveAutomationEventHandler\(\$selectedEvent, \$selectionEventRow') { throw "RED: UIA wait timeouts are not attributed to step, deadline, foreground owner, shell windows, and a shell-only capture" } if ($stubDaemonSource -notmatch '\$frame\.command\.graphCommand\.command\.createNode\._0' -or diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 6b76d265..38a31b02 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -172,8 +172,8 @@ function Assert-WorkspaceTabSelectors { $node.Left.Extent.Text -match '\.Current\.AutomationId$' -and $node.Right.Extent.Text -ceq "'^workspace-tab-[0-9]+$'" }, $true)) - Assert-Contract ($predicates.Count -eq 7) ` - "expected seven actual-tab selectors in the UIA live gate, found $($predicates.Count)" + Assert-Contract ($predicates.Count -eq 10) ` + "expected ten actual-tab selectors in the UIA live gate, found $($predicates.Count)" $graphWait = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and diff --git a/Tools/windows/UI-CAPTURE-SCENARIOS.md b/Tools/windows/UI-CAPTURE-SCENARIOS.md index 4e40f81d..cf541d4a 100644 --- a/Tools/windows/UI-CAPTURE-SCENARIOS.md +++ b/Tools/windows/UI-CAPTURE-SCENARIOS.md @@ -374,6 +374,12 @@ observed UI state, not a `selected` property invented on the daemon's node. | `s-tabs-last-guard` | One selected tab; the exact visible close row is retained, while the phase's source-bound `Workspace.canCloseTab()` contract proves that one tab cannot close. Dynamic UIA rows report enabled independently of that product guard, and capture does not attempt the close. | | `s-restart-confirmed` | Existing Restart session UIA route against an actual isolated daemon, with same-connection protocol and new owned-attach evidence described below. | +Every matched tab observation records each visible tab's owned UIA bounds in +physical pixels and the corresponding logical width and height normalized by +the job's declared DPI. This preserves each baseline phase's observed geometry +while allowing a corrected DPI192 phase to report its native +50-physical/25-logical pill without resampling either image. + The adapter requests eighteen scenes for each before/after phase: **36 original images**, **22 cmd-only seed lifecycles**, and **two real-daemon Restart scenes**. The live transition rehearsal executes the same order and transitions but emits diff --git a/Tools/windows/UiCaptureDriver.psm1 b/Tools/windows/UiCaptureDriver.psm1 index ddbe685e..80b2e82e 100644 --- a/Tools/windows/UiCaptureDriver.psm1 +++ b/Tools/windows/UiCaptureDriver.psm1 @@ -3721,8 +3721,13 @@ function Get-CapMatchedTabObservation { $close[1] -ne $tab[1] -or $close[2] -ne $tab[2] -or $close[3] -ne $tab[3]) { throw 'Matched tabs close bounds escape their exact tab.' } + $physicalWidth=[long]$tab[2]-[long]$tab[0] + $physicalHeight=[long]$tab[3]-[long]$tab[1] $observed.Add([pscustomobject]@{index=$index;automationId=$id;name=$title;selected=$tabs[0].Selected bounds=$tab;closeBounds=$close;closeEnabled=$closes[0].IsEnabled + physicalWidth=$physicalWidth;physicalHeight=$physicalHeight + logicalWidth=[Math]::Round($physicalWidth*$DpiBase/$ExpectedDpi,3) + logicalHeight=[Math]::Round($physicalHeight*$DpiBase/$ExpectedDpi,3) lastTabGuard=if ($TotalTabs -eq 1) {'SOURCE_BOUND_WORKSPACE_CAN_CLOSE_TAB_GT_ONE'} else {'MULTI_TAB_CLOSE_AVAILABLE'} shortcut=if ($index -lt 9) {"Ctrl+$($index+1)"} else {$null}}) } @@ -3738,6 +3743,7 @@ function Get-CapMatchedTabObservation { sourceCommit=$SourceCommit;identityContract='VISIBLE_LAYOUT_INDEX_FNV1A64_ROW_KEY' visibilityContract=$(if ($visibilityDerived) {'DERIVED_CONTIGUOUS_OWNED_VISIBLE_STABLE_IDS'} else {'DECLARED_VISIBLE_STABLE_IDS'}) closeBoundsContract="SOURCE_DPI_SCALED_24_LOGICAL_TO_${expectedCloseWidth}_PHYSICAL" + geometryContract='OWNED_UIA_PHYSICAL_BOUNDS_NORMALIZED_BY_DECLARED_DPI' closeGuardContract='WORKSPACE_CAN_CLOSE_TAB_GT_ONE_NOT_UIA_ISENABLED' evidenceMode='OWNED_UIA_STATE_NOT_GLYPHS_OR_VISUAL_PARITY';hover='VISUAL_ONLY_NOT_EXPOSED_BY_UIA'} } diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 57b74a2e..1d879bc1 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -36,6 +36,7 @@ public static class GraphCodeUiaGateState { public static string NamePropertySourceAutomationId; public static string FocusSourceAutomationId; public static string SelectionSourceAutomationId; + public static string SelectionSourceRuntimeId; public static string TogglePropertySourceAutomationId; public static readonly AutomationEventHandler LiveHandler = HandleLive; public static readonly AutomationPropertyChangedEventHandler NamePropertyHandler = HandleNameProperty; @@ -66,17 +67,26 @@ public static class GraphCodeUiaGateState { } private static void HandleSelected(object sender, AutomationEventArgs eventArgs) { var element = sender as AutomationElement; - if (element != null) SelectionSourceAutomationId = element.Current.AutomationId; + if (element != null) { + SelectionSourceAutomationId = element.Current.AutomationId; + SelectionSourceRuntimeId = String.Join(",", element.GetRuntimeId()); + } SelectedEvents++; } private static void HandleAdded(object sender, AutomationEventArgs eventArgs) { var element = sender as AutomationElement; - if (element != null) SelectionSourceAutomationId = element.Current.AutomationId; + if (element != null) { + SelectionSourceAutomationId = element.Current.AutomationId; + SelectionSourceRuntimeId = String.Join(",", element.GetRuntimeId()); + } AddedEvents++; } private static void HandleRemoved(object sender, AutomationEventArgs eventArgs) { var element = sender as AutomationElement; - if (element != null) SelectionSourceAutomationId = element.Current.AutomationId; + if (element != null) { + SelectionSourceAutomationId = element.Current.AutomationId; + SelectionSourceRuntimeId = String.Join(",", element.GetRuntimeId()); + } RemovedEvents++; } private static void HandleToggleProperty(object sender, AutomationPropertyChangedEventArgs eventArgs) { @@ -2582,6 +2592,111 @@ function Wait-ForGraphChildren( return [pscustomobject]@{ Graph = $liveGraph; Items = $observed; Children = $children } } +function Get-WorkspaceTabMountDecision( + [object[]] $Before, + [object[]] $AfterNew, + [object[]] $Mounted, + [object[]] $SwitchedInitial, + [object[]] $SwitchedNew +) { + $reject = { + param([string] $failure) + return [pscustomobject]@{ ready = $false; failure = $failure } + } + $validRows = { + param([object[]] $rows) + if ($rows -isnot [array]) { return $false } + foreach ($row in $rows) { + if ($row -isnot [Collections.IDictionary] -or + @($row.Keys | Where-Object { + @("automationId", "name", "selected") -cnotcontains $_ + }).Count -ne 0 -or + $row.automationId -isnot [string] -or $row.automationId.Length -eq 0 -or + $row.name -isnot [string] -or $row.selected -isnot [bool]) { + return $false + } + } + return $true + } + foreach ($rows in @($Before, $AfterNew, $Mounted, $SwitchedInitial, $SwitchedNew)) { + if (-not (& $validRows @($rows))) { return & $reject "row-shape" } + } + if ($Before.Count -ne 1 -or $Before[0].name -cne "Goal" -or -not $Before[0].selected) { + return & $reject "initial-tab" + } + $initialId = $Before[0].automationId + if ($AfterNew.Count -lt 1 -or $AfterNew.Count -gt 2) { + return & $reject "post-new-visible-count" + } + $newRows = @($AfterNew | Where-Object { $_.name -ceq "Shell" }) + if ($newRows.Count -ne 1 -or -not $newRows[0].selected -or + $newRows[0].automationId -ceq $initialId) { + return & $reject "new-tab" + } + $newId = $newRows[0].automationId + if ($AfterNew.Count -eq 2) { + $retained = @($AfterNew | Where-Object { $_.automationId -ceq $initialId }) + if ($retained.Count -ne 1 -or $retained[0].name -cne "Goal" -or $retained[0].selected) { + return & $reject "post-new-initial" + } + } + $checkPair = { + param([object[]] $rows, [string] $selectedId, [string] $failure) + if ($rows.Count -ne 2) { return $failure } + if (@($rows | Where-Object { $_.automationId -ceq $initialId -and $_.name -ceq "Goal" }).Count -ne 1 -or + @($rows | Where-Object { $_.automationId -ceq $newId -and $_.name -ceq "Shell" }).Count -ne 1 -or + @($rows | Where-Object { $_.selected }).Count -ne 1 -or + @($rows | Where-Object { $_.automationId -ceq $selectedId -and $_.selected }).Count -ne 1) { + return $failure + } + return $null + } + $failure = & $checkPair $Mounted $newId "mounted-pair" + if ($null -ne $failure) { return & $reject $failure } + $failure = & $checkPair $SwitchedInitial $initialId "initial-switch" + if ($null -ne $failure) { return & $reject $failure } + $failure = & $checkPair $SwitchedNew $newId "new-switch" + if ($null -ne $failure) { return & $reject $failure } + return [pscustomobject]@{ + ready = $true + failure = $null + initialId = $initialId + newId = $newId + overflowObserved = $AfterNew.Count -eq 1 + } +} + +function Get-WorkspaceTabState([object[]] $Elements) { + return @($Elements | ForEach-Object { + $selection = $_.GetCurrentPattern( + [System.Windows.Automation.SelectionItemPattern]::Pattern + ) + [ordered]@{ + automationId = $_.Current.AutomationId + name = $_.Current.Name + selected = [bool]$selection.Current.IsSelected + } + }) +} + +function Get-UiaSelectionEventDecision( + [int] $Count, + [AllowNull()][string] $SourceAutomationId, + [AllowNull()][string] $SourceRuntimeId, + [string] $ExpectedAutomationId, + [string] $ExpectedRuntimeId +) { + if ($Count -eq 0) { return [pscustomobject]@{ ready=$false; failure='zero-events' } } + if ($Count -ne 1) { return [pscustomobject]@{ ready=$false; failure='duplicate-events' } } + if ($SourceAutomationId -cne $ExpectedAutomationId) { + return [pscustomobject]@{ ready=$false; failure='wrong-automation-id' } + } + if ($SourceRuntimeId -cne $ExpectedRuntimeId) { + return [pscustomobject]@{ ready=$false; failure='wrong-runtime-id' } + } + return [pscustomobject]@{ ready=$true; failure=$null } +} + function Get-DynamicProjectCardSnapshotDecision( [string] $surface, [object[]] $cards, @@ -7925,18 +8040,42 @@ try { $_.Current.AutomationId -match '^workspace-tab-[0-9]+$' }) Require ($workspaceTabs.Count -ge 1) "workspace omitted its initial terminal tab before the mounted-tab preservation check" - $initialWorkspaceTabId = $workspaceTabs[0].Current.AutomationId + $initialTabState = @(Get-WorkspaceTabState $workspaceTabs) + $initialWorkspaceTabId = $initialTabState[0].automationId Require ($null -ne $newTab) "workspace omitted New Tab before mounted-tab preservation check" $newTab.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() - $mountedProbe = Wait-ForGraphChildren $root $rawWalker ` + # At DPI192 the initial 1280-physical-pixel window has only 627 logical client + # pixels. With the detail panel expanded, the Mac-sized tab/control chrome has + # room for exactly the selected tab, so the original Goal correctly leaves the + # visible roster after New Tab selects Shell. Observe that bounded overflow + # state first, then collapse the detail panel to expose both mounted tabs. + $afterNewProbe = Wait-ForGraphChildren $root $rawWalker ` { $_.Current.AutomationId -match '^workspace-tab-[0-9]+$' } ` - { param($items) $items.Count -ge 2 } + { param($items) + @($items | Where-Object { $_.Current.Name -ceq "Shell" }).Count -eq 1 } + $graph = $afterNewProbe.Graph + $afterNewTabs = @($afterNewProbe.Items) + $afterNewState = @(Get-WorkspaceTabState $afterNewTabs) + $collapseToggle = @($afterNewProbe.Children | Where-Object { + $_.Current.AutomationId -match '^workspace-toggle-panel-' -and + $_.Current.Name -eq "Collapse loop panel" + }) | Select-Object -First 1 + Require ($null -ne $collapseToggle) ` + "New Tab did not retain the expanded detail-panel control needed to reveal the mounted roster" + $collapseToggle.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $mountedProbe = Wait-ForGraphChildren $root $rawWalker ` + { ($_.Current.AutomationId -match '^workspace-tab-[0-9]+$') -or + ($_.Current.AutomationId -match '^workspace-toggle-panel-' -and + $_.Current.Name -eq "Expand loop panel") } ` + { param($items) + (@($items | Where-Object { $_.Current.AutomationId -match '^workspace-tab-[0-9]+$' }).Count -eq 2) -and + (@($items | Where-Object { $_.Current.Name -eq "Expand loop panel" }).Count -eq 1) } $graph = $mountedProbe.Graph - $workspaceTabs = @($mountedProbe.Items) + $workspaceTabs = @($mountedProbe.Items | Where-Object { + $_.Current.AutomationId -match '^workspace-tab-[0-9]+$' + }) Require ($workspaceTabs.Count -ge 2) "New Tab did not expose a mounted background tab" - Require (($workspaceTabs[0].Current.Name -ceq "Goal") -and - ($workspaceTabs[1].Current.Name -ceq "Shell")) ` - "New Tab did not retain the Goal title and expose the new Shell title" + $mountedState = @(Get-WorkspaceTabState $workspaceTabs) $workspaceCloseButtons = @($mountedProbe.Children | Where-Object { $_.Current.AutomationId -match '^workspace-tab-close-[0-9]+$' }) @@ -7952,7 +8091,7 @@ try { Require (($closeChild.Current.Name -ceq "Close tab") -and ($owners.Count -eq 1)) ` "workspace close child $($closeChild.Current.AutomationId) was not inside exactly one actual tab" } - $newWorkspaceTabId = $workspaceTabs[1].Current.AutomationId + $newWorkspaceTabId = @($mountedState | Where-Object { $_.name -ceq "Shell" })[0].automationId $workspaceTabs[0].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() # Wait for both tabs to be present again after the switch remounts them, then assert # identity. The wait covers only the precondition (two tabs exist); it deliberately @@ -7962,21 +8101,23 @@ try { { param($items) $items.Count -ge 2 } $graph = $switchProbe.Graph $workspaceTabs = @($switchProbe.Items) - Require (($workspaceTabs[0].Current.AutomationId -eq $initialWorkspaceTabId) -and - ($workspaceTabs[1].Current.AutomationId -eq $newWorkspaceTabId)) ` - "switching to the mounted background tab changed terminal tab identity" + $switchedInitialState = @(Get-WorkspaceTabState $workspaceTabs) $workspaceTabs[1].GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() $switchBackProbe = Wait-ForGraphChildren $root $rawWalker ` { $_.Current.AutomationId -match '^workspace-tab-[0-9]+$' } ` { param($items) $items.Count -ge 2 } $graph = $switchBackProbe.Graph $workspaceTabs = @($switchBackProbe.Items) - Require (($workspaceTabs[0].Current.AutomationId -eq $initialWorkspaceTabId) -and - ($workspaceTabs[1].Current.AutomationId -eq $newWorkspaceTabId)) ` - "switching back from the mounted background tab changed terminal tab identity" + $switchedNewState = @(Get-WorkspaceTabState $workspaceTabs) + $tabMountDecision = Get-WorkspaceTabMountDecision ` + $initialTabState $afterNewState $mountedState $switchedInitialState $switchedNewState + Require $tabMountDecision.ready ` + "mounted terminal tab contract failed permanently: $($tabMountDecision.failure)" Write-Host ("UIA_WORKSPACE_TAB_EVIDENCE=" + ([ordered]@{ owner = $expectedWorkspaceLoopBarId parent = "graph" + overflowObserved = $tabMountDecision.overflowObserved + afterNew = $afterNewState tabs = @($workspaceTabs | ForEach-Object { $rect = $_.Current.BoundingRectangle Require (($rect.Width -gt 0) -and ($rect.Height -gt 0)) ` @@ -8121,46 +8262,91 @@ try { $unsafeRow = @($rawRows | Where-Object { $_.Current.Name -eq $fixtureUnsafeName })[0] $safeFocusRow = @($controlRows | Where-Object { $_.Current.Name -eq $fixtureSafeName })[0] Require (($null -ne $safeRow) -and ($null -ne $unsafeRow) -and ($null -ne $safeFocusRow)) "missing fixture worktree rows" + # Selection from earlier navigation is valid but makes Select() idempotent. Clear it + # before subscribing, then reacquire the live dynamic-provider generation so the + # exact-once assertion binds the transition it initiates rather than stale state. + $safeRow.GetCurrentPattern( + [System.Windows.Automation.SelectionItemPattern]::Pattern + ).RemoveFromSelection() + for ($index = 0; $index -lt 20 -and + $selection.Current.GetSelection().Count -ne 0; $index++) { + Start-Sleep -Milliseconds 50 + } + Require ($selection.Current.GetSelection().Count -eq 0) ` + "safe fixture row could not establish an empty selection before event subscription" + $worktrees = Find-FragmentById $root "worktrees" $rawWalker + Require ($null -ne $worktrees) "Worktrees disappeared before selection event subscription" + $rawRows = @(Assert-FragmentLinks $worktrees $rawWalker $initialRowIds "RawView Worktrees event subscription") + $controlRows = @(Assert-FragmentLinks $worktrees $controlWalker $initialRowIds "ControlView Worktrees event subscription") + $selection = $worktrees.GetCurrentPattern([System.Windows.Automation.SelectionPattern]::Pattern) + $safeRow = @($rawRows | Where-Object { $_.Current.Name -eq $fixtureSafeName })[0] + $unsafeRow = @($rawRows | Where-Object { $_.Current.Name -eq $fixtureUnsafeName })[0] + $safeFocusRow = @($controlRows | Where-Object { $_.Current.Name -eq $fixtureSafeName })[0] + Require (($null -ne $safeRow) -and ($null -ne $unsafeRow) -and ($null -ne $safeFocusRow)) ` + "fixture worktree rows disappeared before selection event subscription" $safeRowId = $safeRow.Current.AutomationId $safeRowRuntimeId = Get-RuntimeIdentity $safeRow $safeSelection = $safeRow.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) $unsafeSelection = $unsafeRow.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern) + Require ((-not $safeSelection.Current.IsSelected) -and + ($selection.Current.GetSelection().Count -eq 0)) ` + "selection event subscription did not bind an empty stable provider generation" [GraphCodeUiaGateState]::SelectedEvents = 0 [GraphCodeUiaGateState]::AddedEvents = 0 [GraphCodeUiaGateState]::RemovedEvents = 0 [GraphCodeUiaGateState]::SelectionSourceAutomationId = $null + [GraphCodeUiaGateState]::SelectionSourceRuntimeId = $null $selectedEvent = [System.Windows.Automation.SelectionItemPattern]::ElementSelectedEvent $addedEvent = [System.Windows.Automation.SelectionItemPattern]::ElementAddedToSelectionEvent $removedEvent = [System.Windows.Automation.SelectionItemPattern]::ElementRemovedFromSelectionEvent $selectedHandler = [GraphCodeUiaGateState]::SelectedHandler $addedHandler = [GraphCodeUiaGateState]::AddedHandler $removedHandler = [GraphCodeUiaGateState]::RemovedHandler + $selectionEventRow = $safeRow [System.Windows.Automation.Automation]::AddAutomationEventHandler( - $selectedEvent, $safeRow, [System.Windows.Automation.TreeScope]::Element, $selectedHandler + $selectedEvent, $selectionEventRow, [System.Windows.Automation.TreeScope]::Element, $selectedHandler ) [System.Windows.Automation.Automation]::AddAutomationEventHandler( - $addedEvent, $safeRow, [System.Windows.Automation.TreeScope]::Element, $addedHandler + $addedEvent, $selectionEventRow, [System.Windows.Automation.TreeScope]::Element, $addedHandler ) [System.Windows.Automation.Automation]::AddAutomationEventHandler( - $removedEvent, $safeRow, [System.Windows.Automation.TreeScope]::Element, $removedHandler + $removedEvent, $selectionEventRow, [System.Windows.Automation.TreeScope]::Element, $removedHandler ) try { $selectionEventStartedUtc = [DateTime]::UtcNow + $selectionEventDeadlineUtc = $selectionEventStartedUtc.AddMilliseconds(1000) $safeSelection.Select() - for ($index = 0; $index -lt 20 -and [GraphCodeUiaGateState]::SelectedEvents -lt 1; $index++) { + while ([DateTime]::UtcNow -lt $selectionEventDeadlineUtc -and + [GraphCodeUiaGateState]::SelectedEvents -lt 1) { Start-Sleep -Milliseconds 50 } + if ([GraphCodeUiaGateState]::SelectedEvents -ge 1) { + $drainDeadline = [DateTime]::UtcNow.AddMilliseconds(150) + if ($drainDeadline -gt $selectionEventDeadlineUtc) { + $drainDeadline = $selectionEventDeadlineUtc + } + while ([DateTime]::UtcNow -lt $drainDeadline) { Start-Sleep -Milliseconds 25 } + } $selectedOnTime = [GraphCodeUiaGateState]::SelectedEvents - if ($selectedOnTime -ne 1) { + $selectedDecision = Get-UiaSelectionEventDecision $selectedOnTime ` + ([GraphCodeUiaGateState]::SelectionSourceAutomationId) ` + ([GraphCodeUiaGateState]::SelectionSourceRuntimeId) $safeRowId $safeRowRuntimeId + if (-not $selectedDecision.ready) { Write-UiaLateEventAttribution "ElementSelected after Select" $selectionEventStartedUtc 1000 ` { [GraphCodeUiaGateState]::SelectedEvents -ge 1 } $process.MainWindowHandle } - Require (($safeSelection.Current.IsSelected) -and - ($selectedOnTime -eq 1)) "Select did not raise ElementSelected exactly once" - $safeSelection.Select() - Start-Sleep -Milliseconds 150 - $selectedAfterRepeat = [GraphCodeUiaGateState]::SelectedEvents - Require ($selectedAfterRepeat -eq 1) "idempotent Select raised a duplicate event" + Write-Host ("UIA_SELECTION_EVENT_DECISION=" + ([ordered]@{ + count=$selectedOnTime + sourceAutomationId=[GraphCodeUiaGateState]::SelectionSourceAutomationId + sourceRuntimeId=[GraphCodeUiaGateState]::SelectionSourceRuntimeId + expectedAutomationId=$safeRowId + expectedRuntimeId=$safeRowRuntimeId + ready=[bool]$selectedDecision.ready + failure=[string]$selectedDecision.failure + } | ConvertTo-Json -Compress)) + Require $selectedDecision.ready ` + "Select ElementSelected contract failed: $($selectedDecision.failure)" + $selectedAfterStableDrain = [GraphCodeUiaGateState]::SelectedEvents $unsafeRejected = $false try { $unsafeSelection.Select() } catch { $unsafeRejected = $true } Require $unsafeRejected "unsafe SelectionItem.Select was accepted" @@ -8177,10 +8363,9 @@ try { $selectionCountAfterRemove = $selection.Current.GetSelection().Count Require (($selectionCountAfterRemove -eq 0) -and ($removedOnTime -eq 1)) "RemoveFromSelection did not raise ElementRemovedFromSelection" - $safeSelection.RemoveFromSelection() Start-Sleep -Milliseconds 150 - $removedAfterRepeat = [GraphCodeUiaGateState]::RemovedEvents - Require ($removedAfterRepeat -eq 1) "idempotent RemoveFromSelection raised a duplicate event" + $removedAfterStableDrain = [GraphCodeUiaGateState]::RemovedEvents + Require ($removedAfterStableDrain -eq 1) "RemoveFromSelection raised a delayed duplicate event" $selectionEventStartedUtc = [DateTime]::UtcNow $safeSelection.AddToSelection() for ($index = 0; $index -lt 20 -and [GraphCodeUiaGateState]::AddedEvents -lt 1; $index++) { @@ -8192,10 +8377,9 @@ try { { [GraphCodeUiaGateState]::AddedEvents -ge 1 } $process.MainWindowHandle } Require ($addedOnTime -eq 1) "AddToSelection did not raise ElementAddedToSelection" - $safeSelection.AddToSelection() Start-Sleep -Milliseconds 150 - $addedAfterRepeat = [GraphCodeUiaGateState]::AddedEvents - Require ($addedAfterRepeat -eq 1) "idempotent AddToSelection raised a duplicate event" + $addedAfterStableDrain = [GraphCodeUiaGateState]::AddedEvents + Require ($addedAfterStableDrain -eq 1) "AddToSelection raised a delayed duplicate event" $selectionEventStartedUtc = [DateTime]::UtcNow $safeSelection.RemoveFromSelection() for ($index = 0; $index -lt 20 -and [GraphCodeUiaGateState]::RemovedEvents -lt 2; $index++) { @@ -8231,9 +8415,9 @@ try { Require ($removedOnTime -eq 3) "App mouse selection did not raise ElementRemovedFromSelection" Require ([GraphCodeUiaGateState]::SelectionSourceAutomationId -eq $safeRowId) "selection event source identity changed" } finally { - [System.Windows.Automation.Automation]::RemoveAutomationEventHandler($selectedEvent, $safeRow, $selectedHandler) - [System.Windows.Automation.Automation]::RemoveAutomationEventHandler($addedEvent, $safeRow, $addedHandler) - [System.Windows.Automation.Automation]::RemoveAutomationEventHandler($removedEvent, $safeRow, $removedHandler) + [System.Windows.Automation.Automation]::RemoveAutomationEventHandler($selectedEvent, $selectionEventRow, $selectedHandler) + [System.Windows.Automation.Automation]::RemoveAutomationEventHandler($addedEvent, $selectionEventRow, $addedHandler) + [System.Windows.Automation.Automation]::RemoveAutomationEventHandler($removedEvent, $selectionEventRow, $removedHandler) } $selectionEventEvidence = @{ selected = [GraphCodeUiaGateState]::SelectedEvents @@ -8242,9 +8426,9 @@ try { source = [GraphCodeUiaGateState]::SelectionSourceAutomationId } $repeatedSelectionEvidence = [ordered]@{ - selectedAfterRepeatSelect = $selectedAfterRepeat - removedAfterRepeatRemove = $removedAfterRepeat - addedAfterRepeatAdd = $addedAfterRepeat + selectedAfterStableDrain = $selectedAfterStableDrain + removedAfterStableDrain = $removedAfterStableDrain + addedAfterStableDrain = $addedAfterStableDrain } Write-Host ("UIA_WORKTREE_SELECTION_EVIDENCE=" + ([ordered]@{ fixtureRows = $initialRowIds.Count diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index 5be67fdd..4a4d663e 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -35,6 +35,12 @@ graph command. Workspace tab pills share the available strip width, with a 92-logical-pixel minimum where space permits and a selected-tab window when they overflow. +The native strip follows the macOS logical chrome measurements at every monitor +DPI: 25-point tab pills inside a 37-point strip, 22-point pane headers, +24-by-22-point split/New Tab controls, and a 24-point close target. A +1440-logical client keeps the normal overflow window; expanding to the +Mac-observed 1508-logical width admits all twelve minimum-width tabs without +rescaling or cropping the terminal surface. Agent names follow the primary pane: **Goal**, **Loop**, or the recorded backend's display name; plain-shell tabs are **Shell**. Missing backend metadata is labeled **Agent**, never assumed to be Claude Code. `Ctrl+1` through `Ctrl+9` select those diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index fe2d39c7..6d172279 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -9077,7 +9077,7 @@ pub const App = struct { } } for (0..3) |control_index| { - const control_bounds = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, control_index); + const control_bounds = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, workspace.dpi, control_index); const action = TerminalSurface.chromeActionForIndex(control_index) orelse unreachable; const kind: []const u8 = switch (action) { .split_right => "workspace-split-right", @@ -16436,14 +16436,14 @@ const LiveTerminalFixture = struct { fn newTab(self: *LiveTerminalFixture) !void { const workspace = &self.workspace; - const bounds = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, 2); + const bounds = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, workspace.dpi, 2); try nativeClickCenter(&self.app, bounds); try expectNoTerminalFailure(&self.app); } fn splitRight(self: *LiveTerminalFixture) !void { const workspace = &self.workspace; - const bounds = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, 0); + const bounds = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, workspace.dpi, 0); try nativeClickCenter(&self.app, bounds); try expectNoTerminalFailure(&self.app); } @@ -17878,7 +17878,7 @@ test "workspace surface: native New Tab publishes every drawn tab to UIA and sta try fixture.waitFor(LiveTerminalFixture.shows, loop); try std.testing.expectEqual(@as(usize, 1), workspace.tabCount()); - const new_tab = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, 2); + const new_tab = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, workspace.dpi, 2); try nativeClickCenter(app, new_tab); try std.testing.expectEqual(@as(usize, 2), workspace.tabCount()); try std.testing.expectEqual(workspace.tabCount(), ReopenProbe.uia_tabs); @@ -20174,7 +20174,7 @@ test "workspace surface: sidebar loop opens, New Tab, and Split Right attach vis try expectSameDirectory(fixture.worktree, loop_started_in); // Native New Tab: a plain shell with no session behind it, started in the loop's worktree. - const new_tab = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, 2); + const new_tab = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, workspace.dpi, 2); try nativeClickCenter(app, new_tab); try expectNoTerminalFailure(app); try std.testing.expectEqual(@as(usize, 2), workspace.tabCount()); @@ -20187,7 +20187,7 @@ test "workspace surface: sidebar loop opens, New Tab, and Split Right attach vis try fixture.expectTerminalsInWorkspace(1); // Native Split Right of that shell: a second visible terminal beside it. - const split_right = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, 0); + const split_right = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, workspace.dpi, 0); try nativeClickCenter(app, split_right); try expectNoTerminalFailure(app); const selected = workspace.layout.selectedConst() orelse return error.TestExpectedTab; @@ -22430,12 +22430,12 @@ test "loop detail UIA rectangles equal painted bounds and omit empty sparklines" test "DPI UIA terminal tab close and controls retain physical geometry" { try expectDpiAccessibility(.workspace, null, &.{ - .{ .identity = "workspace-tab:0", .name = "Agent", .bounds = .{ .{ 228, 84, 462, 106 }, .{ 342, 124, 748, 146 }, .{ 456, 164, 1034, 186 } } }, - .{ .identity = "workspace-tab:1", .name = "Shell", .bounds = .{ .{ 466, 84, 700, 106 }, .{ 754, 124, 1160, 146 }, .{ 1042, 164, 1620, 186 } } }, - .{ .identity = "workspace-tab-close:1", .bounds = .{ .{ 676, 84, 700, 106 }, .{ 1124, 124, 1160, 146 }, .{ 1572, 164, 1620, 186 } } }, - .{ .identity = "workspace-split-right:control", .bounds = .{ .{ 708, 83, 776, 107 }, .{ 1172, 123, 1240, 147 }, .{ 1636, 163, 1704, 187 } } }, - .{ .identity = "workspace-split-down:control", .bounds = .{ .{ 780, 83, 848, 107 }, .{ 1244, 123, 1312, 147 }, .{ 1708, 163, 1776, 187 } } }, - .{ .identity = "workspace-new-tab:control", .bounds = .{ .{ 852, 83, 920, 107 }, .{ 1316, 123, 1384, 147 }, .{ 1780, 163, 1848, 187 } } }, + .{ .identity = "workspace-tab:0", .name = "Agent", .bounds = .{ .{ 228, 86, 520, 111 }, .{ 342, 129, 780, 167 }, .{ 456, 172, 1040, 222 } } }, + .{ .identity = "workspace-tab:1", .name = "Shell", .bounds = .{ .{ 524, 86, 816, 111 }, .{ 786, 129, 1224, 167 }, .{ 1048, 172, 1632, 222 } } }, + .{ .identity = "workspace-tab-close:1", .bounds = .{ .{ 792, 86, 816, 111 }, .{ 1188, 129, 1224, 167 }, .{ 1584, 172, 1632, 222 } } }, + .{ .identity = "workspace-split-right:control", .bounds = .{ .{ 844, 86, 868, 108 }, .{ 1266, 129, 1302, 162 }, .{ 1688, 172, 1736, 216 } } }, + .{ .identity = "workspace-split-down:control", .bounds = .{ .{ 870, 86, 894, 108 }, .{ 1305, 129, 1341, 162 }, .{ 1740, 172, 1788, 216 } } }, + .{ .identity = "workspace-new-tab:control", .bounds = .{ .{ 896, 86, 920, 108 }, .{ 1344, 129, 1380, 162 }, .{ 1792, 172, 1840, 216 } } }, }); } @@ -25687,19 +25687,19 @@ test "Quick Chat parity UIA and Show in Graph preserve chat-only workspace actio .identity = "workspace-tab:0", .present = true, .name = "Shell", - .bounds = .{ .{ 8, 4, 246, 26 }, .{ 8, 4, 246, 26 }, .{ 8, 4, 246, 26 } }, + .bounds = .{ .{ 8, 6, 285, 31 }, .{ 8, 6, 285, 31 }, .{ 8, 6, 285, 31 } }, }, .{ .identity = "workspace-tab:1", .present = true, .name = "Copilot CLI", - .bounds = .{ .{ 250, 4, 489, 26 }, .{ 250, 4, 489, 26 }, .{ 250, 4, 489, 26 } }, + .bounds = .{ .{ 289, 6, 566, 31 }, .{ 289, 6, 566, 31 }, .{ 289, 6, 566, 31 } }, }, .{ .identity = "workspace-tab:2", .present = true, .name = "Copilot CLI", - .bounds = .{ .{ 493, 4, 732, 26 }, .{ 493, 4, 732, 26 }, .{ 493, 4, 732, 26 } }, + .bounds = .{ .{ 570, 6, 848, 31 }, .{ 570, 6, 848, 31 }, .{ 570, 6, 848, 31 } }, }, .{ .identity = "workspace-restart-session:chat-b", diff --git a/graphcode-windows/src/DesignTokens.zig b/graphcode-windows/src/DesignTokens.zig index c2d59d38..0244e962 100644 --- a/graphcode-windows/src/DesignTokens.zig +++ b/graphcode-windows/src/DesignTokens.zig @@ -107,7 +107,19 @@ pub const workspace_rail_width: i32 = 212; pub const loop_bar_height: i32 = 46; pub const loop_detail_width: i32 = 272; pub const pane_header_height: i32 = 22; -pub const tab_bar_height: i32 = 30; +// LoopWorkspaceView.swift gives the 15-point tab title 5 points of vertical +// padding inside the pill and 6 more around the strip. Keep these logical +// measurements distinct so the native shell scales them exactly once. +pub const tab_pill_height: i32 = 25; +pub const tab_bar_height: i32 = 37; +pub const tab_bar_inset: i32 = 8; +pub const tab_bar_vertical_padding: i32 = 6; +pub const tab_gap: i32 = 4; +pub const tab_min_width: i32 = 92; +pub const tab_controls_gap: i32 = 20; +pub const tab_control_width: i32 = 24; +pub const tab_control_height: i32 = 22; +pub const tab_control_gap: i32 = 2; pub const canvas_grid_cell: i32 = 48; // Shared dark dialog/sheet palette. Values match the already-validated diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index f2141e1c..e8b2efca 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -163,29 +163,32 @@ fn paneBounds( origin_y: i32, width: i32, height: i32, + dpi: u32, direction: WorkspaceLayout.Direction, position: usize, pane_count: usize, ) c.winghostty_rect { - if (width <= 0 or height <= Tokens.tab_bar_height + Tokens.pane_header_height or + const tab_bar_height = Dpi.scale(Tokens.tab_bar_height, dpi); + const pane_header_height = Dpi.scale(Tokens.pane_header_height, dpi); + if (width <= 0 or height <= tab_bar_height + pane_header_height or pane_count == 0 or position >= pane_count) { return .{ .x = origin_x, .y = origin_y, .width = 0, .height = 0 }; } const horizontal = direction == .horizontal; - const available_height = height - Tokens.tab_bar_height; + const available_height = height - tab_bar_height; const span = if (horizontal) width else available_height; const first = @divTrunc(@as(i64, span) * @as(i64, @intCast(position)), @as(i64, @intCast(pane_count))); const next = @divTrunc(@as(i64, span) * @as(i64, @intCast(position + 1)), @as(i64, @intCast(pane_count))); const pane_width = if (horizontal) next - first else width; // LoopWorkspaceView.swift:318-331 puts a header inside each split-tree leaf, // including vertically stacked panes, rather than reserving one per tab. - const pane_height = (if (horizontal) available_height else next - first) - Tokens.pane_header_height; + const pane_height = (if (horizontal) available_height else next - first) - pane_header_height; if (pane_width <= 0 or pane_height <= 0) return .{ .x = origin_x, .y = origin_y, .width = 0, .height = 0 }; return .{ .x = origin_x + if (horizontal) @as(i32, @intCast(first)) else 0, - .y = origin_y + Tokens.tab_bar_height + Tokens.pane_header_height + if (horizontal) @as(i32, 0) else @as(i32, @intCast(first)), + .y = origin_y + tab_bar_height + pane_header_height + if (horizontal) @as(i32, 0) else @as(i32, @intCast(first)), .width = @intCast(pane_width), .height = @intCast(pane_height), }; @@ -252,10 +255,10 @@ pub fn chromeActionForIndex(index: usize) ?ChromeAction { }; } -fn chromeActionForBounds(origin_x: i32, origin_y: i32, width: i32, x: i32, y: i32) ?ChromeAction { +fn chromeActionForBounds(origin_x: i32, origin_y: i32, width: i32, dpi: u32, x: i32, y: i32) ?ChromeAction { if (x < origin_x or x >= origin_x + width) return null; for (0..3) |index| { - const bounds = chromeControlBounds(origin_x, origin_y, width, index); + const bounds = chromeControlBounds(origin_x, origin_y, width, dpi, index); if (x >= bounds.left and x < bounds.right and y >= bounds.top and y < bounds.bottom) { return chromeActionForIndex(index); } @@ -263,21 +266,30 @@ fn chromeActionForBounds(origin_x: i32, origin_y: i32, width: i32, x: i32, y: i3 return null; } -pub fn chromeControlBounds(origin_x: i32, origin_y: i32, width: i32, index: usize) c.RECT { - const left = @max(origin_x, origin_x + width - 220) + @as(i32, @intCast(index)) * 72; - return .{ .left = left, .top = origin_y + 3, .right = left + 68, .bottom = origin_y + Tokens.tab_bar_height - 3 }; +pub fn chromeControlBounds(origin_x: i32, origin_y: i32, width: i32, dpi: u32, index: usize) c.RECT { + const inset = Dpi.scale(Tokens.tab_bar_inset, dpi); + const control_width = Dpi.scale(Tokens.tab_control_width, dpi); + const control_height = Dpi.scale(Tokens.tab_control_height, dpi); + const gap = Dpi.scale(Tokens.tab_control_gap, dpi); + const group_width = control_width * 3 + gap * 2; + const group_left = @max(origin_x, origin_x + width - inset - group_width); + const left = group_left + @as(i32, @intCast(index)) * (control_width + gap); + const top = origin_y + Dpi.scale(Tokens.tab_bar_vertical_padding, dpi); + return .{ .left = left, .top = top, .right = left + control_width, .bottom = top + control_height }; } /// Legacy fixed geometry; live chrome and UIA use Workspace.visibleTabBounds. pub fn tabBounds(origin_x: i32, origin_y: i32, index: usize) c.RECT { - const left = origin_x + @as(i32, @intCast(index)) * 120; - return .{ .left = left, .top = origin_y + 4, .right = left + 112, .bottom = origin_y + Tokens.tab_bar_height - 4 }; + const left = origin_x + Tokens.tab_bar_inset + + @as(i32, @intCast(index)) * (Tokens.tab_min_width + Tokens.tab_gap); + const top = origin_y + Tokens.tab_bar_vertical_padding; + return .{ .left = left, .top = top, .right = left + Tokens.tab_min_width, .bottom = top + Tokens.tab_pill_height }; } fn tabActionForBounds(origin_x: i32, origin_y: i32, index: usize, x: i32, y: i32) ?TabAction { const bounds = tabBounds(origin_x, origin_y, index); if (x < bounds.left or x >= bounds.right or y < bounds.top or y >= bounds.bottom) return null; - if (x >= bounds.right - 24) return .close; + if (x >= bounds.right - Tokens.tab_control_width) return .close; return .select; } @@ -377,6 +389,8 @@ pub const Surface = struct { last_resize_size: ?GridSize = null, attempted_resize_size: ?GridSize = null, pending_resize_size: ?GridSize = null, + resize_retry_count: u8 = 0, + resize_retry_due_ms: i64 = 0, // Counts batches whose cell, accessibility, and redraw publication calls all succeeded. output_events: usize = 0, output_result: TerminalOutputResult = .{}, @@ -449,6 +463,11 @@ pub const Surface = struct { self.input_bytes = 0; self.output_events = 0; self.output_result = .{}; + self.last_resize_size = null; + self.attempted_resize_size = null; + self.pending_resize_size = null; + self.resize_retry_count = 0; + self.resize_retry_due_ms = 0; } /// Forgets every selection of this surface, the mouse's and the accessibility layer's. @@ -2176,15 +2195,16 @@ pub const Workspace = struct { self.layout_origin_x, self.layout_origin_y, self.layout_width, + self.dpi, x, y, ); } pub fn tabActionAt(self: *const Workspace, x: i32, y: i32) ?struct { index: usize, action: TabAction } { - if (y < self.layout_origin_y or y >= self.layout_origin_y + Tokens.tab_bar_height) return null; - const controls_left = self.chromeControlsLeft(); - if (x < self.layout_origin_x or x >= controls_left) return null; + if (y < self.layout_origin_y or y >= self.layout_origin_y + Dpi.scale(Tokens.tab_bar_height, self.dpi)) return null; + const tabs_right = self.tabsRight(); + if (x < self.layout_origin_x or x >= tabs_right) return null; for (0..self.layout.tabs.items.len) |index| { const bounds = self.visibleTabBounds(index) orelse continue; if (x < bounds.left or x >= bounds.right or y < bounds.top or y >= bounds.bottom) continue; @@ -2202,16 +2222,29 @@ pub const Workspace = struct { } fn chromeControlsLeft(self: *const Workspace) i32 { - return @max(self.layout_origin_x, self.layout_origin_x + self.layout_width - 220); + return chromeControlBounds( + self.layout_origin_x, + self.layout_origin_y, + self.layout_width, + self.dpi, + 0, + ).left; + } + + fn tabsRight(self: *const Workspace) i32 { + return @max( + self.layout_origin_x, + self.chromeControlsLeft() - Dpi.scale(Tokens.tab_controls_gap, self.dpi), + ); } pub fn visibleTabBounds(self: *const Workspace, index: usize) ?c.RECT { if (self.collapsed or index >= self.layout.tabs.items.len) return null; - const inset = Dpi.scale(8, self.dpi); - const gap = Dpi.scale(4, self.dpi); - const available = self.chromeControlsLeft() - self.layout_origin_x - 2 * inset; + const inset = Dpi.scale(Tokens.tab_bar_inset, self.dpi); + const gap = Dpi.scale(Tokens.tab_gap, self.dpi); + const available = self.tabsRight() - self.layout_origin_x - 2 * inset; if (available <= 0) return null; - const capacity: usize = @intCast(@max(1, @divTrunc(available + gap, Dpi.scale(92, self.dpi) + gap))); + const capacity: usize = @intCast(@max(1, @divTrunc(available + gap, Dpi.scale(Tokens.tab_min_width, self.dpi) + gap))); const count = @min(self.layout.tabs.items.len, capacity); const selected = @min(self.layout.selected_tab, self.layout.tabs.items.len - 1); const first = if (selected >= count) selected - count + 1 else 0; @@ -2221,13 +2254,14 @@ pub const Workspace = struct { const content = available - gap * (visible_count - 1); const left = self.layout_origin_x + inset + @divTrunc(content * position, visible_count) + gap * position; const right = self.layout_origin_x + inset + @divTrunc(content * (position + 1), visible_count) + gap * position; - return .{ .left = left, .top = self.layout_origin_y + 4, .right = right, .bottom = self.layout_origin_y + Tokens.tab_bar_height - 4 }; + const top = self.layout_origin_y + Dpi.scale(Tokens.tab_bar_vertical_padding, self.dpi); + return .{ .left = left, .top = top, .right = right, .bottom = top + Dpi.scale(Tokens.tab_pill_height, self.dpi) }; } pub fn tabCloseBounds(self: *const Workspace, index: usize) ?c.RECT { const bounds = self.visibleTabBounds(index) orelse return null; if (bounds.right - bounds.left < Dpi.scale(40, self.dpi)) return null; - return .{ .left = @max(bounds.left, bounds.right - Dpi.scale(24, self.dpi)), .top = bounds.top, .right = bounds.right, .bottom = bounds.bottom }; + return .{ .left = @max(bounds.left, bounds.right - Dpi.scale(Tokens.tab_control_width, self.dpi)), .top = bounds.top, .right = bounds.right, .bottom = bounds.bottom }; } pub fn tabChrome(self: *const Workspace, index: usize) ?TabChrome { @@ -2252,13 +2286,20 @@ pub const Workspace = struct { const has_agent = for (tab.panes.items) |pane| { if (pane.launches_agent) break true; } else false; - if (has_agent and self.agent_tab_state != .quiet and chrome.title.right - chrome.title.left >= 16) { - chrome.indicator = .{ .left = chrome.title.left, .top = bounds.top + 8, .right = chrome.title.left + 5, .bottom = bounds.top + 13 }; - chrome.title.left += 11; + if (has_agent and self.agent_tab_state != .quiet and chrome.title.right - chrome.title.left >= Dpi.scale(16, self.dpi)) { + const indicator_size = Dpi.scale(5, self.dpi); + const indicator_top = @divTrunc(bounds.top + bounds.bottom - indicator_size, 2); + chrome.indicator = .{ + .left = chrome.title.left, + .top = indicator_top, + .right = chrome.title.left + indicator_size, + .bottom = indicator_top + indicator_size, + }; + chrome.title.left += Dpi.scale(11, self.dpi); } - if (has_agent and self.agent_tab_state == .asks and chrome.title.right - chrome.title.left >= 80) { - chrome.asks = .{ .left = chrome.title.right - 26, .top = bounds.top, .right = chrome.title.right, .bottom = bounds.bottom }; - chrome.title.right -= 32; + if (has_agent and self.agent_tab_state == .asks and chrome.title.right - chrome.title.left >= Dpi.scale(80, self.dpi)) { + chrome.asks = .{ .left = chrome.title.right - Dpi.scale(26, self.dpi), .top = bounds.top, .right = chrome.title.right, .bottom = bounds.bottom }; + chrome.title.right -= Dpi.scale(32, self.dpi); } return chrome; } @@ -2273,6 +2314,7 @@ pub const Workspace = struct { self.layout_origin_y, self.layout_width, self.layout_height, + self.dpi, tab.split_direction, position, tab.panes.items.len, @@ -2281,7 +2323,7 @@ pub const Workspace = struct { return .{ .bounds = .{ .left = bounds.x, - .top = bounds.y - Tokens.pane_header_height, + .top = bounds.y - Dpi.scale(Tokens.pane_header_height, self.dpi), .right = bounds.x + @as(i32, @intCast(bounds.width)), .bottom = bounds.y, }, @@ -2310,43 +2352,44 @@ pub const Workspace = struct { .left = self.layout_origin_x, .top = self.layout_origin_y, .right = self.layout_origin_x + self.layout_width, - .bottom = self.layout_origin_y + Tokens.tab_bar_height, + .bottom = self.layout_origin_y + Dpi.scale(Tokens.tab_bar_height, self.dpi), }; + const hairline = Dpi.scale(1, self.dpi); fillRect(hdc, tab_bar, Tokens.workspace_rail); // Theme.tabBarGloss painted over the strip, lit from above; approximated as a // vertical GradientFill (see Tokens.tab_bar_gloss_top/_bottom). GdiGradient.fillVertical(hdc, tab_bar, Tokens.tab_bar_gloss_top, Tokens.tab_bar_gloss_bottom); // Theme.tabBarHighlight -- the one-point specular line along the strip's top edge. - fillRect(hdc, .{ .left = tab_bar.left, .top = tab_bar.top, .right = tab_bar.right, .bottom = tab_bar.top + 1 }, Tokens.tab_bar_highlight); + fillRect(hdc, .{ .left = tab_bar.left, .top = tab_bar.top, .right = tab_bar.right, .bottom = tab_bar.top + hairline }, Tokens.tab_bar_highlight); for (self.layout.tabs.items, 0..) |_, index| { const chrome = self.tabChrome(index) orelse continue; const bounds = chrome.bounds; const style = tabChromeStyle(index == self.layout.selected_tab); - if (style.background) |background| fillRoundedRect(hdc, bounds, background, 6, null); + if (style.background) |background| fillRoundedRectForDpi(hdc, bounds, background, Dpi.scale(6, self.dpi), null, self.dpi); if (chrome.indicator) |indicator| { const color: u32 = if (self.agent_tab_state == .running) 0x00FF840A else 0x000A9FFF; - paintChromeDot(hdc, indicator, color, style.background orelse Tokens.tab_bar_gloss_bottom, self.agent_tab_state == .needs_you); + paintChromeDotForDpi(hdc, indicator, color, style.background orelse Tokens.tab_bar_gloss_bottom, self.agent_tab_state == .needs_you, self.dpi); } - if (chrome.asks) |asks| drawChromeText(hdc, "asks", asks, 9.5, .card_mono, 0x000A9FFF); - if (chrome.title.right > chrome.title.left) drawChromeText(hdc, self.tabTitle(index).?, chrome.title, 12, style.font, style.ink); - if (chrome.show_close) drawChromeText(hdc, "x", .{ - .left = @max(bounds.left, bounds.right - 17), + if (chrome.asks) |asks| drawChromeTextForDpi(hdc, "asks", asks, 9.5, .card_mono, 0x000A9FFF, self.dpi); + if (chrome.title.right > chrome.title.left) drawChromeTextForDpi(hdc, self.tabTitle(index).?, chrome.title, 12, style.font, style.ink, self.dpi); + if (chrome.show_close) drawChromeTextForDpi(hdc, "x", .{ + .left = @max(bounds.left, bounds.right - Dpi.scale(17, self.dpi)), .top = bounds.top, - .right = bounds.right - 5, + .right = bounds.right - Dpi.scale(5, self.dpi), .bottom = bounds.bottom, - }, 10, .semibold, 0x00999999); - if (chrome.shortcut) |value| drawChromeText(hdc, value, chrome.trailing.?, 10, .card_mono, 0x007E7E85); + }, 10, .semibold, 0x00999999, self.dpi); + if (chrome.shortcut) |value| drawChromeTextForDpi(hdc, value, chrome.trailing.?, 10, .card_mono, 0x007E7E85, self.dpi); } - fillRect(hdc, .{ .left = tab_bar.left, .top = tab_bar.bottom - 1, .right = tab_bar.right, .bottom = tab_bar.bottom }, Tokens.tab_bar_shadow_line); + fillRect(hdc, .{ .left = tab_bar.left, .top = tab_bar.bottom - hairline, .right = tab_bar.right, .bottom = tab_bar.bottom }, Tokens.tab_bar_shadow_line); for (0..3) |index| { - const bounds = chromeControlBounds(self.layout_origin_x, self.layout_origin_y, self.layout_width, index); - fillRoundedRect(hdc, bounds, Tokens.control_gloss_bottom, 5, Tokens.control_border); - paintChromeGlyph(hdc, bounds, chromeGlyph(index)); + const bounds = chromeControlBounds(self.layout_origin_x, self.layout_origin_y, self.layout_width, self.dpi, index); + fillRoundedRectForDpi(hdc, bounds, Tokens.control_gloss_bottom, Dpi.scale(5, self.dpi), Tokens.control_border, self.dpi); + paintChromeGlyph(hdc, bounds, chromeGlyph(index), self.dpi); } const selected = self.layout.selectedConst() orelse return; for (selected.panes.items, 0..) |_, position| - if (self.paneHeaderChrome(position)) |header| paintPaneHeader(hdc, header); + if (self.paneHeaderChrome(position)) |header| paintPaneHeader(hdc, header, self.dpi); } pub fn paintLoopBar( @@ -2580,6 +2623,7 @@ pub const Workspace = struct { self.layout_origin_y, self.layout_width, self.layout_height, + self.dpi, selected.split_direction, position, pane_count, @@ -2642,6 +2686,7 @@ pub const Workspace = struct { self.layout_origin_y, self.layout_width, self.layout_height, + self.dpi, tab.split_direction, position, tab.panes.items.len, @@ -2657,6 +2702,7 @@ pub const Workspace = struct { self.layout_origin_y, self.layout_width, self.layout_height, + self.dpi, .horizontal, 0, 1, @@ -2673,6 +2719,7 @@ pub const Workspace = struct { self.layout_origin_y, self.layout_width, self.layout_height, + self.dpi, direction, position, count, @@ -2968,6 +3015,7 @@ pub const Workspace = struct { pub fn destroySurface(self: *Workspace, index: usize) void { if (index >= self.surfaces.len) return; const slot = &self.surfaces[index]; + if (self.resizeTargetsSurface(index)) self.stopResizeChild(); slot.destroying = true; self.releaseDeliveredMouse(index); self.waitAttach(index); @@ -2988,6 +3036,14 @@ pub const Workspace = struct { self.resetSessionState(index); } + fn resizeTargetsSurface(self: *const Workspace, index: usize) bool { + if (index >= self.surfaces.len) return false; + if (self.resize_child_surface == index) return true; + const session = self.surfaces[index].session_name; + return session.len != 0 and self.resize_child_session.len != 0 and + std.mem.eql(u8, session, self.resize_child_session); + } + /// The part of a surface's teardown that runs while its attach is still alive: drops the /// surface's queued input and releases the mouse buttons its program was sent as down. fn releaseDeliveredMouse(self: *Workspace, index: usize) void { @@ -3275,16 +3331,33 @@ pub const Workspace = struct { if (c.GetExitCodeProcess(child.id, &exit_code) == 0) { _ = child.kill() catch {}; _ = child.wait() catch {}; - self.finishResizeControl(false, "terminal PTY resize status unavailable"); + self.finishResizeControl(false, false, "terminal PTY resize status unavailable"); } else if (exit_code != c.STILL_ACTIVE) { + const stderr = if (child.stderr) |stream| + stream.readToEndAlloc(self.allocator, 4096) catch &.{} + else + &.{}; + defer if (stderr.len != 0) self.allocator.free(stderr); _ = child.wait() catch {}; - self.finishResizeControl(exit_code == 0, "terminal PTY resize command failed"); + if (exit_code != 0) { + const size = self.resize_child_size orelse GridSize{ .cols = 0, .rows = 0 }; + std.log.err( + "terminal PTY resize failed session={s} cols={d} rows={d} exit={d} stderr={s}", + .{ self.resize_child_session, size.cols, size.rows, exit_code, stderr }, + ); + } + const retryable = exit_code != 0 and + (std.mem.indexOf(u8, stderr, "ConnectionRefused") != null or + std.mem.indexOf(u8, stderr, "FILE_NOT_FOUND") != null); + self.finishResizeControl(exit_code == 0, retryable, "terminal PTY resize command failed"); } } if (self.resize_child != null) return; + const now_ms = std.time.milliTimestamp(); for (&self.surfaces, 0..) |*slot, index| { const size = slot.pending_resize_size orelse continue; if (slot.attach == null or slot.surface == null) continue; + if (now_ms < slot.resize_retry_due_ms) continue; var args: [4][]const u8 = undefined; var size_buffer: [16]u8 = undefined; var session_buffer: [ZmxSession.prefix.len + 128]u8 = undefined; @@ -3324,7 +3397,7 @@ pub const Workspace = struct { } } - fn finishResizeControl(self: *Workspace, succeeded: bool, failure_message: []const u8) void { + fn finishResizeControl(self: *Workspace, succeeded: bool, retryable: bool, failure_message: []const u8) void { const index = self.resize_child_surface orelse max_surfaces; const size = self.resize_child_size; if (index < self.surfaces.len and size != null and @@ -3337,8 +3410,17 @@ pub const Workspace = struct { if (succeeded) { slot.last_resize_size = size; slot.attempted_resize_size = null; + slot.resize_retry_count = 0; + slot.resize_retry_due_ms = 0; + } else if (retryable and slot.surface != null and !slot.destroying and + slot.resize_retry_count < 5) + { + slot.resize_retry_count += 1; + slot.resize_retry_due_ms = std.time.milliTimestamp() + 100; + slot.pending_resize_size = size; } else { slot.attempted_resize_size = size; + slot.resize_retry_due_ms = 0; self.setInputError(failure_message); } } else if (!succeeded) { @@ -7462,12 +7544,20 @@ fn nativeRect(bounds: LoopBarLayout.Rect) c.RECT { } fn fillRoundedRect(hdc: c.HDC, bounds: c.RECT, color: u32, radius: i32, border: ?u32) void { + fillRoundedRectWithPenWidth(hdc, bounds, color, radius, border, 1); +} + +fn fillRoundedRectForDpi(hdc: c.HDC, bounds: c.RECT, color: u32, radius: i32, border: ?u32, dpi: u32) void { + fillRoundedRectWithPenWidth(hdc, bounds, color, radius, border, Dpi.scale(1, dpi)); +} + +fn fillRoundedRectWithPenWidth(hdc: c.HDC, bounds: c.RECT, color: u32, radius: i32, border: ?u32, pen_width: i32) void { const brush = c.CreateSolidBrush(color) orelse { std.log.warn("Unable to create workspace chrome brush", .{}); return; }; defer _ = c.DeleteObject(brush); - const pen = c.CreatePen(c.PS_SOLID, 1, border orelse color) orelse { + const pen = c.CreatePen(c.PS_SOLID, pen_width, border orelse color) orelse { std.log.warn("Unable to create workspace chrome pen", .{}); return; }; @@ -7507,8 +7597,8 @@ fn chromeLine(hdc: c.HDC, x1: i32, y1: i32, x2: i32, y2: i32) void { std.log.warn("Unable to paint workspace chrome glyph", .{}); } -fn paintChromeGlyph(hdc: c.HDC, bounds: c.RECT, glyph: ChromeGlyph) void { - const pen = c.CreatePen(c.PS_SOLID, 1, 0x00D8D8D8) orelse { +fn paintChromeGlyph(hdc: c.HDC, bounds: c.RECT, glyph: ChromeGlyph, dpi: u32) void { + const pen = c.CreatePen(c.PS_SOLID, Dpi.scale(1, dpi), 0x00D8D8D8) orelse { std.log.warn("Unable to create workspace glyph pen", .{}); return; }; @@ -7521,16 +7611,24 @@ fn paintChromeGlyph(hdc: c.HDC, bounds: c.RECT, glyph: ChromeGlyph) void { const y = @divTrunc(bounds.top + bounds.bottom, 2); switch (glyph) { .new_tab => { - chromeLine(hdc, x - 5, y, x + 6, y); - chromeLine(hdc, x, y - 5, x, y + 6); + chromeLine(hdc, x - Dpi.scale(5, dpi), y, x + Dpi.scale(6, dpi), y); + chromeLine(hdc, x, y - Dpi.scale(5, dpi), x, y + Dpi.scale(6, dpi)); }, .split_right, .split_down => { - if (c.RoundRect(hdc, x - 7, y - 5, x + 8, y + 6, 3, 3) == 0) + if (c.RoundRect( + hdc, + x - Dpi.scale(7, dpi), + y - Dpi.scale(5, dpi), + x + Dpi.scale(8, dpi), + y + Dpi.scale(6, dpi), + Dpi.scale(3, dpi), + Dpi.scale(3, dpi), + ) == 0) std.log.warn("Unable to paint workspace split glyph", .{}); if (glyph == .split_right) - chromeLine(hdc, x, y - 5, x, y + 6) + chromeLine(hdc, x, y - Dpi.scale(5, dpi), x, y + Dpi.scale(6, dpi)) else - chromeLine(hdc, x - 7, y, x + 8, y); + chromeLine(hdc, x - Dpi.scale(7, dpi), y, x + Dpi.scale(8, dpi), y); }, } } @@ -7543,50 +7641,63 @@ const PaneHeaderChrome = struct { }; const pane_focus_hint = "Ctrl+Shift+] to focus"; -fn paintPaneHeader(hdc: c.HDC, header: PaneHeaderChrome) void { +fn paintPaneHeader(hdc: c.HDC, header: PaneHeaderChrome, dpi: u32) void { const bounds = header.bounds; const background = blendChromeColor(if (header.focused) Tokens.pane_focus_tint else 0x00FFFFFF, Tokens.window_tone, if (header.focused) 10 else 3); fillRect(hdc, bounds, background); - if (bounds.right - bounds.left < 24) return; - paintChromeDot(hdc, .{ .left = bounds.left + 8, .top = bounds.top + 8, .right = bounds.left + 13, .bottom = bounds.top + 13 }, if (header.focused) Tokens.pane_focus_tint else blendChromeColor(0x00FFFFFF, background, 30), background, false); - drawChromeText(hdc, header.title, .{ - .left = bounds.left + 19, + if (bounds.right - bounds.left < Dpi.scale(24, dpi)) return; + paintChromeDotForDpi(hdc, .{ + .left = bounds.left + Dpi.scale(8, dpi), + .top = bounds.top + Dpi.scale(8, dpi), + .right = bounds.left + Dpi.scale(13, dpi), + .bottom = bounds.top + Dpi.scale(13, dpi), + }, if (header.focused) Tokens.pane_focus_tint else blendChromeColor(0x00FFFFFF, background, 30), background, false, dpi); + drawChromeTextForDpi(hdc, header.title, .{ + .left = bounds.left + Dpi.scale(19, dpi), .top = bounds.top, - .right = @min(bounds.left + 62, bounds.right - 8), + .right = @min(bounds.left + Dpi.scale(62, dpi), bounds.right - Dpi.scale(8, dpi)), .bottom = bounds.bottom, - }, 10.5, .semibold, if (header.focused) blendChromeColor(0x00FFC58C, background, 90) else blendChromeColor(0x00FFFFFF, background, 45)); + }, 10.5, .semibold, if (header.focused) blendChromeColor(0x00FFC58C, background, 90) else blendChromeColor(0x00FFFFFF, background, 45), dpi); if (header.focused) { - drawChromeText(hdc, "focused", .{ - .left = bounds.left + 65, + drawChromeTextForDpi(hdc, "focused", .{ + .left = bounds.left + Dpi.scale(65, dpi), .top = bounds.top, - .right = @min(bounds.left + 122, bounds.right - 8), + .right = @min(bounds.left + Dpi.scale(122, dpi), bounds.right - Dpi.scale(8, dpi)), .bottom = bounds.bottom, - }, 9.5, .card_mono, blendChromeColor(0x00FFFFFF, background, 35)); - if (header.detail.len != 0 and bounds.right - bounds.left >= 180) { - drawChromeText(hdc, header.detail, .{ - .left = @max(bounds.left + 128, bounds.right - 128), + }, 9.5, .card_mono, blendChromeColor(0x00FFFFFF, background, 35), dpi); + if (header.detail.len != 0 and bounds.right - bounds.left >= Dpi.scale(180, dpi)) { + drawChromeTextForDpi(hdc, header.detail, .{ + .left = @max(bounds.left + Dpi.scale(128, dpi), bounds.right - Dpi.scale(128, dpi)), .top = bounds.top, - .right = bounds.right - 8, + .right = bounds.right - Dpi.scale(8, dpi), .bottom = bounds.bottom, - }, 9.5, .card_mono, blendChromeColor(0x00FFFFFF, background, 35)); + }, 9.5, .card_mono, blendChromeColor(0x00FFFFFF, background, 35), dpi); } - } else if (bounds.right - bounds.left >= 240) { - drawChromeText(hdc, pane_focus_hint, .{ - .left = bounds.right - 144, + } else if (bounds.right - bounds.left >= Dpi.scale(240, dpi)) { + drawChromeTextForDpi(hdc, pane_focus_hint, .{ + .left = bounds.right - Dpi.scale(144, dpi), .top = bounds.top, - .right = bounds.right - 8, + .right = bounds.right - Dpi.scale(8, dpi), .bottom = bounds.bottom, - }, 9.5, .card_mono, blendChromeColor(0x00FFFFFF, background, 35)); + }, 9.5, .card_mono, blendChromeColor(0x00FFFFFF, background, 35), dpi); } } fn paintChromeDot(hdc: c.HDC, bounds: c.RECT, tint: u32, background: u32, hollow: bool) void { + paintChromeDotWithPenWidth(hdc, bounds, tint, background, hollow, 1); +} + +fn paintChromeDotForDpi(hdc: c.HDC, bounds: c.RECT, tint: u32, background: u32, hollow: bool, dpi: u32) void { + paintChromeDotWithPenWidth(hdc, bounds, tint, background, hollow, Dpi.scale(1, dpi)); +} + +fn paintChromeDotWithPenWidth(hdc: c.HDC, bounds: c.RECT, tint: u32, background: u32, hollow: bool, pen_width: i32) void { const brush = c.CreateSolidBrush(if (hollow) background else tint) orelse { std.log.warn("Unable to create workspace indicator brush", .{}); return; }; defer _ = c.DeleteObject(brush); - const pen = c.CreatePen(c.PS_SOLID, 1, tint) orelse { + const pen = c.CreatePen(c.PS_SOLID, pen_width, tint) orelse { std.log.warn("Unable to create workspace indicator pen", .{}); return; }; @@ -7606,6 +7717,11 @@ fn selectChromeFont(hdc: c.HDC, size: f32, style: AppFont.Style) !c.HGDIOBJ { return c.SelectObject(hdc, font) orelse error.WorkspaceFontSelectionFailed; } +fn selectChromeFontForDpi(hdc: c.HDC, size: f32, style: AppFont.Style, dpi: u32) !c.HGDIOBJ { + const font = AppFont.getStyleForDpi(size, style, dpi) orelse return error.WorkspaceFontUnavailable; + return c.SelectObject(hdc, font) orelse error.WorkspaceFontSelectionFailed; +} + fn chromeTextWidth(hdc: c.HDC, text: []const u8, size: f32, style: AppFont.Style) !i32 { const wide = try std.unicode.utf8ToUtf16LeAlloc(std.heap.page_allocator, text); defer std.heap.page_allocator.free(wide); @@ -7618,6 +7734,14 @@ fn chromeTextWidth(hdc: c.HDC, text: []const u8, size: f32, style: AppFont.Style } fn drawChromeText(hdc: c.HDC, text: []const u8, bounds: c.RECT, size: f32, style: AppFont.Style, color: u32) void { + drawChromeTextWithFont(hdc, text, bounds, size, style, color, Dpi.base_dpi); +} + +fn drawChromeTextForDpi(hdc: c.HDC, text: []const u8, bounds: c.RECT, size: f32, style: AppFont.Style, color: u32, dpi: u32) void { + drawChromeTextWithFont(hdc, text, bounds, size, style, color, dpi); +} + +fn drawChromeTextWithFont(hdc: c.HDC, text: []const u8, bounds: c.RECT, size: f32, style: AppFont.Style, color: u32, dpi: u32) void { if (text.len == 0 or bounds.right <= bounds.left or bounds.bottom <= bounds.top) return; const wide = std.unicode.utf8ToUtf16LeAlloc(std.heap.page_allocator, text) catch |err| { std.log.warn("Unable to prepare workspace text: {s}", .{@errorName(err)}); @@ -7628,7 +7752,7 @@ fn drawChromeText(hdc: c.HDC, text: []const u8, bounds: c.RECT, size: f32, style std.log.warn("Workspace text exceeds native text length", .{}); return; }; - const old_font = selectChromeFont(hdc, size, style) catch |err| { + const old_font = selectChromeFontForDpi(hdc, size, style, dpi) catch |err| { std.log.warn("Unable to select workspace text font: {s}", .{@errorName(err)}); return; }; @@ -7963,11 +8087,21 @@ test "bounded input write rejects an invalid attach without blocking" { } test "workspace chrome actions occupy distinct visible buttons" { - try std.testing.expectEqual(ChromeAction.split_right, chromeActionForBounds(220, 34, 800, 804, 44).?); - try std.testing.expectEqual(ChromeAction.split_down, chromeActionForBounds(220, 34, 800, 876, 44).?); - try std.testing.expectEqual(ChromeAction.new_tab, chromeActionForBounds(220, 34, 800, 948, 44).?); - try std.testing.expect(chromeActionForBounds(220, 34, 800, 868, 44) == null); - try std.testing.expectEqual(@as(?ChromeAction, null), chromeActionForBounds(220, 34, 800, 700, 44)); + for ([_]ChromeAction{ .split_right, .split_down, .new_tab }, 0..) |expected, index| { + const bounds = chromeControlBounds(220, 34, 800, Dpi.base_dpi, index); + try std.testing.expectEqual( + expected, + chromeActionForBounds( + 220, + 34, + 800, + Dpi.base_dpi, + @divTrunc(bounds.left + bounds.right, 2), + @divTrunc(bounds.top + bounds.bottom, 2), + ).?, + ); + } + try std.testing.expectEqual(@as(?ChromeAction, null), chromeActionForBounds(220, 34, 800, Dpi.base_dpi, 700, 44)); } test "workspace chrome hidden partial tabs have no hit target" { @@ -7983,7 +8117,7 @@ test "workspace chrome hidden partial tabs have no hit target" { .project_key = &.{}, .layout_origin_x = 220, .layout_origin_y = 80, - .layout_width = 420, + .layout_width = 220, }; defer workspace.layout.deinit(); try workspace.layout.addTab("agent", true); @@ -8000,7 +8134,7 @@ test "workspace chrome vertical splits reserve a header above every terminal" { for ([_]usize{ 2, 3, 4 }) |count| { var previous_bottom: i32 = 20 + Tokens.tab_bar_height; for (0..count) |position| { - const bounds = paneBounds(10, 20, 801, 401, .vertical, position, count); + const bounds = paneBounds(10, 20, 801, 401, Dpi.base_dpi, .vertical, position, count); try std.testing.expect(bounds.width > 0 and bounds.height > 0); try std.testing.expectEqual(previous_bottom, bounds.y - Tokens.pane_header_height); previous_bottom = bounds.y + @as(i32, @intCast(bounds.height)); @@ -8044,6 +8178,79 @@ test "workspace chrome selected tab is the only filled semibold pill" { try std.testing.expect(chrome_text_format & c.DT_VCENTER != 0); } +test "workspace terminal chrome keeps mac logical density at every DPI and expands twelve tabs at 1508" { + const allocator = std.testing.allocator; + for ([_]u32{ 192, 144, 96 }) |dpi| { + var workspace = Workspace{ + .parent = null, + .allocator = allocator, + .zmx_path = &.{}, + .cwd = &.{}, + .input_queue = .{ .allocator = allocator }, + .layout = try WorkspaceLayout.Layout.init(allocator, "chrome-density"), + .layout_path = &.{}, + .project_key = &.{}, + .layout_origin_x = Dpi.scale(Tokens.sidebar_width, dpi), + .layout_origin_y = Dpi.scale(Tokens.header_height + Tokens.loop_bar_height, dpi), + .layout_width = Dpi.scale(1440 - Tokens.sidebar_width, dpi), + .layout_height = Dpi.scale(700, dpi), + .dpi = dpi, + }; + defer workspace.layout.deinit(); + for (0..12) |index| { + var id: [32]u8 = undefined; + try workspace.layout.addTab(try std.fmt.bufPrint(&id, "shell-{d}", .{index}), false); + } + + try workspace.layout.selectTab(0); + const first = workspace.visibleTabBounds(0).?; + try std.testing.expectEqual(Dpi.scale(25, dpi), first.bottom - first.top); + try std.testing.expectEqual(Dpi.scale(24, dpi), workspace.tabCloseBounds(0).?.right - workspace.tabCloseBounds(0).?.left); + try std.testing.expectEqual(Dpi.scale(22, dpi), workspace.paneHeaderChrome(0).?.bounds.bottom - workspace.paneHeaderChrome(0).?.bounds.top); + const control = chromeControlBounds( + workspace.layout_origin_x, + workspace.layout_origin_y, + workspace.layout_width, + dpi, + 0, + ); + try std.testing.expectEqual(Dpi.scale(24, dpi), control.right - control.left); + try std.testing.expectEqual(Dpi.scale(22, dpi), control.bottom - control.top); + const pane = paneBounds( + workspace.layout_origin_x, + workspace.layout_origin_y, + workspace.layout_width, + workspace.layout_height, + dpi, + .horizontal, + 0, + 1, + ); + try std.testing.expectEqual( + Dpi.scale(Tokens.tab_bar_height + Tokens.pane_header_height, dpi), + pane.y - workspace.layout_origin_y, + ); + try std.testing.expectEqual( + @as(u32, @intCast(workspace.layout_height - Dpi.scale(Tokens.tab_bar_height + Tokens.pane_header_height, dpi))), + pane.height, + ); + try std.testing.expect(workspace.tabActionAt(first.left, workspace.layout_origin_y + Dpi.scale(Tokens.tab_bar_height, dpi)) == null); + + var visible_at_1440: usize = 0; + for (0..workspace.tabCount()) |index| { + if (workspace.visibleTabBounds(index) != null) visible_at_1440 += 1; + } + try std.testing.expectEqual(@as(usize, 11), visible_at_1440); + + workspace.layout_width = Dpi.scale(1508 - Tokens.sidebar_width, dpi); + var visible_at_1508: usize = 0; + for (0..workspace.tabCount()) |index| { + if (workspace.visibleTabBounds(index) != null) visible_at_1508 += 1; + } + try std.testing.expectEqual(@as(usize, 12), visible_at_1508); + } +} + test "lane 9 tab pills share available content width rather than a fixed 112 pixels" { // LoopWorkspacePanes.swift:44-83 and LoopWorkspaceView.swift:230-267. const allocator = std.testing.allocator; @@ -8195,7 +8402,7 @@ test "lane 9 tab content shortcut close and overflow geometry agree at narrow an try workspace.layout.selectTab(selected); const selected_bounds = workspace.visibleTabBounds(selected); if (selected_bounds == null) { - try std.testing.expect(workspace.chromeControlsLeft() - workspace.layout_origin_x <= 2 * Dpi.scale(8, dpi)); + try std.testing.expect(workspace.tabsRight() - workspace.layout_origin_x <= 2 * Dpi.scale(Tokens.tab_bar_inset, dpi)); continue; } var visible: usize = 0; @@ -8249,12 +8456,12 @@ test "lane 9 tab content shortcut close and overflow geometry agree at narrow an test "workspace chrome split glyphs retain their existing action columns" { for ([_]ChromeGlyph{ .split_right, .split_down, .new_tab }, 0..) |glyph, index| { - const bounds = chromeControlBounds(220, 80, 800, index); + const bounds = chromeControlBounds(220, 80, 800, Dpi.base_dpi, index); try std.testing.expectEqual(glyph, chromeGlyph(index)); - const action = chromeActionForBounds(220, 80, 800, @divTrunc(bounds.left + bounds.right, 2), @divTrunc(bounds.top + bounds.bottom, 2)).?; + const action = chromeActionForBounds(220, 80, 800, Dpi.base_dpi, @divTrunc(bounds.left + bounds.right, 2), @divTrunc(bounds.top + bounds.bottom, 2)).?; try std.testing.expectEqualStrings(@tagName(glyph), @tagName(action)); } - try std.testing.expect(chromeActionForBounds(220, 80, 100, 400, 90) == null); + try std.testing.expect(chromeActionForBounds(220, 80, 100, Dpi.base_dpi, 400, 90) == null); } test "issue 772 workspace chrome matches mac control order and focused backend identity" { @@ -8282,10 +8489,10 @@ test "issue 772 workspace chrome matches mac control order and focused backend i workspace.layout.selected().?.focused_pane = 0; for ([_]ChromeAction{ .split_right, .split_down, .new_tab }, 0..) |expected, index| { - const bounds = chromeControlBounds(220, 80, 800, index); + const bounds = chromeControlBounds(220, 80, 800, workspace.dpi, index); try std.testing.expectEqual( expected, - chromeActionForBounds(220, 80, 800, @divTrunc(bounds.left + bounds.right, 2), @divTrunc(bounds.top + bounds.bottom, 2)).?, + chromeActionForBounds(220, 80, 800, workspace.dpi, @divTrunc(bounds.left + bounds.right, 2), @divTrunc(bounds.top + bounds.bottom, 2)).?, ); } const agent = workspace.paneHeaderChrome(0).?; @@ -8323,7 +8530,7 @@ test "workspace chrome pane headers follow selected layout and focus instead of try std.testing.expectEqual(@as(i32, 620), left.bounds.right); try std.testing.expectEqual(left.bounds.right, right.bounds.left); try std.testing.expectEqual(@as(i32, 1021), right.bounds.right); - try std.testing.expectEqual(@as(i32, 110), left.bounds.top); + try std.testing.expectEqual(@as(i32, 117), left.bounds.top); try std.testing.expectEqual(@as(i32, Tokens.pane_header_height), left.bounds.bottom - left.bounds.top); try std.testing.expect(workspace.paneHeaderChrome(2) == null); try workspace.layout.selectTab(0); @@ -8488,24 +8695,24 @@ test "pane attach and resize commands carry geometry outside terminal input" { } test "pane bounds partition available area without losing remainder pixels" { - const left = paneBounds(10, 20, 801, 400, .horizontal, 0, 2); - const right = paneBounds(10, 20, 801, 400, .horizontal, 1, 2); + const left = paneBounds(10, 20, 801, 400, Dpi.base_dpi, .horizontal, 0, 2); + const right = paneBounds(10, 20, 801, 400, Dpi.base_dpi, .horizontal, 1, 2); try std.testing.expectEqual(@as(i32, 10), left.x); try std.testing.expectEqual(@as(u32, 400), left.width); try std.testing.expectEqual(@as(i32, 410), right.x); try std.testing.expectEqual(@as(u32, 401), right.width); - try std.testing.expectEqual(@as(u32, 348), left.height); - try std.testing.expectEqual(@as(u32, 348), right.height); + try std.testing.expectEqual(@as(u32, 341), left.height); + try std.testing.expectEqual(@as(u32, 341), right.height); } test "pane bounds ignore zero or unavailable client geometry" { - const bounds = paneBounds(0, 0, 0, 0, .horizontal, 0, 1); + const bounds = paneBounds(0, 0, 0, 0, Dpi.base_dpi, .horizontal, 0, 1); try std.testing.expectEqual(@as(u32, 0), bounds.width); try std.testing.expectEqual(@as(u32, 0), bounds.height); - const no_width = paneBounds(0, 0, 0, 400, .horizontal, 0, 1); + const no_width = paneBounds(0, 0, 0, 400, Dpi.base_dpi, .horizontal, 0, 1); try std.testing.expectEqual(@as(u32, 0), no_width.width); try std.testing.expectEqual(@as(u32, 0), no_width.height); - const no_height = paneBounds(0, 0, 480, 0, .horizontal, 0, 1); + const no_height = paneBounds(0, 0, 480, 0, Dpi.base_dpi, .horizontal, 0, 1); try std.testing.expectEqual(@as(u32, 0), no_height.width); try std.testing.expectEqual(@as(u32, 0), no_height.height); const no_client_area = paneBounds( @@ -8513,6 +8720,7 @@ test "pane bounds ignore zero or unavailable client geometry" { 0, 480, Tokens.tab_bar_height + Tokens.pane_header_height, + Dpi.base_dpi, .horizontal, 0, 1, @@ -8526,7 +8734,7 @@ test "zero pane geometry leaves the grid and backend resize queue unchanged" { defer workspace.layout.deinit(); defer std.testing.allocator.free(workspace.surfaces[0].cells); - const bounds = paneBounds(0, 0, 0, 0, .horizontal, 0, 1); + const bounds = paneBounds(0, 0, 0, 0, Dpi.base_dpi, .horizontal, 0, 1); workspace.syncPaneGrid(0, bounds, Workspace.resizeSurfaceGridState); try std.testing.expectEqual(default_grid, workspace.surfaces[0].grid); @@ -8538,7 +8746,7 @@ test "minimize and restore to the same pane size does not queue a resize" { defer workspace.layout.deinit(); defer std.testing.allocator.free(workspace.surfaces[0].cells); - const minimized = paneBounds(0, 0, 0, 0, .horizontal, 0, 1); + const minimized = paneBounds(0, 0, 0, 0, Dpi.base_dpi, .horizontal, 0, 1); workspace.syncPaneGrid(0, minimized, Workspace.resizeSurfaceGridState); try std.testing.expectEqual(default_grid, workspace.surfaces[0].grid); try std.testing.expect(workspace.surfaces[0].pending_resize_size == null); @@ -8548,6 +8756,7 @@ test "minimize and restore to the same pane size does not queue a resize" { 0, 960, Tokens.tab_bar_height + Tokens.pane_header_height + 640, + Dpi.base_dpi, .horizontal, 0, 1, @@ -8563,7 +8772,7 @@ test "minimize and restore to a new pane size queues one resize for the new grid defer workspace.layout.deinit(); defer std.testing.allocator.free(workspace.surfaces[0].cells); - const minimized = paneBounds(0, 0, 0, 0, .horizontal, 0, 1); + const minimized = paneBounds(0, 0, 0, 0, Dpi.base_dpi, .horizontal, 0, 1); workspace.syncPaneGrid(0, minimized, Workspace.resizeSurfaceGridState); try std.testing.expectEqual(default_grid, workspace.surfaces[0].grid); try std.testing.expect(workspace.surfaces[0].pending_resize_size == null); @@ -8573,6 +8782,7 @@ test "minimize and restore to a new pane size queues one resize for the new grid 0, 800, Tokens.tab_bar_height + Tokens.pane_header_height + 320, + Dpi.base_dpi, .horizontal, 0, 1, @@ -8649,9 +8859,63 @@ test "pane resize requests coalesce, deduplicate and suppress failed retries" { try std.testing.expectEqual(@as(usize, 0), workspace.input_queue.count); } +test "surface teardown cancels only its owned PTY resize and preserves later status" { + const allocator = std.testing.allocator; + var workspace = try minimalWorkspaceForOptionsTest(allocator); + defer workspace.layout.deinit(); + workspace.surfaces[0].session_name = try allocator.dupe(u8, "session-a"); + workspace.surfaces[1].session_name = try allocator.dupe(u8, "session-b"); + defer allocator.free(workspace.surfaces[1].session_name); + workspace.resize_child_surface = 0; + workspace.resize_child_size = .{ .cols = 120, .rows = 32 }; + workspace.resize_child_session = try allocator.dupe(u8, "session-a"); + + try std.testing.expect(workspace.resizeTargetsSurface(0)); + try std.testing.expect(!workspace.resizeTargetsSurface(1)); + try std.testing.expect(!workspace.resizeTargetsSurface(workspace.surfaces.len)); + + workspace.destroySurface(0); + + try std.testing.expect(workspace.resize_child == null); + try std.testing.expect(workspace.resize_child_surface == null); + try std.testing.expect(workspace.resize_child_size == null); + try std.testing.expectEqualStrings("", workspace.resize_child_session); + try std.testing.expect(workspace.inputStatus("Project relocation is unavailable") == null); +} + +test "PTY resize retries a not-yet-published zmx endpoint and surfaces exhaustion" { + const allocator = std.testing.allocator; + var workspace = try minimalWorkspaceForOptionsTest(allocator); + defer workspace.layout.deinit(); + workspace.surfaces[0].session_name = try allocator.dupe(u8, "session-a"); + defer allocator.free(workspace.surfaces[0].session_name); + workspace.surfaces[0].surface = @ptrFromInt(1); + defer workspace.surfaces[0].surface = null; + const size = GridSize{ .cols = 50, .rows = 10 }; + + workspace.resize_child_surface = 0; + workspace.resize_child_size = size; + workspace.resize_child_session = try allocator.dupe(u8, "session-a"); + workspace.finishResizeControl(false, true, "terminal PTY resize command failed"); + + try std.testing.expectEqual(size, workspace.surfaces[0].pending_resize_size.?); + try std.testing.expectEqual(@as(u8, 1), workspace.surfaces[0].resize_retry_count); + try std.testing.expect(workspace.surfaces[0].resize_retry_due_ms > 0); + try std.testing.expect(workspace.inputStatus("Project relocation is unavailable") == null); + + workspace.surfaces[0].resize_retry_count = 5; + workspace.resize_child_surface = 0; + workspace.resize_child_size = size; + workspace.resize_child_session = try allocator.dupe(u8, "session-a"); + workspace.finishResizeControl(false, true, "terminal PTY resize command failed"); + + try std.testing.expectEqualStrings("terminal PTY resize command failed", workspace.inputStatus("").?); + try std.testing.expect(workspace.surfaces[0].pending_resize_size == null); +} + test "workspace tab chrome separates selection and close affordances" { try std.testing.expectEqual(TabAction.select, tabActionForBounds(220, 34, 0, 228, 42).?); - try std.testing.expectEqual(TabAction.close, tabActionForBounds(220, 34, 0, 320, 42).?); + try std.testing.expectEqual(TabAction.close, tabActionForBounds(220, 34, 0, 319, 42).?); try std.testing.expect(tabActionForBounds(220, 34, 0, 340, 42) == null); } diff --git a/graphcode-windows/src/ZmxSession.zig b/graphcode-windows/src/ZmxSession.zig index 76ad3a0d..c523a8e0 100644 --- a/graphcode-windows/src/ZmxSession.zig +++ b/graphcode-windows/src/ZmxSession.zig @@ -72,7 +72,7 @@ pub fn child( result.cwd = cwd; result.stdin_behavior = if (stdio == .attach) .Pipe else .Ignore; result.stdout_behavior = if (stdio == .attach or stdio == .capture) .Pipe else .Ignore; - result.stderr_behavior = .Ignore; + result.stderr_behavior = if (stdio == .control) .Pipe else .Ignore; result.create_no_window = true; return result; } @@ -91,7 +91,7 @@ test "zmx children keep their stdio contract and never open a console window" { const control = child(std.testing.allocator, &kill_argv, null, .control); try std.testing.expectEqual(std.process.Child.StdIo.Ignore, control.stdin_behavior); try std.testing.expectEqual(std.process.Child.StdIo.Ignore, control.stdout_behavior); - try std.testing.expectEqual(std.process.Child.StdIo.Ignore, control.stderr_behavior); + try std.testing.expectEqual(std.process.Child.StdIo.Pipe, control.stderr_behavior); try std.testing.expect(control.cwd == null); try std.testing.expectEqual(true, control.create_no_window); }