using AngleSharp.Dom; using AngleSharp.Html.Dom; using XFESystem.Desktop; namespace XFESystem.Browser; public sealed partial class BrowserService { private readonly long paintIdentity = BrowserPaintIdentity.Next(); private BrowserViewport viewport = new(760, 380); private BrowserDisplayList? displayList; private readonly BrowserLayoutEngine layoutEngine = new(); private IBrowserTextMetrics textMetrics = new BrowserFallbackTextMetrics(); private IDesktopWebCanvas? metricCanvas; private INode? hoveredNode, pointerDownNode; private bool layoutDirty = true; private int tracePumps, tracePaints; internal BrowserViewport BackgroundViewport => viewport; public long Revision { get; private set; } public long LayoutPassCount { get; private set; } internal long LayoutTicks { get; private set; } public long ScrollLayoutReuseCount { get; private set; } public long RasterPaintCount { get; private set; } public long CachedPaintCount { get; private set; } public long PackagedScriptCacheHits => runtime?.PackagedScriptCacheHits ?? 0; public long PackagedScriptPreparations => runtime?.PackagedScriptPreparations ?? 0; public string LastInputTrace { get; private set; } = ""; public void ResizeViewport(int width, int height) { var size = new BrowserViewport(Math.Max(1, width), Math.Max(1,height), viewport.ScrollX, viewport.ScrollY); if (size != viewport) { viewport = size; layoutDirty = true; dom?.DispatchEvent(null, "resize", new() { Bubbles = false, Cancelable = false }); } } public void SetCanvas(IDesktopCanvas canvas) { if (canvas is IDesktopWebCanvas web && !ReferenceEquals(web, metricCanvas)) { metricCanvas = web; textMetrics = new CanvasMetrics(web); layoutDirty = true; } } public void CompleteEditorPreparationHandoff(IDesktopCanvas canvas, bool equivalentTextMetrics) { if (!equivalentTextMetrics || metricCanvas is null || canvas is not IDesktopWebCanvas web) { SetCanvas(canvas); return; } // The worker stopped accessing its page before publication. The native // platform has now verified matching assets/scale and transferred the // private caches. Switching equivalent metrics needs no CSS reflow. metricCanvas = web; textMetrics = new CanvasMetrics(web); } internal BrowserPaintSnapshot CaptureEditorPaintSnapshot() { BrowserDisplayList list; if (document is not null) list = EnsureLayout(); else { // Failed HTTPS/offline navigation still publishes a visible error // frame. It must not throw a second error by laying out a null DOM. var commands = new List { new(BrowserDrawKind.Fill, new(0, 0, viewport.Width, viewport.Height), 0, null, Color: 0x111827) }; int columns = Math.Max(8, (int)(viewport.Width - 32) / 8), row = 0; foreach (string line in CurrentPage.Text.Split('\n')) for (int start = 0; start < line.Length; start += columns) commands.Add(new(BrowserDrawKind.Text, new(16, 16 + row++ * 22, viewport.Width - 32, 22), commands.Count, null, Color: 0xBCD4F5, Text: line.Substring(start, Math.Min(columns, line.Length - start)), Font: new(14, "system-ui"))); list = new(viewport, commands, [], [], viewport.Width, Math.Max(viewport.Height, 32 + row * 22)); } return BrowserPaintSnapshot.Capture(list, node => dom is not null && dom.TryCanvas(node, out var surface) ? surface : null, textPreparation: metricCanvas as IDesktopWebTextPreparationCanvas, sourceImages: FindImage); } internal void ScrollBackgroundDocument(double deltaY) { if (document is null || !double.IsFinite(deltaY)) return; double maximum = Math.Max(0, EnsureLayout().DocumentHeight - viewport.Height); UpdateViewport(viewport with { ScrollY = Math.Clamp(viewport.ScrollY + deltaY, 0, maximum) }); } public double DocumentHeight => document is null ? 0 : EnsureLayout().DocumentHeight; public bool TextCursor => document is not null && hoveredNode is not null && EnsureLayout().GetComputedStyle(hoveredNode)?.GetPropertyValue("cursor") is "text" or "vertical-text"; public BrowserDisplayList Layout(BrowserViewport size, IBrowserTextMetrics? metrics = null) => WithGate(() => { if (metrics is not null && !ReferenceEquals(metrics, textMetrics)) { textMetrics = metrics; layoutDirty = true; } UpdateViewport(size); return EnsureLayout(); }); private void UpdateViewport(BrowserViewport size) { if (viewport == size) return; bool resize = viewport.Width != size.Width || viewport.Height != size.Height; bool scroll = viewport.ScrollX != size.ScrollX || viewport.ScrollY != size.ScrollY; if (!resize && !layoutDirty && displayList is not null && displayList.CanReuseForScroll(size)) { displayList = displayList.WithScroll(size); ScrollLayoutReuseCount++; } else layoutDirty = true; viewport = size; // The original website reader listens to the real window scroll event. // Dispatch after publishing viewport geometry; its RAF reads scrollY // and getBoundingClientRect from the same worker-owned document. if (scroll) dom?.DispatchEvent(null, "scroll", new() { Bubbles = false, Cancelable = false }); } private void InvalidateDocument() { layoutDirty = true; Revision++; } private void InvalidateInteraction(BrowserInteractionState before, BrowserInteractionState after) { if (layoutDirty || displayList is null || layoutEngine.InteractionChangesStyles(before, after)) InvalidateDocument(); } private BrowserDisplayList EnsureLayout() { if (document is null) throw new InvalidOperationException("No HTML document is loaded."); // A geometry query may have prepared this layout while author code was // running. Decode its display images when the owner next uses it // outside script, even if no DOM mutation requires another layout. if (!layoutDirty && displayList is not null && LoadDisplayImages(displayList)) layoutDirty = true; if (layoutDirty || displayList is null) { LayoutPassCount++; long layoutStarted = System.Diagnostics.Stopwatch.GetTimestamp(); long layoutAllocated = GC.GetAllocatedBytesForCurrentThread(); displayList = layoutEngine.Layout(document, viewport, linkedStyles.Select(item => new BrowserStyleSheet(item.Css, item.Owner)).ToArray(), textMetrics, dom is null ? null : dom.NodeIdentity, cspBlocksAuthorStyles, new(hoveredNode, pointerDownNode, dom?.FocusedNode), FindImage); if (LoadDisplayImages(displayList)) displayList = layoutEngine.Layout(document, viewport, linkedStyles.Select(item => new BrowserStyleSheet(item.Css, item.Owner)).ToArray(), textMetrics, dom is null ? null : dom.NodeIdentity, cspBlocksAuthorStyles, new(hoveredNode, pointerDownNode, dom?.FocusedNode), FindImage); long elapsed = System.Diagnostics.Stopwatch.GetTimestamp() - layoutStarted; if (currentAddress?.Scheme is "http" or "https") Trace?.Invoke("XFE_BROWSER_LAYOUT_COST|Pass|" + LayoutPassCount + "|Ticks|" + elapsed + "|AllocatedBytes|" + (GC.GetAllocatedBytesForCurrentThread() - layoutAllocated) + "|InScript|" + (runtime?.IsExecutingScript == true || dom?.IsExecutingCallback == true ? 1 : 0) + "\n"); LayoutTicks += elapsed; if (awaitingDocumentPresentation) { documentLayoutTicks += elapsed; documentStylesheetParseTicks += layoutEngine.LastStylesheetParseTicks; } layoutDirty = false; } return displayList; } public void Paint(IDesktopCanvas canvas, DesktopBounds bounds, double scrollY) => WithGate(() => { if (document is null) { canvas.Text(CurrentPage.Text, 0xD85050, bounds.X + 8, bounds.Y + 8); return 0; } var size = new BrowserViewport(bounds.Width, bounds.Height, 0, Math.Max(0, scrollY)); if (awaitingDocumentPresentation) { documentPaintBounds = bounds; documentPaintScale = canvas.ScalePercent; documentPaintCanvas = canvas as IDesktopPresentedPixelsCanvas; documentPaintSequenceBefore = documentPaintCanvas?.FramesPresented ?? -1; } if (size != viewport) { bool resize = size.Width != viewport.Width || size.Height != viewport.Height; UpdateViewport(size); if (resize) dom?.DispatchEvent(null, "resize", new() { Bubbles = false, Cancelable = false }); } SetCanvas(canvas); try { var frameCache = canvas as IDesktopWebFrameCache; if (frameCache is not null && !Intersects(bounds, frameCache.WebPaintClip)) return 0; if (!layoutDirty && displayList is not null && frameCache is not null && frameCache.TryRestoreWebFrame(paintIdentity, Revision, LayoutPassCount, bounds, viewport.ScrollX, viewport.ScrollY)) { CachedPaintCount++; if (awaitingDocumentPresentation) documentPaintAwaitingPresentation = true; return 0; } bool tracing = currentAddress?.AbsoluteUri == PackagedBrowserResources.MonacoPageAddress && tracePaints++ < 8; if (tracing) Trace?.Invoke("XFE_BROWSER_PAINT_BEGIN\n"); var list = EnsureLayout(); if (tracing) Trace?.Invoke("XFE_BROWSER_LAYOUT_READY|" + list.Commands.Count + "\n"); long rasterStarted = awaitingDocumentPresentation ? System.Diagnostics.Stopwatch.GetTimestamp() : 0; BrowserRasterPainter.Paint(canvas, bounds, list, node => dom is not null && dom.TryCanvas(node, out var surface) ? surface : null, FindImage); if (awaitingDocumentPresentation) documentRasterTicks += System.Diagnostics.Stopwatch.GetTimestamp() - rasterStarted; RasterPaintCount++; long cacheStarted = awaitingDocumentPresentation ? System.Diagnostics.Stopwatch.GetTimestamp() : 0; frameCache?.StoreWebFrame(paintIdentity, Revision, LayoutPassCount, bounds, viewport.ScrollX, viewport.ScrollY); if (awaitingDocumentPresentation) documentFrameCacheTicks += System.Diagnostics.Stopwatch.GetTimestamp() - cacheStarted; if (awaitingDocumentPresentation) documentPaintAwaitingPresentation = true; if (tracing) Trace?.Invoke("XFE_BROWSER_PAINT_END\n"); } catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException and not DesktopRenderYieldException) { Trace?.Invoke("XFE_BROWSER_PAINT_FAILED|" + BoundedText.Safe(error.Message, 256) + "\n"); canvas.Fill(0xFFFFFF, bounds.X, bounds.Y, bounds.Width, bounds.Height); canvas.Text("Rendering failed: " + BoundedText.Safe(error.Message, 70), 0xA03030, bounds.X + 8, bounds.Y + 8); } return 0; }); private static bool Intersects(DesktopBounds first, DesktopBounds second) => first.X < second.X + second.Width && second.X < first.X + first.Width && first.Y < second.Y + second.Height && second.Y < first.Y + first.Height; private bool WithInputGate(Func action) => WithGate(() => { try { return action(); } catch (Exception error) when (error is not OutOfMemoryException and not StackOverflowException) { try { Diagnostic("Browser event failed: " + error.Message); } catch (BrowserLimitException) { } try { RefreshAfterInput(); } catch (BrowserLimitException) { } LastInputTrace += "|Error|" + BoundedText.Safe(error.Message,256); return true; } }); public bool Pointer(BrowserPointerInput input) => WithInputGate(() => { if (document is null || dom is null) return false; long revision = Revision; var hit = EnsureLayout().HitTest(input.X, input.Y)?.Node; if (hit is IText) hit = hit.ParentElement; if (input.Type is "mousedown" or "mouseup") LastInputTrace = input.Type + "|" + input.X + "|" + input.Y + "|" + hit?.NodeName + "|" + (hit as IElement)?.Id; var init = new BrowserDomEventInit { ClientX = input.X, ClientY = input.Y, Button = input.Button, Buttons = input.Buttons, DeltaY = input.DeltaY, CtrlKey = input.Control, ShiftKey = input.Shift, AltKey = input.Alt }; if (hit != hoveredNode) { if (hoveredNode is not null) { dom.DispatchEvent(hoveredNode, "mouseout", init); dom.DispatchEvent(hoveredNode, "mouseleave", init with { Bubbles = false }); } var beforeHover = new BrowserInteractionState(hoveredNode, pointerDownNode, dom.FocusedNode); hoveredNode = hit; InvalidateInteraction(beforeHover, new(hoveredNode, pointerDownNode, dom.FocusedNode)); if (hit is not null) { dom.DispatchEvent(hit, "mouseover", init); dom.DispatchEvent(hit, "mouseenter", init with { Bubbles = false }); } } bool pointerAllowed = input.Type is "mousedown" or "mouseup" or "mousemove" ? dom.DispatchEvent(hit ?? document, input.Type.Replace("mouse", "pointer", StringComparison.Ordinal), init) : true; bool allowed = pointerAllowed && dom.DispatchEvent(hit ?? document, input.Type, init); if (input.Type == "pointercancel") { pointerDownNode = null; hoveredNode = null; InvalidateDocument(); } if (input.Type == "mousedown") { var beforeActive = new BrowserInteractionState(hoveredNode, pointerDownNode, dom.FocusedNode); pointerDownNode = hit; InvalidateInteraction(beforeActive, new(hoveredNode, pointerDownNode, dom.FocusedNode)); if (allowed) dom.FocusNode(hit); } if (input.Type == "mouseup") { bool sameTarget = hit is not null && ReferenceEquals(hit, pointerDownNode); bool clickAllowed = sameTarget && dom.DispatchEvent(hit, "click", init); LastInputTrace += "|Same|" + sameTarget + "|ClickAllowed|" + clickAllowed; if (clickAllowed) { DefaultClick(hit as IElement); } var beforeRelease = new BrowserInteractionState(hoveredNode, pointerDownNode, dom.FocusedNode); pointerDownNode = null; InvalidateInteraction(beforeRelease, new(hoveredNode, pointerDownNode, dom.FocusedNode)); } CaptureEventOutput(); DrainResources(); FollowPendingNavigation(); if (Revision != revision) RefreshAfterInput(); return !allowed; }); public bool Key(BrowserKeyboardInput input) => WithInputGate(() => { if (document is null || dom is null) return false; var target = dom.FocusedNode ?? document.Body ?? (INode)document; var init = new BrowserDomEventInit { Key = input.Key, Code = input.Code, CtrlKey = input.Control, ShiftKey = input.Shift, AltKey = input.Alt, Data = input.Text }; bool allowed = dom.DispatchEvent(target, input.Type, init); if (allowed && input.Type == "keydown") { if (!input.Control && !input.Alt && input.Text.Length != 0) { if (dom.DispatchEvent(target, "keypress", init) && dom.DispatchEvent(target, "beforeinput", init with { InputType = "insertText" })) dom.ApplyTextInput(input.Text); } else if (input.Key == "Backspace" || input.Key == "Delete") { string type = input.Key == "Backspace" ? "deleteContentBackward" : "deleteContentForward"; if (dom.DispatchEvent(target, "beforeinput", init with { InputType = type })) dom.ApplyTextInput("", input.Key == "Backspace" ? -1 : 1); } else if (input.Key == "Enter" && target is IHtmlInputElement inputControl && inputControl.Type is "text" or "search" or "email" or "url" or "tel" or "password" or "number" && inputControl.Closest("form") is { } form) SubmitForm(form, form.QuerySelector("input[type=submit]:not([disabled]),button:not([type]):not([disabled]),button[type=submit]:not([disabled])")); else dom.ApplyControlKey(input.Key, input.Shift, input.Control); } CaptureEventOutput(); DrainResources(); FollowPendingNavigation(); RefreshAfterInput(); return true; }); private long nextRetainedStatePrune; public bool AdvanceTime(long monotonicMilliseconds) => WithInputGate(() => { if(monotonicMilliseconds>=nextRetainedStatePrune) {dom?.PruneRetainedState();nextRetainedStatePrune=monotonicMilliseconds+5_000;} if (documentEnhancementsPending) { long previousRevision = Revision; if (AdvanceDocumentLoading()) { if (Revision != previousRevision) RefreshAfterInput(); return Revision != previousRevision; } return false; } if (runtime is null) return false; long before = Revision; bool tracing = currentAddress?.AbsoluteUri == PackagedBrowserResources.MonacoPageAddress && tracePumps++ < 20; if (tracing) Trace?.Invoke("XFE_BROWSER_PUMP_BEGIN|"+monotonicMilliseconds+"|Tasks|"+runtime.PendingTaskCount+"\n"); var result = runtime.Pump(monotonicMilliseconds, activeLimits.MaximumEventCallbacksPerPump); if (tracing) Trace?.Invoke("XFE_BROWSER_PUMP_END|"+result.Success+"|"+result.Value+"\n"); if (!result.Success) { Diagnostic("Event loop failed: " + result.Error); InvalidateDocument(); Trace?.Invoke("XFE_BROWSER_EVENT_LOOP_ERROR|"+BoundedText.Safe(result.Error,1024)+"\n"); } if (result.ConsoleOutput.Length != 0) { Diagnostic(result.ConsoleOutput); InvalidateDocument(); } DrainResources(); FollowPendingNavigation(); if (before != Revision) RefreshAfterInput(); return before != Revision; }); private void RefreshAfterInput() { if (document is null || currentAddress is null) return; try { CurrentPage = RenderCurrent(pageDiagnostics?.ToString().TrimEnd('\n') ?? ""); } catch (BrowserLimitException error) { Diagnostic(error.Message); } } private void CaptureEventOutput() { string output = runtime?.DrainConsoleOutput() ?? ""; if (output.Length != 0) { Diagnostic(output); InvalidateDocument(); } } private sealed class CanvasMetrics(IDesktopWebCanvas canvas) : IBrowserTextMetrics { public double Measure(string text, BrowserFont font) => canvas.MeasureWebText(text, font.Size, font.Family, font.Weight); } } public static partial class BrowserRasterPainter { public static void Paint(IDesktopCanvas canvas, DesktopBounds viewport, BrowserDisplayList list, Func? surfaces = null, Func? images = null) => PaintCore(canvas, viewport, list.Viewport, list.Commands, index => list.Commands[index].Kind == BrowserDrawKind.Image ? images?.Invoke(list.Commands[index].ImageSource) : list.Commands[index].Node is { } node ? surfaces?.Invoke(node) : null, null, false); private static void PaintCanvas(IDesktopCanvas canvas, BrowserRect bounds, BrowserRect visible, BrowserCanvasSurface surface, double opacity) { if (surface.Width <= 0 || surface.Height <= 0 || bounds.Width <= 0 || bounds.Height <= 0) return; var pixels = surface.Pixels.Span; int left = (int)Math.Floor(visible.Left), right = (int)Math.Ceiling(visible.Right), top = (int)Math.Floor(visible.Top), bottom = (int)Math.Ceiling(visible.Bottom); for (int row = top; row < bottom; row++) { int sourceY = Math.Clamp((int)((row - bounds.Y) * surface.Height / bounds.Height), 0, surface.Height - 1); for (int x = left; x < right;) { int sourceX = Math.Clamp((int)((x - bounds.X) * surface.Width / bounds.Width), 0, surface.Width - 1); uint color = pixels[sourceY * surface.Width + sourceX]; int end = x + 1; while (end < right && pixels[sourceY * surface.Width + Math.Clamp((int)((end - bounds.X) * surface.Width / bounds.Width), 0, surface.Width - 1)] == color) end++; int alpha = (int)((color >> 24) * opacity); if (alpha >= 255) canvas.Fill(color & 0xFFFFFF, x, row, end - x, 1); else if (alpha > 0 && canvas is IDesktopCoverageCanvas coverage) { int nx = (int)Math.Round(x * canvas.ScalePercent / 100d), ny = (int)Math.Round(row * canvas.ScalePercent / 100d); int nw = Math.Max(1,(int)Math.Round(end * canvas.ScalePercent / 100d)-nx), nh = Math.Max(1,(int)Math.Round((row+1) * canvas.ScalePercent / 100d)-ny); var mask = new byte[nw]; Array.Fill(mask,(byte)alpha); for (int sy = 0; sy < nh; sy++) coverage.BlendCoverage(color & 0xFFFFFF, nx, ny + sy, nw,1,mask); } x = end; } } } private static DesktopBounds ToBounds(BrowserRect r) => new((int)Math.Floor(r.X), (int)Math.Floor(r.Y), Math.Max(0, (int)Math.Ceiling(r.Right) - (int)Math.Floor(r.X)), Math.Max(0, (int)Math.Ceiling(r.Bottom) - (int)Math.Floor(r.Y))); }