///|
pub(all) suberror SkiaRendererError {
SkiaUnavailable
SurfaceUnavailable
CanvasUnavailable
ReadPixelsUnavailable
GpuContextUnavailable(String)
PresentFailed(String)
} derive(Eq, Debug, ToJson)
///|
pub fn SkiaRendererError::message(self : SkiaRendererError) -> String {
match self {
SkiaUnavailable => "moui_skia/native is unavailable"
SurfaceUnavailable => "failed to create Skia surface"
CanvasUnavailable => "failed to acquire Skia canvas"
ReadPixelsUnavailable => "failed to read Skia surface pixels"
GpuContextUnavailable(description) =>
"failed to create Skia GPU context: \{description}"
PresentFailed(description) => "Skia presenter failed: \{description}"
}
}
///|
pub struct SkiaUnsupportedCommandDiagnostic {
priv command : String
priv reason : String
} derive(Eq, Debug, ToJson)
///|
pub(all) enum SkiaFontResolution {
SystemFontMgr
EmptyTypeface
} derive(Eq, Debug, ToJson)
///|
pub fn SkiaUnsupportedCommandDiagnostic::command(
self : SkiaUnsupportedCommandDiagnostic,
) -> String {
self.command
}
///|
pub fn SkiaUnsupportedCommandDiagnostic::reason(
self : SkiaUnsupportedCommandDiagnostic,
) -> String {
self.reason
}
///|
pub struct SkiaRasterRenderer {
priv mut metrics : @core.SurfaceMetrics
priv target : SkiaPresentTarget
priv gpu_target : HostGpuPresentTarget?
priv font_resolution : SkiaFontResolution
priv surface_route : SkiaSurfaceRoute
priv gpu_context : @skia_native.GpuContext?
priv mut surface : @skia_native.Surface
priv mut gpu_surface_attached : Bool
priv image_cache : Map[String, @skia_native.Image]
priv failed_images : Map[String, String]
priv image_lifecycle : @render_common.ImageResourceLifecycle
priv async_image_loading : Bool
priv mut present_count : Int
priv mut unsupported_command_count : Int
priv unsupported_command_diagnostics : Array[SkiaUnsupportedCommandDiagnostic]
priv mut last_frame : SkiaPixelFrame?
priv layer_cache : Map[String, SkiaCachedLayerImageEntry]
priv layer_cache_identity_index : Map[String, String]
priv picture_layer_cache : Map[String, SkiaCachedLayerPictureEntry]
priv picture_layer_cache_identity_index : Map[String, String]
priv layer_cache_admissions : Map[String, SkiaCachedLayerAdmissionRecord]
priv mut layer_cache_total_bytes : Int64
priv mut picture_layer_cache_total_bytes : Int64
priv mut layer_cache_tick : Int
priv mut platform_view_draw_fn : (() -> Unit)?
priv mut platform_view_recording_canvas : @skia_native.Canvas?
}
///|
pub fn skia_available() -> Bool {
@skia_native.skia_available()
}
///|
pub fn backend_info() -> @render.RendererBackendInfo {
let available = skia_available()
let surface_preflight = raster_surface_preflight(
@core.SurfaceMetrics::from_logical(
size=@core.Size::new(width=1.0, height=1.0),
),
)
let text_preflight = skia_text_descriptor_preflight(
@core.FontSpec::new(size=12.0, weight=400),
"MoUI",
)
let text_maturity = skia_text_maturity_preflight()
@render.RendererBackendInfo::new(
backend=@render.RendererBackendKind::SkiaRasterNative,
status=if available {
@render.RendererCapabilityStatus::Supported
} else {
@render.RendererCapabilityStatus::Unavailable
},
can_render=available,
can_resize=available,
owns_surface=true,
description="Native Skia raster renderer using moui_skia/native CPU surfaces, " +
surface_preflight.summary() +
", " +
skia_gpu_metal_preflight_summary() +
", " +
text_preflight.summary() +
", " +
text_maturity.summary() +
", flush-and-submit finalization, and platform pixel presenters.",
)
}
///|
pub fn renderer_descriptor() -> @render.RendererDescriptor {
skia_raster_native_descriptor()
}
///|
pub fn skia_raster_native_descriptor() -> @render.RendererDescriptor {
@render.RendererDescriptor::new(
backend=@render.RendererBackendKind::SkiaRasterNative,
family=@render.RendererFamily::Skia,
presentation=@render.RendererPresentationModel::CpuPixelFrame,
supported_targets=[@render.RendererTarget::Native],
capabilities=skia_provider_capabilities(
provider_id="skia-raster",
backend=@render.RendererBackendKind::SkiaRasterNative,
),
description="Native Skia raster renderer that presents copied RGBA premultiplied pixel frames to the host.",
)
}
///|
pub fn skia_gpu_native_descriptor() -> @render.RendererDescriptor {
@render.RendererDescriptor::new(
backend=@render.RendererBackendKind::SkiaGpuNative,
family=@render.RendererFamily::Skia,
presentation=@render.RendererPresentationModel::HostGpuSurface,
supported_targets=[@render.RendererTarget::Native],
capabilities=skia_provider_capabilities(
provider_id="skia-hybrid",
backend=@render.RendererBackendKind::SkiaGpuNative,
),
description="Native Skia GPU renderer identity for direct host GPU surfaces. Providers must prove a matching Metal, Vulkan, EGL/GLES, or Direct3D surface before selecting it; resolve_surface_route picks the platform-appropriate SurfaceRoute (metal/vulkan/egl/direct3d) and the matching HostGpuPresentTarget wiring. Auto prefers SkiaGpuNative when the host exposes a usable GPU surface; SkiaRasterNative remains the explicit mode and sticky recovery fallback.",
)
}
///|
pub fn create_with_present_target(
metrics : @core.SurfaceMetrics,
target : SkiaPresentTarget,
font_resolution? : SkiaFontResolution = SkiaFontResolution::SystemFontMgr,
async_image_loading? : Bool = false,
) -> SkiaRasterRenderer raise SkiaRendererError {
create_with_present_target_and_route(
metrics,
target,
surface_route=RasterSurfaceRoute,
font_resolution~,
async_image_loading~,
)
}
///|
pub fn create_with_present_target_and_route(
metrics : @core.SurfaceMetrics,
target : SkiaPresentTarget,
surface_route? : SkiaSurfaceRoute = SkiaSurfaceRoute::RasterSurfaceRoute,
gpu_target? : HostGpuPresentTarget? = None,
font_resolution? : SkiaFontResolution = SkiaFontResolution::SystemFontMgr,
async_image_loading? : Bool = false,
) -> SkiaRasterRenderer raise SkiaRendererError {
if !skia_available() {
raise SkiaRendererError::SkiaUnavailable
}
// The direct GPU present path requires a GPU surface route; pairing a
// HostGpuPresentTarget with a RasterSurfaceRoute would silently present
// a raster surface through a GPU presenter, which is a configuration error.
if gpu_target is Some(_) && !surface_route.is_gpu() {
raise SkiaRendererError::GpuContextUnavailable(
"gpu-target-requires-gpu-surface-route",
)
}
let gpu_context = create_gpu_context(surface_route)
// Window systems such as AppKit cannot supply a drawable until control has
// returned to the event loop. Keep an offscreen GPU surface only as a typed
// placeholder; the first render replaces it before recording any commands.
let surface = create_surface(metrics, surface_route, gpu_context)
{
metrics,
target,
gpu_target,
font_resolution,
surface_route,
gpu_context,
surface,
gpu_surface_attached: gpu_target is None,
image_cache: Map([]),
failed_images: Map([]),
image_lifecycle: @render_common.ImageResourceLifecycle::new(),
async_image_loading,
present_count: 0,
unsupported_command_count: 0,
unsupported_command_diagnostics: [],
last_frame: None,
layer_cache: Map([]),
layer_cache_identity_index: Map([]),
picture_layer_cache: Map([]),
picture_layer_cache_identity_index: Map([]),
layer_cache_admissions: Map([]),
layer_cache_total_bytes: 0L,
picture_layer_cache_total_bytes: 0L,
layer_cache_tick: 0,
platform_view_draw_fn: None,
platform_view_recording_canvas: None,
}
}
///|
pub fn SkiaRasterRenderer::render(
self : SkiaRasterRenderer,
commands : Array[@core.DrawCommand],
) -> Unit raise SkiaRendererError {
self.ensure_gpu_window_surface()
match self.surface.canvas() {
Some(canvas) => {
canvas.clear(skia_color(@render.first_clear_color(commands)))
let saved = canvas.save()
canvas.scale(
@moui_skia.Size::new(
Float::from_double(self.metrics.scale_factor),
Float::from_double(self.metrics.scale_factor),
),
)
self.unsupported_command_count = 0
self.unsupported_command_diagnostics.clear()
let canvas_scopes : Array[@render_common.PlanScopeKind] = []
let plan = @render_common.lower_draw_commands(commands)
for op in plan.ops {
self.render_command(canvas, op, canvas_scopes)
}
if !canvas_scopes.is_empty() {
for scope in canvas_scopes {
self.record_unsupported_command(
@render_common.plan_scope_push_command(scope),
"command stream ended with an open \{@render_common.plan_scope_name(scope)} scope",
)
}
}
canvas.restore_to_count(saved)
if !self.surface.flush_and_submit() {
raise SkiaRendererError::SurfaceUnavailable
}
// Dispatch on the present target kind. When a HostGpuPresentTarget is
// bound, the platform owns the drawable and Skia has already flushed;
// skip `read_frame` (no CPU readback) and clear `last_frame`. The
// presenter may return a fresh `Surface` bound to the next drawable
// (Metal `CAMetalDrawable` pattern); swap it in for the next frame.
match self.gpu_target {
Some(gpu_target) => {
let outcome = (gpu_target.present_gpu)(self.gpu_context, self.surface)
if outcome.succeeded() {
self.present_count = self.present_count + 1
self.last_frame = None
match outcome.next_surface() {
Some(next_surface) => self.surface = next_surface
None => ()
}
} else {
raise SkiaRendererError::PresentFailed(gpu_target.description)
}
}
None => {
let frame = self.read_frame()
self.last_frame = Some(frame)
if (self.target.present)(frame) {
self.present_count = self.present_count + 1
} else {
raise SkiaRendererError::PresentFailed(self.target.description)
}
}
}
}
None => raise SkiaRendererError::CanvasUnavailable
}
}
///|
pub fn SkiaRasterRenderer::render_frame(
self : SkiaRasterRenderer,
frame : @core.DrawFrame,
) -> @render.RenderFrameResult {
self.render_frame_inner(frame) catch {
err => {
self.record_unsupported_command("RenderFrame", err.message())
@render.RenderFrameResult::new(presented=false)
}
}
}
///|
fn SkiaRasterRenderer::resolve_effective_damage(
self : SkiaRasterRenderer,
frame : @core.DrawFrame,
) -> @core.DamageRegion {
// Host GPU surfaces (EGL/Metal/Vulkan/D3D window drawables) swap into a fresh
// back buffer that does not retain prior frame pixels. Partial damage would
// clear only the dirty rects and leave the rest white/undefined after a list
// click or other local invalidation (seen on HarmonyOS egl-gpu as "everything
// outside the click area goes white until a later full redraw").
if self.gpu_target is Some(_) {
return @core.DamageRegion::full(
"host-gpu drawable has no preserved backbuffer",
)
}
match frame.damage {
@core.DamageRegion::Empty =>
@core.DamageRegion::full("empty damage fallback")
@core.DamageRegion::FullSurface(reason) =>
@core.DamageRegion::FullSurface(reason)
@core.DamageRegion::Rects(rects) => {
let mut has_scope = false
for command in frame.commands {
match command {
@core.DrawCommand::PushOpacity(_)
| @core.DrawCommand::PushLayer(_)
| @core.DrawCommand::PushFilter(_) => {
has_scope = true
break
}
_ => ()
}
}
if has_scope {
@core.DamageRegion::full("scope commands present with damage rects")
} else {
match skia_damage_rect_union(rects) {
Some(rect) => @core.DamageRegion::Rects([rect])
None => @core.DamageRegion::full("empty damage rects fallback")
}
}
}
}
}
///|
fn skia_damage_rect_union(rects : Array[@core.Rect]) -> @core.Rect? {
match rects {
[] => None
[first, .. rest] => {
let mut bounds = first
for rect in rest {
bounds = bounds.union(rect)
}
Some(bounds)
}
}
}
///|
fn SkiaRasterRenderer::apply_damage_clip(
self : SkiaRasterRenderer,
canvas : @skia_native.Canvas,
damage : @core.DamageRegion,
) -> Unit {
let _ = self
match damage {
@core.DamageRegion::Rects([rect, ..]) => canvas.clip_rect(skia_rect(rect))
@core.DamageRegion::Empty | @core.DamageRegion::FullSurface(_) => ()
@core.DamageRegion::Rects([]) => ()
}
}
///|
fn SkiaRasterRenderer::apply_damage_clear(
self : SkiaRasterRenderer,
canvas : @skia_native.Canvas,
damage : @core.DamageRegion,
clear_color : @core.Color,
) -> Unit {
let _ = self
match damage {
@core.DamageRegion::FullSurface(_) => canvas.clear(skia_color(clear_color))
@core.DamageRegion::Rects(rects) =>
// Per-rect clip + clear ensures each dirty region is independently cleared.
for rect in rects {
let saved = canvas.save()
canvas.clip_rect(skia_rect(rect))
canvas.clear(skia_color(clear_color))
canvas.restore_to_count(saved)
}
@core.DamageRegion::Empty => ()
}
}
///|
fn SkiaRasterRenderer::render_frame_inner(
self : SkiaRasterRenderer,
frame : @core.DrawFrame,
) -> @render.RenderFrameResult raise SkiaRendererError {
self.ensure_gpu_window_surface()
match self.surface.canvas() {
Some(canvas) => {
let (stats, complete) = self.draw_frame_to_canvas(
canvas,
frame,
draw_platform_views=true,
)
if !complete {
return stats.to_render_frame_result(presented=false)
}
if !self.surface.flush_and_submit() {
raise SkiaRendererError::SurfaceUnavailable
}
// Dispatch on the present target kind. When a HostGpuPresentTarget is
// bound, the platform owns the drawable and Skia has already flushed;
// skip `read_frame` (no CPU readback) and clear `last_frame`. The
// presenter may return a fresh `Surface` bound to the next drawable
// (Metal `CAMetalDrawable` pattern); swap it in for the next frame.
match self.gpu_target {
Some(gpu_target) => {
let outcome = (gpu_target.present_gpu)(self.gpu_context, self.surface)
if outcome.succeeded() {
self.present_count = self.present_count + 1
self.last_frame = None
match outcome.next_surface() {
Some(next_surface) => self.surface = next_surface
None => ()
}
stats.to_render_frame_result(presented=true)
} else {
raise SkiaRendererError::PresentFailed(gpu_target.description)
}
}
None => {
let skia_frame = self.read_frame()
self.last_frame = Some(skia_frame)
if (self.target.present)(skia_frame) {
self.present_count = self.present_count + 1
stats.to_render_frame_result(presented=true)
} else {
raise SkiaRendererError::PresentFailed(self.target.description)
}
}
}
}
None => raise SkiaRendererError::CanvasUnavailable
}
}
///|
fn SkiaRasterRenderer::draw_frame_to_canvas(
self : SkiaRasterRenderer,
canvas : @skia_native.Canvas,
frame : @core.DrawFrame,
draw_platform_views~ : Bool,
) -> (SkiaCachedLayerFrameStats, Bool) {
let effective_damage = self.resolve_effective_damage(frame)
let saved = canvas.save()
canvas.scale(
@moui_skia.Size::new(
Float::from_double(self.metrics.scale_factor),
Float::from_double(self.metrics.scale_factor),
),
)
// Damage bounds are logical coordinates. Keep every command, including
// full-surface backgrounds and explicit compatibility Clears, inside the
// same damage clip so skipped cached layers retain their previous pixels.
self.apply_damage_clip(canvas, effective_damage)
self.apply_damage_clear(canvas, effective_damage, frame.clear_color)
self.unsupported_command_count = 0
self.unsupported_command_diagnostics.clear()
let stats = SkiaCachedLayerFrameStats::new()
let canvas_scopes : Array[@render_common.PlanScopeKind] = []
let plan = @render_common.lower_draw_commands(frame.commands)
let complete = self.render_cached_command_range(
canvas,
plan.ops,
start_index=0,
end_index=plan.ops.length(),
canvas_scopes,
stats,
damage=effective_damage,
)
if !canvas_scopes.is_empty() {
for scope in canvas_scopes {
self.record_unsupported_command(
@render_common.plan_scope_push_command(scope),
"command stream ended with an open \{@render_common.plan_scope_name(scope)} scope",
)
}
}
canvas.restore_to_count(saved)
if draw_platform_views {
match self.platform_view_draw_fn {
Some(draw_fn) => draw_fn()
None => ()
}
}
(stats, complete)
}
///|
/// Record a frame into an immutable picture for replay by a native renderer
/// worker. No MoonBit object crosses the thread boundary.
pub fn SkiaRasterRenderer::record_frame_picture(
self : SkiaRasterRenderer,
frame : @core.DrawFrame,
) -> @skia_native.Picture? {
match @skia_native.PictureRecorder::new() {
None => None
Some(recorder) => {
let physical = self.metrics.physical_size
let width = physical.width.ceil().max(1.0).to_int()
let height = physical.height.ceil().max(1.0).to_int()
match recorder.begin(width, height) {
None => None
Some(canvas) => {
let complete = self.draw_frame_to_picture_canvas(canvas, frame)
if !complete {
None
} else {
match self.platform_view_draw_fn {
Some(draw_fn) => {
self.platform_view_recording_canvas = Some(canvas)
draw_fn()
self.platform_view_recording_canvas = None
}
None => ()
}
recorder.finish()
}
}
}
}
}
}
///|
pub fn SkiaRasterRenderer::backend_kind(
self : SkiaRasterRenderer,
) -> @render.RendererBackendKind {
let _ = self
@render.RendererBackendKind::SkiaRasterNative
}
///|
pub fn SkiaRasterRenderer::backend_info(
self : SkiaRasterRenderer,
) -> @render.RendererBackendInfo {
let _ = self
backend_info()
}
///|
pub fn SkiaRasterRenderer::present_count(self : SkiaRasterRenderer) -> Int {
self.present_count
}
///|
pub fn SkiaRasterRenderer::unsupported_command_count(
self : SkiaRasterRenderer,
) -> Int {
self.unsupported_command_count
}
///|
pub fn SkiaRasterRenderer::unsupported_command_diagnostics(
self : SkiaRasterRenderer,
) -> Array[SkiaUnsupportedCommandDiagnostic] {
self.unsupported_command_diagnostics.copy()
}
///|
pub fn SkiaRasterRenderer::last_frame(
self : SkiaRasterRenderer,
) -> SkiaPixelFrame? {
self.last_frame
}
///| Draw platform view content (e.g., offscreen webview pixels) onto the surface
///|
/// This should be called after render_frame but before the window system presents
pub fn SkiaRasterRenderer::draw_platform_view_pixels(
self : SkiaRasterRenderer,
pixels : Bytes,
src_width : Int,
src_height : Int,
src_stride : Int,
dst_x : Double,
dst_y : Double,
dst_width : Double,
dst_height : Double,
) -> Unit {
let recording_canvas = self.platform_view_recording_canvas
let canvas = match recording_canvas {
Some(canvas) => Some(canvas)
None => self.surface.canvas()
}
match canvas {
Some(canvas) =>
match
@skia_native.Bitmap::from_pixels(
pixels, src_width, src_height, src_stride,
) {
Some(bitmap) =>
match @skia_native.Image::from_bitmap(bitmap) {
Some(image) => {
let src_rect = @moui_skia.Rect::from_xywh(
0.0,
0.0,
Float::from_int(src_width),
Float::from_int(src_height),
)
let dst_rect = @moui_skia.Rect::from_xywh(
Float::from_double(dst_x * self.metrics.scale_factor),
Float::from_double(dst_y * self.metrics.scale_factor),
Float::from_double(dst_width * self.metrics.scale_factor),
Float::from_double(dst_height * self.metrics.scale_factor),
)
canvas.draw_image_rect(
image,
src_rect,
dst_rect,
@moui_skia.Paint::new(),
sampling=@moui_skia.SamplingOptions::linear(),
)
if recording_canvas is None {
ignore(self.surface.flush_and_submit())
}
}
None => ()
}
None => ()
}
None => ()
}
}
///|
/// Set a callback to draw platform view pixels after command rendering but before flush.
pub fn SkiaRasterRenderer::set_platform_view_draw_fn(
self : SkiaRasterRenderer,
draw_fn : (() -> Unit)?,
) -> Unit {
self.platform_view_draw_fn = draw_fn
}