Web source: crop capture to actual content bounds (anchor 0,0); slider click-to-position; web URI X clears; tab order

This commit is contained in:
2026-08-28 11:10:49 -07:00
parent 23874c2dd6
commit f39ebd96ce
6 changed files with 98 additions and 30 deletions
+20 -14
View File
@@ -6,26 +6,32 @@
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.
## The web-source rendering work (2026-08-28) — CURRENT MODEL: stable viewport + Fill
## The web-source rendering work (2026-08-28) — CURRENT MODEL: stable viewport + content-bounds crop
**Background:** viewport==box made dragging the box reflow the page (truncation, scrollbars,
unrecoverable image, "janky at stressful geometries"). Fixed-viewport + `Uniform` left letterbox
dead space. CSS reset enlarged the gap. Scrollbars were called "super fail".
dead space. CSS reset enlarged the gap. Scrollbars were called "super fail". The right/bottom
dead-space + "not anchored at (0,0)" report persisted across every model — dead space was
page-internal (widget content smaller than the viewport), which is now cropped out.
**The model that satisfies invariants (implemented, needs visual verification):**
- `Services/WebView2Manager.cs` — the page renders ONCE at the viewport size the element was
created at (snapshot in `Register`, never tracked → no reflow, no truncation during resize).
`overflow:hidden` (html+body) + transparent-bg injection on every nav — nothing else, no
layout/CSS meddling. Immediate capture on `NavigationCompleted`. `FramePump` unchanged.
- `MainWindow.xaml` — web `<Image>` uses `Stretch="Fill"`: any box the user draws is covered
edge-to-edge, zero dead space, zero crop (OBS free-transform: fixed browser resolution,
scene-item scales the texture). Aspect distortion only when the box aspect ≠ viewport aspect.
- Invariants: **page viewport is stable; display Fill-scales texture to the box.**
**The model that satisfies invariants (implemented, visual verification pending):**
- `Services/WebView2Manager.cs` — stable viewport = the element's size at `Register` (snapshot,
never tracked → no reflow/truncation during resize). `overflow:hidden` (html+body) +
transparent-bg injection on every nav (nothing else). After each navigation,
`QueryContentBoundsAsync` reads the page's real content rect
(`scrollWidth/scrollHeight` vs `innerWidth/innerHeight` via `ExecuteScriptAsync` + JSON) and
`CaptureFrame` **crops the capture to the actual content bounds, anchored at (0,0)** — dead
space inside the viewport no longer reaches the box. Immediate capture on `NavigationCompleted`.
- `MainWindow.xaml` — web `<Image>` uses `Stretch="Fill"`: the cropped frame maps edge-to-edge,
so the selection box always bounds the real widget. `FramePump` unchanged.
- Invariants: **page viewport stable; display Fill-crops to content and fills the box.**
Build: 0 warnings; tests 4/4 (snapshot at register, no tracking after, min 1, transparent bg).
**Property panel / sliders (2026-08-28):** web URI ✕ clears the textbox; web ✓/✕ icon buttons
`IsTabStop="False"` so Tab walks X→Y→W→H→URI; Slider style `IsMoveToPointEnabled="True"`
restores click-anywhere-on-bar.
**Rejected for the record:** fixed 1920×1080+`Uniform` (letterbox), CSS margin/100% reset
(enlarged gap), viewport==box (reflow/truncation/scrollbars).
Build: 0 warnings; tests 4/4. Rejected for the record: fixed 1920×1080+`Uniform` (letterbox),
CSS margin/100% reset (enlarged gap), viewport==box (reflow/truncation/scrollbars).
## Key facts / landmines
+2 -2
View File
@@ -546,13 +546,13 @@
<TextBlock Text="URI" Foreground="#a0a0b0" FontSize="11" Margin="0,0,0,2"/>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" Margin="4,0,0,0"
Width="22" Height="22" Margin="4,0,0,0" IsTabStop="False"
Click="WebAccept_Click">
<Path Data="M4,9 L8,14 L16,4" Stroke="#22c55e" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
</Button>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Width="22" Height="22" ToolTip="Revert"
Width="22" Height="22" ToolTip="Revert" IsTabStop="False"
Click="WebRevert_Click" Margin="4,0,0,0">
<Path Data="M5,5 L15,15 M15,5 L5,15" Stroke="#e94560" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/>
+2 -1
View File
@@ -859,7 +859,8 @@ public partial class MainWindow : Window
private void WebRevert_Click(object sender, RoutedEventArgs e)
{
if (_snapshotElement is Models.Source src)
src.WebUri = _snapshotWebUri;
src.WebUri = string.Empty;
_snapshotWebUri = string.Empty;
}
private void WebAccept_Click(object sender, RoutedEventArgs e)
+58 -3
View File
@@ -1,6 +1,7 @@
using System;
using System.Drawing;
using System.IO;
using System.Text.Json;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
@@ -26,6 +27,10 @@ public sealed class WebView2Manager : IDisposable
public required WebView2 Control { get; init; }
public required DispatcherTimer CaptureTimer { get; init; }
public VideoFrame? LatestFrame;
public double ViewportCssW;
public double ViewportCssH;
public double ContentCssW;
public double ContentCssH;
public bool Disposed;
public bool Initialized;
}
@@ -127,6 +132,7 @@ public sealed class WebView2Manager : IDisposable
"document.documentElement.style.overflow='hidden';" +
"document.body.style.background='transparent';" +
"document.body.style.overflow='hidden';");
_ = QueryContentBoundsAsync(source.Id);
_ = CaptureFrame(source.Id);
};
@@ -153,6 +159,35 @@ public sealed class WebView2Manager : IDisposable
catch (Exception ex) { AppLog.Write($"WebView2Manager: navigate failed: {ex.Message}"); }
}
// The page's real content rect (scrollWidth/Height) can be smaller than the
// viewport for widgets that don't fill it — the leftover area is dead space
// that must not stretch across the selection box. Crop the capture to the
// content bounds (anchored at 0,0) so the box shows exactly the widget.
private async Task QueryContentBoundsAsync(string sourceId)
{
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
try
{
if (session.Control.CoreWebView2 is not { } cws) return;
var json = await cws.ExecuteScriptAsync(
"JSON.stringify({w:document.documentElement.scrollWidth||0," +
"h:document.documentElement.scrollHeight||0," +
"vw:window.innerWidth||0,vh:window.innerHeight||0})");
if (string.IsNullOrEmpty(json) || json == "null") return;
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
session.ViewportCssW = root.GetProperty("vw").GetDouble();
session.ViewportCssH = root.GetProperty("vh").GetDouble();
session.ContentCssW = root.GetProperty("w").GetDouble();
session.ContentCssH = root.GetProperty("h").GetDouble();
_ = CaptureFrame(sourceId);
}
catch (Exception ex)
{
AppLog.Write($"WebView2Manager: content bounds query failed: {ex.Message}");
}
}
private async Task CaptureFrame(string sourceId)
{
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
@@ -187,11 +222,31 @@ public sealed class WebView2Manager : IDisposable
formatted.CopyPixels(pixels, pixW * 4, 0);
var stride = pixW * 4;
var wb = new WriteableBitmap(pixW, pixH, 96, 96, PixelFormats.Bgra32, null);
wb.WritePixels(new Int32Rect(0, 0, pixW, pixH), pixels, stride, 0);
int cropW = pixW, cropH = pixH;
if (session.ViewportCssW >= 1 && session.ViewportCssH >= 1 &&
session.ContentCssW >= 1 && session.ContentCssH >= 1)
{
var sx = pixW / session.ViewportCssW;
var sy = pixH / session.ViewportCssH;
cropW = (int)Math.Clamp(Math.Round(session.ContentCssW * sx), 1, pixW);
cropH = (int)Math.Clamp(Math.Round(session.ContentCssH * sy), 1, pixH);
}
var outPixels = pixels;
if (cropW != pixW || cropH != pixH)
{
var dstStride = cropW * 4;
outPixels = new byte[dstStride * cropH];
for (var r = 0; r < cropH; r++)
Buffer.BlockCopy(pixels, r * stride, outPixels, r * dstStride, dstStride);
}
var wb = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null);
wb.WritePixels(new Int32Rect(0, 0, cropW, cropH), outPixels, cropW * 4, 0);
wb.Freeze();
session.LatestFrame = new VideoFrame(pixW, pixH, pixels);
session.LatestFrame = new VideoFrame(cropW, cropH, outPixels);
_ = _dispatcher.BeginInvoke(() =>
{
+15 -10
View File
@@ -917,17 +917,22 @@ capturing the primary display (which causes the infinity mirror effect).
8. ✅ Preview shows live web content (not just a blank rectangle)
9. ☐ Handle navigation errors, invalid URIs, timeout gracefully
**Rendering model (2026-08-28, stable-viewport + fill — the working invariant):**
**Rendering model (2026-08-28, stable viewport + content-bounds crop):**
the page renders ONCE at the size the element was created at (snapshot in `Register`, never
tracked) — so dragging the box never reflows the page, never truncates it, and scrollbars are
suppressed (`overflow:hidden` on html/body injection; background-transparent only otherwise,
no layout meddling). The captured texture is displayed with `Stretch="Fill"`, so any box the
user draws is covered edge-to-edge with zero dead space (OBS free-transform behavior:
browser renders at a fixed resolution, the scene-item scales the texture). The invariants:
the page viewport is stable; the display Fill-scales it to the box. Graceful-error handling
(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).
tracked) — dragging the box never reflows the page; scrollbars suppressed via
`overflow:hidden` html/body injection (background-transparent only otherwise). After each
navigation the manager queries the page's real content rect (`scrollWidth/scrollHeight` vs
`innerWidth/innerHeight`) and **crops the capture to the actual content bounds, anchored at
(0,0)** — widgets that don't fill their viewport no longer stretch dead space across the box.
`Stretch="Fill"` maps the cropped frame edge-to-edge (box is covered; the fill/adornment rect
snaps to it). Graceful-error handling (item 9) is a follow-up. Rejected for the record:
fixed-viewport 1920×1080 + `Uniform` (letterbox), CSS margin/100% reset (enlarged gaps),
viewport==box (page reflow → truncation/scrollbars).
**Properties panel (2026-08-28):** web URI ✓/✕ icon buttons are `IsTabStop="False"` so Tab flows
X→Y→W→H→URI; the ✕ button now clears the URI textbox (was reverting to the pre-accept snapshot).
Slider style gained `IsMoveToPointEnabled="True"` — click-anywhere-on-bar jumps the thumb to the
click (volume sliders keep their manual `SetSliderValueFromClick`, harmless duplication).
### Design decisions
+1
View File
@@ -352,6 +352,7 @@
<Style TargetType="Slider">
<Setter Property="Height" Value="20"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="IsMoveToPointEnabled" Value="True"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Slider">