using System.Diagnostics; using XFESystem.Desktop; namespace XFESystem.Platform; /// CPU renderer with platform-owned pixels, font caches and pointer-free scene snapshots. public sealed class CpuFramebufferCanvas : IBrandedDesktopCanvas, IDesktopIconCanvas, IDesktopCoverageCanvas, IDesktopSceneMotionCanvas, IDesktopWebCanvas, IDesktopWebFrameCache, IDesktopPresentedPixelsCanvas, IDesktopRenderYieldCanvas, IEditorPreparationCanvasFactory, IEditorPreparationCanvasHandoff, IDesktopWebTextPreparationCanvas { private readonly IFramebufferPresenter presenter; private readonly int[] pixels, scene, presented; private PlatformFonts fonts; private bool preparationOnly, preparationTransferred; private readonly DesktopSceneMotionBuffer motion; private readonly DesktopWebFrameCache webFrames = new(); private readonly Stack clips = []; private readonly Func timestamp; private readonly Func? yieldRequested; private DesktopBounds clip; private readonly Dictionary<(string Text, double Size, string Family, int Weight), PlatformTextMask> textMasks = []; private long maskBytes; public int NativeWidth => presenter.Width; public int NativeHeight => presenter.Height; public int Width => (int)((long)NativeWidth * 100 / ScalePercent); public int Height => (int)((long)NativeHeight * 100 / ScalePercent); public int ScalePercent { get; } public bool SupportsSceneCache => true; public bool RenderYieldRequested => yieldRequested?.Invoke() ?? false; public long FramesPresented { get; private set; } public long LastPresentTicks { get; private set; } public ReadOnlyMemory Pixels => pixels; public PlatformFonts Fonts => fonts; public int CachedTextMaskCount => textMasks.Count; public long CachedTextMaskBytes => maskBytes; public CpuFramebufferCanvas(IFramebufferPresenter presenter, PlatformFonts? fonts = null, int scalePercent = 100, Func? timestamp = null, Func? yieldRequested = null) { ArgumentNullException.ThrowIfNull(presenter); PixelGeometry.ValidateSize(presenter.Width, presenter.Height); if (scalePercent is < 100 or > 400 || presenter.Width * 100L / scalePercent < 1 || presenter.Height * 100L / scalePercent < 1) throw new ArgumentOutOfRangeException(nameof(scalePercent)); this.presenter = presenter; this.fonts = fonts ?? PlatformFonts.CreateBundled(); ScalePercent = scalePercent; this.timestamp = timestamp ?? Stopwatch.GetTimestamp; this.yieldRequested = yieldRequested; pixels = new int[checked(NativeWidth * NativeHeight)]; scene = new int[pixels.Length]; presented = new int[pixels.Length]; motion = new(pixels, scene, NativeWidth, NativeHeight); clip = new(0, 0, NativeWidth, NativeHeight); } public int TextWidth(string text) => (int)Math.Ceiling(fonts.Measure(text, 13, "monospace", 400)); public void Text(string text, uint rgb, int x, int y) { ArgumentNullException.ThrowIfNull(text); foreach (string line in text.Replace("\r", "", StringComparison.Ordinal).Split('\n')) { WebText(line, rgb, x, y, 13, "monospace", 400); y += (int)Math.Ceiling(MeasureWebLineHeight(13, "monospace", 400)); } } public void Fill(uint rgb, int x, int y, int width, int height) { var rect = PixelGeometry.Intersect(NativeRect(new(x, y, width, height)), clip); for (int row = rect.Y; row < rect.Y + rect.Height; row++) { motion.RowSegments(rect.X, rect.Width, row, out int leftEnd, out int rightStart); pixels.AsSpan(row * NativeWidth + rect.X, leftEnd - rect.X).Fill((int)(rgb & 0xFFFFFF)); pixels.AsSpan(row * NativeWidth + rightStart, rect.X + rect.Width - rightStart).Fill((int)(rgb & 0xFFFFFF)); } } public void Line(uint rgb, int x1, int y1, int x2, int y2) { double ax = x1 * (ScalePercent / 100d), ay = y1 * (ScalePercent / 100d); double bx = x2 * (ScalePercent / 100d), by = y2 * (ScalePercent / 100d); if (!ClipLine(ref ax, ref ay, ref bx, ref by)) return; int x = (int)Math.Round(ax), y = (int)Math.Round(ay), endX = (int)Math.Round(bx), endY = (int)Math.Round(by); int dx = Math.Abs(endX - x), sx = x < endX ? 1 : -1, dy = -Math.Abs(endY - y), sy = y < endY ? 1 : -1, error = dx + dy; while (true) { SetPixel(rgb, x, y, 255); if (x == endX && y == endY) break; int twice = error * 2; if (twice >= dy) { error += dy; x += sx; } if (twice <= dx) { error += dx; y += sy; } } } public void BlendCoverage(uint rgb, int nativeX, int nativeY, int width, int height, ReadOnlySpan coverage) { if (width <= 0 || height <= 0 || (long)width * height != coverage.Length) throw new ArgumentException("Coverage must contain exactly width * height bytes.", nameof(coverage)); var rect = PixelGeometry.Intersect(new(nativeX, nativeY, width, height), clip); for (int y = rect.Y; y < rect.Y + rect.Height; y++) for (int x = rect.X; x < rect.X + rect.Width; x++) SetPixel(rgb, x, y, coverage[checked((int)(((long)y - nativeY) * width + x - nativeX))]); } public void BeginScene(DesktopBounds damage) { motion.End(); clips.Clear(); clip = NativeRect(damage); } public void PushClip(DesktopBounds bounds) { if (clips.Count >= 128) throw new InvalidOperationException("Clip nesting budget exceeded."); clips.Push(clip); clip = PixelGeometry.Intersect(clip, NativeRect(bounds)); } public void PopClip() { if (!clips.TryPop(out clip)) throw new InvalidOperationException("Unbalanced web clipping."); } public void SaveScene() => SaveScene(new(0, 0, Width, Height)); public void SaveScene(DesktopBounds damage) => Copy(pixels, scene, NativeRect(damage)); public void RestoreSceneRegion(DesktopBounds bounds) => Copy(scene, pixels, NativeRect(bounds)); public void PreserveTranslatedSceneRegion(DesktopBounds source, int destinationX, int destinationY) => PreserveTranslatedSceneRegion(source, destinationX, destinationY, false); public void PreserveTranslatedSceneRegion(DesktopBounds source, int destinationX, int destinationY, bool unchangedInScanout) { // Clip source and destination together in the shared, tested motion buffer. int sx = ScaledCoordinate(source.X), sy = ScaledCoordinate(source.Y); int sw = ScaledLength(source.Width), sh = ScaledLength(source.Height); motion.Preserve(new(sx, sy, sw, sh), ScaledCoordinate(destinationX), ScaledCoordinate(destinationY), unchangedInScanout); } public void EndScenePreservation() => motion.End(); public void AbortScenePreservation() => motion.End(); public void Present() => Present(new(0, 0, Width, Height)); public void Present(DesktopBounds damage) { var native = NativeRect(damage); presenter.Present(pixels, native); Copy(pixels, presented, native); LastPresentTicks = timestamp(); FramesPresented++; } public bool TryFingerprintPresentedPixels(DesktopBounds nativeBounds, out DesktopPresentedPixelsFingerprint result) => DesktopPresentedPixels.TryFingerprint(presented, NativeWidth, NativeHeight, NativeWidth, nativeBounds, FramesPresented, out result); public double MeasureWebText(string text, double fontSize, string fontFamily, int fontWeight) => fonts.Measure(text, fontSize, fontFamily, fontWeight); public double MeasureWebLineHeight(double fontSize, string fontFamily, int fontWeight) => fonts.LineHeight(fontSize, fontFamily, fontWeight); public (int Width, int Height, byte[] Alpha) RasterizeWebText(string text, double fontSize, string fontFamily, int fontWeight) { var mask = Mask(text, fontSize, fontFamily, fontWeight); return (mask.Width, mask.Height, (byte[])mask.Alpha.Clone()); } public IDesktopPreparedWebText PrepareWebText(string text, double fontSize, string fontFamily, int fontWeight, double opacity) { if (!double.IsFinite(opacity) || opacity < 0 || opacity > 1) throw new ArgumentOutOfRangeException(nameof(opacity)); var mask = Mask(text, fontSize * ScalePercent / 100, fontFamily, fontWeight); // Neither the cache nor this mask is mutated after construction. The // worker retains its fonts; publication transfers only opaque coverage. byte[] alpha = mask.Alpha; if (opacity != 1) { alpha = new byte[mask.Alpha.Length]; for (int index = 0; index < alpha.Length; index++) alpha[index] = (byte)Math.Round(mask.Alpha[index] * opacity); } return new PreparedText(ScalePercent, fonts.LineHeight(fontSize, fontFamily, fontWeight), mask.Width, mask.Height, alpha); } private sealed class PreparedText(int scalePercent, double lineHeight, int width, int height, byte[] coverage) : IDesktopPreparedWebText { public int ScalePercent => scalePercent; public double LineHeight => lineHeight; public void Paint(IDesktopCoverageCanvas target, uint rgb, double x, double y) { if (target.ScalePercent != scalePercent) throw new ArgumentException("Prepared text scale differs from the destination.", nameof(target)); double nativeX = x * scalePercent / 100, nativeY = y * scalePercent / 100; if (!double.IsFinite(nativeX) || !double.IsFinite(nativeY) || nativeX < int.MinValue || nativeX > int.MaxValue || nativeY < int.MinValue || nativeY > int.MaxValue) return; target.BlendCoverage(rgb, (int)Math.Round(nativeX), (int)Math.Round(nativeY), width, height, coverage); } } public void WebText(string text, uint color, double x, double y, double fontSize, string fontFamily, int fontWeight, double opacity = 1) { if (!double.IsFinite(x) || !double.IsFinite(y) || !double.IsFinite(opacity) || opacity < 0 || opacity > 1) throw new ArgumentOutOfRangeException(nameof(opacity)); if (text.Length == 0 || opacity == 0) return; double nx = x * ScalePercent / 100, ny = y * ScalePercent / 100; if (nx < int.MinValue || nx > int.MaxValue || ny < int.MinValue || ny > int.MaxValue) return; var mask = Mask(text, fontSize * ScalePercent / 100, fontFamily, fontWeight); int px = (int)Math.Round(nx), py = (int)Math.Round(ny); var rect = PixelGeometry.Intersect(new(px, py, mask.Width, mask.Height), clip); for (int row = rect.Y; row < rect.Y + rect.Height; row++) for (int col = rect.X; col < rect.X + rect.Width; col++) SetPixel(color, col, row, (byte)Math.Round(mask.Alpha[(row - py) * mask.Width + col - px] * opacity)); } public DesktopBounds WebPaintClip => new(clip.X * 100 / ScalePercent, clip.Y * 100 / ScalePercent, (int)Math.Ceiling((clip.X + clip.Width) * 100d / ScalePercent) - clip.X * 100 / ScalePercent, (int)Math.Ceiling((clip.Y + clip.Height) * 100d / ScalePercent) - clip.Y * 100 / ScalePercent); public bool TryRestoreWebFrame(long pageId, long revision, long layoutVersion, DesktopBounds bounds, double scrollX, double scrollY) { if (!RawNativeRect(bounds, out var native)) return false; return webFrames.TryRestore(new(pageId, revision, layoutVersion, native.Width, native.Height, ScalePercent, scrollX, scrollY), pixels, NativeWidth, NativeHeight, native.X, native.Y, clip, motion.PreservedBounds); } public void StoreWebFrame(long pageId, long revision, long layoutVersion, DesktopBounds bounds, double scrollX, double scrollY) { if (!RawNativeRect(bounds, out var native)) return; webFrames.Store(new(pageId, revision, layoutVersion, native.Width, native.Height, ScalePercent, scrollX, scrollY), pixels, NativeWidth, NativeHeight, native.X, native.Y, clip, motion.PreservedBounds); } public void BrandIcon(int x, int y, int size) => Image(Images.Brand, 0, x, y, size); public void DrawIcon(DesktopIcon icon, int x, int y, int size) { if (icon == DesktopIcon.Settings) { int middleX = x + size / 2, middleY = y + size / 2; for (int i = 0; i < 16; i++) { double angle = Math.PI * i / 8, next = Math.PI * (i + 1) / 8; int radius = i % 2 == 0 ? size * 2 / 5 : size / 3; int nextRadius = i % 2 == 0 ? size / 3 : size * 2 / 5; Line(0x91BFFF, middleX + (int)(Math.Cos(angle) * radius), middleY + (int)(Math.Sin(angle) * radius), middleX + (int)(Math.Cos(next) * nextRadius), middleY + (int)(Math.Sin(next) * nextRadius)); } for (int i = 0; i < 24; i++) { double angle = Math.PI * i / 12, next = Math.PI * (i + 1) / 12; Line(0x91BFFF, middleX + (int)(Math.Cos(angle) * size / 8), middleY + (int)(Math.Sin(angle) * size / 8), middleX + (int)(Math.Cos(next) * size / 8), middleY + (int)(Math.Sin(next) * size / 8)); } return; } if ((int)icon is < 0 or >= 9) throw new ArgumentOutOfRangeException(nameof(icon)); Image(Images.Atlas, (int)icon * 64 * 64 * 4, x, y, size); } public IDesktopCanvas CreateEditorPreparationCanvas() { // The worker needs real fonts/metrics; it does not need another full-screen scanout/scene allocation. int edge = (ScalePercent + 99) / 100; return new CpuFramebufferCanvas(new ArrayFramebufferPresenter(edge, edge), fonts.CreateIndependent(), ScalePercent, timestamp) { preparationOnly = true }; } public void PrepareEditorText(string text, double fontSize, string fontFamily, int fontWeight) { if (!preparationOnly || preparationTransferred) throw new InvalidOperationException("Font preparation requires an exclusively owned preparation canvas."); _ = Mask(text, fontSize * ScalePercent / 100, fontFamily, fontWeight); } public bool TryCompleteEditorPreparationHandoff(IDesktopCanvas target) { if (!preparationOnly || preparationTransferred || target is not CpuFramebufferCanvas destination || destination.preparationOnly || ScalePercent != destination.ScalePercent || !fonts.HasSameAssets(destination.fonts)) return false; // Publication/ownership transfer is supplied by the existing editor service before this UI-only call. destination.fonts = fonts; foreach (var entry in textMasks) { if (destination.textMasks.ContainsKey(entry.Key)) continue; if (destination.textMasks.Count == 1024 || destination.maskBytes + entry.Value.Alpha.Length > 2 * 1024 * 1024) break; destination.textMasks.Add(entry.Key, entry.Value); destination.maskBytes += entry.Value.Alpha.Length; } textMasks.Clear(); maskBytes = 0; preparationTransferred = true; // The transferred font cache is no longer usable by this preparation canvas. fonts = fonts.CreateIndependent(); return true; } private static class Images { internal static readonly byte[] Brand = PlatformResources.Read("PlatformImages/Brand.rgba"); internal static readonly byte[] Atlas = PlatformResources.Read("PlatformImages/Icons.rgba"); } private void Image(byte[] rgba, int offset, int x, int y, int size) { if (size <= 0) return; if (offset < 0 || offset + 64 * 64 * 4 > rgba.Length) throw new InvalidDataException("The icon asset is incomplete."); int px = ScaledCoordinate(x), py = ScaledCoordinate(y), edge = ScaledLength(size); if (edge <= 0) return; var rect = PixelGeometry.Intersect(new(px, py, edge, edge), clip); for (int row = rect.Y; row < rect.Y + rect.Height; row++) for (int col = rect.X; col < rect.X + rect.Width; col++) { int at = offset + ((int)((long)(row - py) * 64 / edge) * 64 + (int)((long)(col - px) * 64 / edge)) * 4; SetPixel((uint)(rgba[at] << 16 | rgba[at + 1] << 8 | rgba[at + 2]), col, row, rgba[at + 3]); } } private PlatformTextMask Mask(string text, double size, string family, int weight) { var key = (text, size, family, weight); if (textMasks.TryGetValue(key, out var cached)) return cached; var mask = fonts.Rasterize(text, size, family, weight); if (text.Length <= 4096 && textMasks.Count < 1024 && maskBytes + mask.Alpha.Length <= 2 * 1024 * 1024) { textMasks.Add(key, mask); maskBytes += mask.Alpha.Length; } return mask; } private void SetPixel(uint rgb, int x, int y, byte alpha) { if (alpha == 0 || !clip.Contains(x, y) || motion.PreservedBounds.Contains(x, y)) return; int at = y * NativeWidth + x; if (alpha == 255) { pixels[at] = (int)(rgb & 0xFFFFFF); return; } uint before = (uint)pixels[at]; int inverse = 255 - alpha; uint r = (uint)(((rgb >> 16 & 255) * alpha + (before >> 16 & 255) * inverse + 127) / 255); uint g = (uint)(((rgb >> 8 & 255) * alpha + (before >> 8 & 255) * inverse + 127) / 255); uint b = (uint)(((rgb & 255) * alpha + (before & 255) * inverse + 127) / 255); pixels[at] = (int)(r << 16 | g << 8 | b); } private void Copy(int[] source, int[] target, DesktopBounds rect) { for (int row = rect.Y; row < rect.Y + rect.Height; row++) source.AsSpan(row * NativeWidth + rect.X, rect.Width).CopyTo(target.AsSpan(row * NativeWidth + rect.X, rect.Width)); } private int ScaledCoordinate(int value) => (int)Math.Clamp((long)value * ScalePercent / 100, int.MinValue, int.MaxValue); private int ScaledLength(int value) => (int)Math.Clamp((long)Math.Max(0, value) * ScalePercent / 100, 0, int.MaxValue); private DesktopBounds NativeRect(DesktopBounds bounds) { if (bounds.Width <= 0 || bounds.Height <= 0) return default; long left = (long)bounds.X * ScalePercent / 100, top = (long)bounds.Y * ScalePercent / 100; long right = ((long)bounds.X + Math.Max(0, bounds.Width)) * ScalePercent / 100; long bottom = ((long)bounds.Y + Math.Max(0, bounds.Height)) * ScalePercent / 100; int x = (int)Math.Clamp(left, 0, NativeWidth), y = (int)Math.Clamp(top, 0, NativeHeight); if ((long)bounds.X + Math.Max(0, bounds.Width) >= Width) right = NativeWidth; if ((long)bounds.Y + Math.Max(0, bounds.Height) >= Height) bottom = NativeHeight; int r = (int)Math.Clamp(right, 0, NativeWidth), b = (int)Math.Clamp(bottom, 0, NativeHeight); return new(x, y, Math.Max(0, r - x), Math.Max(0, b - y)); } private bool RawNativeRect(DesktopBounds bounds, out DesktopBounds native) { long x = (long)bounds.X * ScalePercent / 100, y = (long)bounds.Y * ScalePercent / 100; long w = (long)bounds.Width * ScalePercent / 100, h = (long)bounds.Height * ScalePercent / 100; native = default; if (x < int.MinValue || x > int.MaxValue || y < int.MinValue || y > int.MaxValue || w < 1 || w > int.MaxValue || h < 1 || h > int.MaxValue) return false; native = new((int)x, (int)y, (int)w, (int)h); return true; } private bool ClipLine(ref double ax, ref double ay, ref double bx, ref double by) { if (clip.Width == 0 || clip.Height == 0) return false; double dx = bx - ax, dy = by - ay, begin = 0, end = 1; bool Edge(double p, double q) { if (p == 0) return q >= 0; double ratio = q / p; if (p < 0) { if (ratio > end) return false; begin = Math.Max(begin, ratio); } else { if (ratio < begin) return false; end = Math.Min(end, ratio); } return true; } if (!Edge(-dx, ax - clip.X) || !Edge(dx, clip.X + clip.Width - 1 - ax) || !Edge(-dy, ay - clip.Y) || !Edge(dy, clip.Y + clip.Height - 1 - ay)) return false; bx = ax + end * dx; by = ay + end * dy; ax += begin * dx; ay += begin * dy; return true; } }