Web source: stable viewport (snapshot at register) + Stretch=Fill + overflow:hidden — no reflow, no scrollbars, no dead space

This commit is contained in:
2026-08-28 10:56:23 -07:00
parent 8d793af91a
commit 23874c2dd6
4 changed files with 44 additions and 66 deletions
+16 -24
View File
@@ -6,34 +6,26 @@
working). **NOT pushed — user rule: never push without explicit instruction (2026-08-24).** No feature working). **NOT pushed — user rule: never push without explicit instruction (2026-08-24).** No feature
branches pre-1.0: all work lands on `main` per work unit. branches pre-1.0: all work lands on `main` per work unit.
## The web-source rendering work (2026-08-28) — CURRENT MODEL: viewport == element box ## The web-source rendering work (2026-08-28) — CURRENT MODEL: stable viewport + Fill
**Background:** the fixed-viewport fix (1920×1080 control + `Stretch="Uniform"`) made the page **Background:** viewport==box made dragging the box reflow the page (truncation, scrollbars,
render without truncation but left dead space between the selection box and the content (box unrecoverable image, "janky at stressful geometries"). Fixed-viewport + `Uniform` left letterbox
aspect ≠ capture aspect → letterbox; box is sized by the parent layout, capture is a separate dead space. CSS reset enlarged the gap. Scrollbars were called "super fail".
hardcoded viewport — they bind to different rects). A CSS-reset experiment made the gap worse
and was shelved by the creator. He stressed the systems must be GEOMETRY-AGNOSTIC: no 16:9,
no fixed render size, no per-widget assumptions.
**The model that delivers it (implemented, needs visual verification):** **The model that satisfies invariants (implemented, needs visual verification):**
- `Services/WebView2Manager.cs` — the WebView2 viewport IS the element box - `Services/WebView2Manager.cs` — the page renders ONCE at the viewport size the element was
(`Source.Width × Source.Height`). The page renders at exactly the box size; a `PropertyChanged` created at (snapshot in `Register`, never tracked → no reflow, no truncation during resize).
hook keeps the viewport locked to the box (re-triggers capture on resize, sub-0.5px drift is `overflow:hidden` (html+body) + transparent-bg injection on every nav — nothing else, no
ignored). `CaptureFrame` is unchanged (aspect-preserving Physical-size capture). layout/CSS meddling. Immediate capture on `NavigationCompleted`. `FramePump` unchanged.
- `MainWindow.xaml` — web `<Image>` uses `Stretch="Fill"` (capture aspect == box aspect by - `MainWindow.xaml` — web `<Image>` uses `Stretch="Fill"`: any box the user draws is covered
construction, so Fill == Uniform with zero dead space; during a transient stale frame it just edge-to-edge, zero dead space, zero crop (OBS free-transform: fixed browser resolution,
holds aspect briefly). Everything else still `UniformToFill`. scene-item scales the texture). Aspect distortion only when the box aspect ≠ viewport aspect.
- Selection rect == content rect by construction → flush edges, no padding, works for any widget - Invariants: **page viewport is stable; display Fill-scales texture to the box.**
geometry the creator throws at it. FramePump still reads `GetLatestFrame` via `ResolveOutputFrame`.
- Transparent-background JS injection retained (background only — no layout/CSS-reset meddling).
Build: 0 warnings; WebView2Manager tests 4/4 (size tracks source, sub-pixel drift ignored, Build: 0 warnings; tests 4/4 (snapshot at register, no tracking after, min 1, transparent bg).
minimum 1, transparent bg).
**Landmines (earlier attempts — do not repeat):** fixed-viewport (letterbox dead space), **Rejected for the record:** fixed 1920×1080+`Uniform` (letterbox), CSS margin/100% reset
CSS reset/margin juggling (enlarged the gap), control-tracking-box combined with (enlarged gap), viewport==box (reflow/truncation/scrollbars).
`UniformToFill` display (truncation), ZoomFactor meddling. The working invariant is:
**viewport rect == selection rect, display = Fill.**
## Key facts / landmines ## Key facts / landmines
+12 -22
View File
@@ -40,10 +40,12 @@ public sealed class WebView2Manager : IDisposable
{ {
if (_sessions.ContainsKey(source.Id)) return; if (_sessions.ContainsKey(source.Id)) return;
// Geometry-agnostic OBS model: the browser viewport IS the element box. // OBS model: the page renders ONCE at a stable viewport — the size the
// The page renders at exactly Source.Width × Source.Height, so the // element was created at (snapshot, never tracked, so dragging the box
// captured frame and the selection rect are always the same rectangle — // never reflows/truncates the page and no scrollbars appear). The
// no fixed render size, no aspect assumptions, no dead space or padding. // captured texture is then stretched by the display (Stretch=Fill) into
// whatever box the user draws, so the frame always bounds content with
// zero dead space. Geometry-agnostic: any widget, any box.
var webView = new WebView2 var webView = new WebView2
{ {
Width = Math.Max(1, source.Width), Width = Math.Max(1, source.Width),
@@ -69,22 +71,6 @@ public sealed class WebView2Manager : IDisposable
_sessions[source.Id] = session; _sessions[source.Id] = session;
// Interop sync: keep the viewport locked to the element box so the
// selection rect always bounds the actual rendered content.
source.PropertyChanged += (_, e) =>
{
if (e.PropertyName is not (nameof(Source.Width) or nameof(Source.Height))) return;
if (session.Disposed) return;
if (Math.Abs(session.Control.Width - source.Width) > 0.5 ||
Math.Abs(session.Control.Height - source.Height) > 0.5)
{
session.Control.Width = Math.Max(1, source.Width);
session.Control.Height = Math.Max(1, source.Height);
if (session.Initialized)
_ = CaptureFrame(source.Id);
}
};
timer.Tick += (_, _) => timer.Tick += (_, _) =>
{ {
if (!session.Disposed && session.Initialized) if (!session.Disposed && session.Initialized)
@@ -129,14 +115,18 @@ public sealed class WebView2Manager : IDisposable
{ {
_ = cws.ExecuteScriptAsync( _ = cws.ExecuteScriptAsync(
"document.documentElement.style.background='transparent';" + "document.documentElement.style.background='transparent';" +
"document.body.style.background='transparent';"); "document.documentElement.style.overflow='hidden';" +
"document.body.style.background='transparent';" +
"document.body.style.overflow='hidden';");
}; };
cws.NavigationCompleted += (_, _) => cws.NavigationCompleted += (_, _) =>
{ {
_ = cws.ExecuteScriptAsync( _ = cws.ExecuteScriptAsync(
"document.documentElement.style.background='transparent';" + "document.documentElement.style.background='transparent';" +
"document.body.style.background='transparent';"); "document.documentElement.style.overflow='hidden';" +
"document.body.style.background='transparent';" +
"document.body.style.overflow='hidden';");
_ = CaptureFrame(source.Id); _ = CaptureFrame(source.Id);
}; };
+11 -9
View File
@@ -917,15 +917,17 @@ capturing the primary display (which causes the infinity mirror effect).
8. ✅ Preview shows live web content (not just a blank rectangle) 8. ✅ Preview shows live web content (not just a blank rectangle)
9. ☐ Handle navigation errors, invalid URIs, timeout gracefully 9. ☐ Handle navigation errors, invalid URIs, timeout gracefully
**Rendering model (2026-08-28, geometry-agnostic — supersedes the earlier fixed-viewport model):** **Rendering model (2026-08-28, stable-viewport + fill — the working invariant):**
the WebView2 viewport IS the element box (`Source.Width × Source.Height`); the page renders at the page renders ONCE at the size the element was created at (snapshot in `Register`, never
exactly the box size and the captured frame is displayed with `Stretch="Fill"`, so the selection tracked) — so dragging the box never reflows the page, never truncates it, and scrollbars are
rect always bounds the actual rendered content — no fixed render size, no aspect assumptions, suppressed (`overflow:hidden` on html/body injection; background-transparent only otherwise,
no dead space/padding for any widget geometry. A `PropertyChanged` hook keeps the viewport no layout meddling). The captured texture is displayed with `Stretch="Fill"`, so any box the
locked to the box; capture is re-triggered on resize. Graceful-error handling (item 9) is a user draws is covered edge-to-edge with zero dead space (OBS free-transform behavior:
follow-up. Earlier attempts for the record: (a) fixed 1920×1080 viewport + `Stretch="Uniform"` browser renders at a fixed resolution, the scene-item scales the texture). The invariants:
fixed the truncation but left box-vs-content gaps (box aspect ≠ capture aspect); (b) CSS reset the page viewport is stable; the display Fill-scales it to the box. Graceful-error handling
injection made gaps larger (belayed by creator). (item 9) is a follow-up. Attempts rejected for the record: fixed 1920×1080 + `Uniform`
(letterbox dead space), CSS margin/100% reset (enlarged the gap), viewport==box (page reflow
→ truncation/scrollbars/jank).
### Design decisions ### Design decisions
+5 -11
View File
@@ -20,7 +20,7 @@ public sealed class WebView2ManagerTests
} }
[Fact] [Fact]
public void WebView2_Control_Size_Tracks_Source_Dimensions() public void WebView2_Control_Size_Snapshots_Source_At_Register()
{ {
_app.Run(() => _app.Run(() =>
{ {
@@ -38,17 +38,11 @@ public sealed class WebView2ManagerTests
var webView = panel.Children.OfType<WebView2>().First(); var webView = panel.Children.OfType<WebView2>().First();
Assert.Equal(640.0, webView.Width); Assert.Equal(640.0, webView.Width);
Assert.Equal(480.0, webView.Height); Assert.Equal(480.0, webView.Height);
source.Width = 1024;
source.Height = 768;
Assert.Equal(1024.0, webView.Width);
Assert.Equal(768.0, webView.Height);
}); });
} }
[Fact] [Fact]
public void WebView2_Control_Size_Ignores_Sub_One_Pixel_Drift() public void WebView2_Control_Size_Does_Not_Track_Box_After_Register()
{ {
_app.Run(() => _app.Run(() =>
{ {
@@ -65,10 +59,10 @@ public sealed class WebView2ManagerTests
var webView = panel.Children.OfType<WebView2>().First(); var webView = panel.Children.OfType<WebView2>().First();
source.Width = 640.4; source.Width = 1024;
Assert.Equal(640.0, webView.Width); source.Height = 768;
source.Height = 480.4; Assert.Equal(640.0, webView.Width);
Assert.Equal(480.0, webView.Height); Assert.Equal(480.0, webView.Height);
}); });
} }