Fixes #5359. Normalize NeedsDrawRect to viewport-local coordinates#5435
Conversation
…ests CoPilot review of PR gui-cs#5435 caught three issues: 1. (Blocking) The subview cascade in View.NeedsDraw.cs subtracted subview.Viewport.Location on top of the adornment offset when converting to subview-viewport-local. That's wrong — viewport-local coords are scroll-INDEPENDENT ((0, 0) is always the top-left visible cell regardless of scroll; scroll changes which content cell appears there, not the viewport-local coords themselves). Subtracting the scroll shifted the dirty rect to a different on-screen position than the cells that were actually dirtied. ViewportToScreen treats input as scroll-independent, so the bug would also corrupt the framework's narrowing in CanNarrowClearToNeedsDrawRect for the subview's later self-redraw. Fix: only subtract the adornment offset. 2. (Medium) The RegionAwareClearViewportTests.FrameworkDraw_ScrolledView_FallsBackToFullClear test documented the workaround guard removed by this PR. With the guard gone the test was passing accidentally because its dirty rect (1, 6, 3, 2) was outside the 5-row viewport visible area (Y=6 > 4), so the narrowing intersected to empty and fell through for an unrelated reason. Delete the test — NeedsDrawCoordTests's FrameworkNarrowing_NowWorks_OnScrolledView covers the correct behavior. Replace with a comment block explaining the removal. 3. (Low) SetFrame's scroll translation in View.Layout.cs was only exercised by tests with SuperView.Viewport.Location == Point.Empty (where the translation is a no-op). Add SetFrame_ScrolledSuperView_TranslatesInvalidationToViewportLocal to cover the scrolled case. Also update SetNeedsDraw_ScrolledSubview_... test: the previous assertion locked in the buggy shifted rect. With the cascade corrected, parent dirty viewport-local (2, 2, 4, 2) → subview viewport-local (2, 2, 4, 2) (same on-screen cells, just rendered by the subview now). Full suites still green: 17285 / 74 / 433. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Normalizes NeedsDrawRect (and SetNeedsDraw(Rectangle)’s viewPortRelativeRegion) to viewport-local coordinates so dirty-rect accumulation, parent→subview propagation, and framework clear narrowing behave consistently under scrolling (including negative Viewport.Location).
Changes:
- Standardize invalidation to
(0,0)-based viewport-local rects (no scroll-offset leakage) and translate dirty regions correctly when cascading into subviews. - Update draw/layout plumbing to use the normalized contract (adornment escalation, region-aware clear narrowing on scrolled views, and
SetFrame→SuperViewinvalidation translation). - Add/adjust unit + integration tests to pin the coordinate convention and fan-out behavior.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| Tests/UnitTestsParallelizable/Views/TabView/TabsFanOutDiagnosticTests.cs | Switch diagnostics to track DrawingText (NeedsDraw-gated) to validate reduced inactive-tab fan-out. |
| Tests/UnitTestsParallelizable/ViewBase/Draw/SubViewOnlyRedrawTests.cs | New tests ensuring child-only invalidations don’t trigger parent clear/text/content redraw; includes TransparentMouse regression coverage. |
| Tests/UnitTestsParallelizable/ViewBase/Draw/RegionAwareClearViewportTests.cs | New tests covering public full-clear contract vs framework narrowing behavior, plus frame-shrink invalidation semantics. |
| Tests/UnitTestsParallelizable/ViewBase/Draw/NeedsDrawTests.cs | Update expectations to reflect true-union accumulation and viewport-local semantics after scroll/frame changes. |
| Tests/UnitTestsParallelizable/ViewBase/Draw/NeedsDrawCoordTests.cs | New tests pinning viewport-local invalidation across scroll/negative locations, subview adornments, and scrolled subviews. |
| Tests/IntegrationTests/TabsFanOutIntegrationTests.cs | Track DrawingText to better isolate NeedsDraw-gated fan-out vs remaining LayoutAndDraw(force=true) behavior. |
| Terminal.Gui/ViewBase/View.NeedsDraw.cs | Enforce viewport-local invalidation; fix no-arg SetNeedsDraw; correct parent→subview dirty-rect translation chain. |
| Terminal.Gui/ViewBase/View.Layout.cs | Translate SetFrame’s superview invalidation from content coords to superview viewport-local coords. |
| Terminal.Gui/ViewBase/View.Drawing.cs | Gate self-clear/text/content on a pre-adornment NeedsDraw snapshot; enable clear narrowing on scrolled views using normalized rects. |
| Terminal.Gui/ViewBase/View.Drawing.Adornments.cs | Ensure adornment escalation sets viewport-local full-viewport rect (no scroll offset). |
| Terminal.Gui/ViewBase/Adornment/AdornmentView.cs | Remove mid-pass SetNeedsDraw() churn from OnClearingViewport; update migration commentary. |
| Terminal.Gui/App/ApplicationImpl.Screen.cs | Document remaining draw fan-out source via LayoutAndDraw’s force=true path. |
| plans/5359-needsdraw-coord-normalization.md | Add design/verification notes documenting the new coordinate convention and test mapping. |
NeedsDrawRect and the viewPortRelativeRegion parameter of SetNeedsDraw are now in viewport-LOCAL coords: (0, 0) is the top-left visible cell of the View's Viewport, independent of Viewport.Location (scroll offset, possibly negative under AllowNegativeX/Y). Previously the no-arg SetNeedsDraw() passed Viewport (with the scroll offset baked in), DoDrawAdornments escalation did the same, and the subview cascade compared parent's viewport-relative region to subview.Frame (in parent's content coords) without accounting for the parent's scroll. PR gui-cs#5431 worked around the resulting inconsistency by gating its region-aware ClearViewport narrowing on "Viewport.Location == Point.Empty"; this commit normalizes the convention so that workaround can be removed in a follow-up commit. Changes: - SetNeedsDraw() no-arg now passes new (Point.Empty, Viewport.Size). - DoDrawAdornments NeedsDrawRect escalation does the same. - SetNeedsDraw(Rectangle) subview cascade translates the viewport-local region through: parent scroll -> content coords -> intersect with subview.Frame -> subview frame-local -> subtract subview adornment offset and subview scroll -> clip to subview viewport bounds. If the result is empty (dirty area is only in subview's adornments/scrolled- off content), fall back to a no-arg subview SetNeedsDraw so the subview redraws safely. - View.Layout.SetFrame translates the union(old, new) frame from SuperView content coords to SuperView viewport-local before passing to SuperView.SetNeedsDraw. - Update NeedsDrawRect_Is_Viewport_Relative test scrolled-frame assertions to reflect the new convention (NeedsDrawRect no longer acquires scroll-position offsets). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e normalized PR gui-cs#5431 added a "Viewport.Location == Point.Empty" early-return to CanNarrowClearToNeedsDrawRect because the cascade in SetNeedsDraw produced rects in a different coordinate space than ViewportToScreen expects. Issue gui-cs#5359 normalized NeedsDrawRect on viewport-local coordinates, so the conversion via ViewportToScreen is now safe regardless of the view's scroll state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nine tests pin the viewport-local coordinate convention: - SetNeedsDraw() no-arg on a scrolled view stores (0, 0, W, H). - SetNeedsDraw() no-arg on a view with negative Viewport.Location stores (0, 0, W, H). - Subview cascade from a scrolled parent produces correct subview- viewport-local rect (parent scroll translated out). - Subview-with-padding cascade produces subview-viewport-local coords (adornment offset subtracted), not subview-frame-local. - Scrolled-subview cascade subtracts the subview's own scroll on top of the frame and adornment offsets. - Dirty region overlapping only the subview's adornment area falls back to a full subview SetNeedsDraw (the safe fallback). - Framework ClearViewport narrowing now works on scrolled views (the scrolled-view guard removal from this PR). - Zero-size viewport is a no-op. - Repeated small invalidations on a scrolled view accumulate as a viewport-local union. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ests CoPilot review of PR gui-cs#5435 caught three issues: 1. (Blocking) The subview cascade in View.NeedsDraw.cs subtracted subview.Viewport.Location on top of the adornment offset when converting to subview-viewport-local. That's wrong — viewport-local coords are scroll-INDEPENDENT ((0, 0) is always the top-left visible cell regardless of scroll; scroll changes which content cell appears there, not the viewport-local coords themselves). Subtracting the scroll shifted the dirty rect to a different on-screen position than the cells that were actually dirtied. ViewportToScreen treats input as scroll-independent, so the bug would also corrupt the framework's narrowing in CanNarrowClearToNeedsDrawRect for the subview's later self-redraw. Fix: only subtract the adornment offset. 2. (Medium) The RegionAwareClearViewportTests.FrameworkDraw_ScrolledView_FallsBackToFullClear test documented the workaround guard removed by this PR. With the guard gone the test was passing accidentally because its dirty rect (1, 6, 3, 2) was outside the 5-row viewport visible area (Y=6 > 4), so the narrowing intersected to empty and fell through for an unrelated reason. Delete the test — NeedsDrawCoordTests's FrameworkNarrowing_NowWorks_OnScrolledView covers the correct behavior. Replace with a comment block explaining the removal. 3. (Low) SetFrame's scroll translation in View.Layout.cs was only exercised by tests with SuperView.Viewport.Location == Point.Empty (where the translation is a no-op). Add SetFrame_ScrolledSuperView_TranslatesInvalidationToViewportLocal to cover the scrolled case. Also update SetNeedsDraw_ScrolledSubview_... test: the previous assertion locked in the buggy shifted rect. With the cascade corrected, parent dirty viewport-local (2, 2, 4, 2) → subview viewport-local (2, 2, 4, 2) (same on-screen cells, just rendered by the subview now). Full suites still green: 17285 / 74 / 433. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
676a2c2 to
c49d075
Compare
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Correct the step-4 cascade comment to say GetViewportOffsetFromFrame() is the combined Margin/Border/Padding inset (not just Padding), and update the plan to match the implemented contract — the cascade does not subtract the subview's Viewport.Location because viewport-local coordinates are scroll-independent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Copilot autofix in 491f190 opened <para> twice with a single closing tag. Remove the stray opening tag so the doc comment is well-formed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
LGTM;clean fix, solid tests. Please remove plans/5359-needsdraw-coord-normalization.md before merge.
One question: in the subview cascade fallback (line 174 in View.NeedsDraw.cs), when the dirty region overlaps the subview's frame but lands entirely in its adornment area, you call the no-arg SetNeedsDraw() which marks the entire subview viewport dirty. Was a more targeted adornment-only invalidation considered, or is full-subview the intentional safe default here?
|
Removed the plan file - thanks for noticing that. Full-subview was the intentional safe default. At that point in the cascade, all we know is that the parent dirty region intersected the subview’s frame. But after translating into the subview’s viewport there’s no viewport-local region left, it’s all adornment territory. I asked my agent to consider a narrower adornment-only invalidation, but we don’t have a separate targeted dirty region path there for border/padding, and the current draw contract treats adornment dirtiness as part of the subview redraw path anyway. So kept subview.SetNeedsDraw () as the conservative fallback, and the SetNeedsDraw_DirtyRegionInSubviewAdornmentOnly_FallsBackToFullSubviewRedraw test is meant to pin that behavior. We could do a follow-up issue to try to optimize that case though. |
Summary
Note
#5431 has merged to
develop. This branch has been rebased onto the post-mergedeveloptip, so the diff now contains only the #5359 commits.Standardizes
NeedsDrawRectand theviewPortRelativeRegionparameter ofSetNeedsDrawon viewport-local coordinates:(0, 0)is the top-left visible cell of the View'sViewport, independent ofViewport.Location(scroll offset, possibly negative underAllowNegativeX/Y). This matches the conventionViewportToScreen(in Rectangle)already expects.This is the deferred coordinate-system normalization called out in #5431's description:
Split from #4973.
Root causes
SetNeedsDraw()no-arg passedViewportdirectly — so the scroll offset (Viewport.Location, possibly negative) leaked intoNeedsDrawRect.DoDrawAdornmentsescalation did the same:NeedsDrawRect = Viewport.SetNeedsDraw(Rectangle)subview cascade comparedsubview.Frame(in parent's content coords) directly toviewPortRelativeRegion(intended as viewport-local). Only correct whenparent.Viewport.Location == (0, 0), and it ignored the subview's own adornment offset.SetFrame's SuperView invalidation (added in Fixes #5358. Narrow draw pipeline to skip parent on child-only redraws #5431) passedRectangle.Union(prev, frame)— in SuperView's CONTENT coords — toSuperView.SetNeedsDraw, which expects viewport-local.PR #5431 worked around (3) by gating its region-aware
ClearViewportnarrowing onViewport.Location == Point.Empty. With the convention normalized, that guard is removed.Changes
View.NeedsDraw.csNeedsDrawRectdoc comment now explicitly states viewport-LOCAL semantics.SetNeedsDraw()no-arg passesnew (Point.Empty, Viewport.Size)— never leaks the scroll offset.SetNeedsDraw(Rectangle)subview cascade translates through the chain: parent's viewport-local → parent's content coords (add parentViewport.Location) → intersect withsubview.Frame→ subview-frame-local (subtractFrame.Location) → subview-viewport-local (subtractGetViewportOffsetFromFrame()) → clip to the subview's viewport bounds. The subview's ownViewport.Locationis not subtracted: viewport-local coords are scroll-independent —(0, 0)is always the top-left visible cell, and we propagate the dirty on-screen cells, not the content cells they happen to be showing. If the clipped result is empty (dirty area is only in the subview's adornments), the cascade falls back to a no-arg subviewSetNeedsDrawso the subview redraws safely and its adornments are flagged.View.Drawing.Adornments.cs—DoDrawAdornmentsescalation:NeedsDrawRect = new (Point.Empty, Viewport.Size)instead ofViewport.View.Layout.cs—SetFrametranslatesRectangle.Union(prev, frame)from SuperView content coords to SuperView viewport-local before invalidating.View.Drawing.cs—CanNarrowClearToNeedsDrawRectno longer early-returns whenViewport.Location != Point.Empty. Updates theDoClearViewportcomment block to reflect the new contract.Testing
dotnet build --no-restore— succeeds, no new warnings.dotnet test --project Tests/UnitTestsParallelizable --no-build— 17270 pass / 0 fail / 17 skip after rebase onto post-mergedevelop.New tests —
Tests/UnitTestsParallelizable/ViewBase/Draw/NeedsDrawCoordTests.cs(10 tests):SetNeedsDraw_NoArg_OnScrolledView_StoresViewportLocalRectSetNeedsDraw_NoArg_OnNegativeViewportLocation_StoresViewportLocalRectSetNeedsDraw_ScrolledParent_CascadesViewportLocalRectToSubviewSetNeedsDraw_SubViewWithPadding_CascadesViewportLocalRectIntoSubviewViewportSetNeedsDraw_ScrolledSubview_CascadesIntoSubviewViewportAtCorrectOnScreenPositionSetNeedsDraw_DirtyRegionInSubviewAdornmentOnly_FallsBackToFullSubviewRedrawFrameworkNarrowing_NowWorks_OnScrolledViewSetNeedsDraw_ZeroSizeViewport_IsNoOpSetFrame_ScrolledSuperView_TranslatesInvalidationToViewportLocalSetNeedsDraw_RepeatedSmallInvalidationsOnScrolledView_AccumulateAsViewportLocalUnionUpdated tests:
Tests/UnitTestsParallelizable/ViewBase/Draw/NeedsDrawTests.cs—NeedsDrawRect_Is_Viewport_Relativescrolled-frame assertions updated to reflect the new convention (post-scrollSetFrameno longer accumulates a scroll-offset rect).Tests/UnitTestsParallelizable/ViewBase/Draw/RegionAwareClearViewportTests.cs— removed the now-obsoleteFrameworkDraw_ScrolledView_FallsBackToFullCleartest (it documented Fixes #5358. Narrow draw pipeline to skip parent on child-only redraws #5431's temporaryViewport.Location == Point.Emptyguard, now removed); replaced with a pointer comment toNeedsDrawCoordTests.FrameworkNarrowing_NowWorks_OnScrolledView.Acceptance criteria mapping
Rectangle.Unionfix). ExistingSetNeedsDraw_MultipleRectangles_Expandstest covers; new…_RepeatedSmallInvalidationsOnScrolledView_…test pins it across the new coord convention._ScrolledParent_,_SubViewWithPadding_,_ScrolledSubview_,_DirtyRegionInSubviewAdornmentOnly_tests.SetNeedsDraw()no-arg fix + new_OnScrolledView_,_OnNegativeViewportLocation_tests.