///|
fn SkiaRasterRenderer::draw_image(
self : SkiaRasterRenderer,
canvas : @skia_native.Canvas,
run : @core.ImageRun,
) -> Unit {
match self.skia_image_for_source(run.source) {
Some(image) => {
let placement = skia_image_placement(
run.frame,
@core.Size::new(
width=image.width().to_double(),
height=image.height().to_double(),
),
run.fit,
)
canvas.draw_image_rect(
image,
@moui_skia.Rect::from_xywh(
Float::from_double(placement.u0 * image.width().to_double()),
Float::from_double(placement.v0 * image.height().to_double()),
Float::from_double(
(placement.u1 - placement.u0) * image.width().to_double(),
),
Float::from_double(
(placement.v1 - placement.v0) * image.height().to_double(),
),
),
skia_rect(placement.frame),
skia_opacity_paint(run.opacity),
sampling=@moui_skia.SamplingOptions::linear(),
)
}
None => self.draw_image_placeholder(canvas, run)
}
}
///|
/// Draw the placeholder that stands in for an image that is not on screen yet.
///
/// Replaces a full-frame diagonal gradient (`(0.72,0.78,0.86)` →
/// `(0.42,0.50,0.62)`, opaque) that read as a broken render: a large,
/// high-contrast block in the middle of a document pulls the eye away from the
/// text and says nothing about the image it stands for.
///
/// The card is built from three cheap parts:
///
/// 1. A near-transparent neutral fill (alpha 0.07). Low enough that the tinted
/// rounded rect the rich-text painter already emits before `DrawImage`
/// (`moui_richtext`'s `rgba(0.18,0.4,0.7,0.08)`) shows through, so the card
/// picks up the application's own image tint without the renderer needing a
/// theme it does not have — and stays legible on both a white and a dark
/// page.
/// 2. A hairline border, one notch stronger for a failed image than for one
/// still loading, so the two states differ without a second layout.
/// 3. A centered "picture" glyph (frame + sun + peak, plus a slash when the
/// load failed). Drawn only when the short side is at least
/// [`skia_image_placeholder_glyph_min_side`] — in a 32 px badge row a glyph
/// is mud, so those boxes keep just the card.
///
/// Cost is a constant handful of Skia calls (one rounded fill, one rounded
/// stroke, one or two paths). No text shaping, no font lookup, no per-pixel
/// work: a failed image can be repainted every frame without entering the hot
/// path, which is what keeps placeholder rendering off the load-time budget.
fn SkiaRasterRenderer::draw_image_placeholder(
self : SkiaRasterRenderer,
canvas : @skia_native.Canvas,
run : @core.ImageRun,
) -> Unit {
let frame = run.frame
if frame.size.width <= 0.0 || frame.size.height <= 0.0 {
return
}
let failed = self.skia_image_placeholder_failed(run.source)
let rounded = @core.RoundedRect::new(
rect=frame,
radius=skia_image_placeholder_radius(frame),
)
self.draw_brush_rounded_rect(
canvas,
rounded,
skia_image_placeholder_fill(run),
stroke_width=None,
)
self.draw_brush_rounded_rect(
canvas,
rounded,
skia_image_placeholder_border(run, failed),
stroke_width=Some(skia_image_placeholder_border_width),
)
match skia_image_placeholder_glyph_box(frame) {
Some(box) => self.draw_image_placeholder_glyph(canvas, box, run, failed)
None => ()
}
}
///|
/// Card corner radius, clamped so a box shorter than the radius still gets a
/// sensible quarter-round instead of an over-rounded pill.
let skia_image_placeholder_radius_max : Double = 8.0
///|
fn skia_image_placeholder_radius(frame : @core.Rect) -> Double {
let half = frame.size.width.min(frame.size.height) / 2.0
if half < skia_image_placeholder_radius_max {
half
} else {
skia_image_placeholder_radius_max
}
}
///|
let skia_image_placeholder_border_width : Double = 1.0
///|
/// Shortest side that still fits a legible glyph. Below this the card stands
/// alone: badges and inline thumbnails are simply too small to draw into.
let skia_image_placeholder_glyph_min_side : Double = 48.0
///|
/// Card fill: a neutral cool gray at very low alpha, scaled by the run's own
/// opacity so a faded image fades its placeholder with it.
fn skia_image_placeholder_fill(run : @core.ImageRun) -> @core.Brush {
@core.Brush::solid(
@core.Color::rgba(r=0.42, g=0.47, b=0.55, a=0.07 * run.opacity),
)
}
///|
/// Card border: the same hue as the fill, three steps up in alpha. A failed
/// load is drawn firmer than a pending one — "still coming" and "will never
/// come" should not look identical.
fn skia_image_placeholder_border(
run : @core.ImageRun,
failed : Bool,
) -> @core.Brush {
let alpha = if failed { 0.34 } else { 0.18 }
@core.Brush::solid(
@core.Color::rgba(r=0.42, g=0.47, b=0.55, a=alpha * run.opacity),
)
}
///|
/// Glyph color: a muted ink one step darker than the border.
fn skia_image_placeholder_glyph_brush(
run : @core.ImageRun,
failed : Bool,
) -> @core.Brush {
let alpha = if failed { 0.62 } else { 0.45 }
@core.Brush::solid(
@core.Color::rgba(r=0.38, g=0.44, b=0.54, a=alpha * run.opacity),
)
}
///|
/// True when this source is known to have failed: either the decoded-image
/// failure cache or the lifecycle record. Both are already consulted by
/// `skia_image_for_source`, so the placeholder reuses the same verdict rather
/// than inventing a second one.
fn SkiaRasterRenderer::skia_image_placeholder_failed(
self : SkiaRasterRenderer,
source : String,
) -> Bool {
if self.failed_images.get(source) is Some(_) {
return true
}
match self.image_lifecycle.lookup(source) {
Some({ status: ImageFailed, .. }) => true
_ => false
}
}
///|
/// Square glyph box centered in the card, or `None` when the card is too small
/// to draw into. The glyph is sized off the short side so it never touches the
/// border, and capped so a poster-sized image does not get a giant icon.
fn skia_image_placeholder_glyph_box(frame : @core.Rect) -> @core.Rect? {
let short = frame.size.width.min(frame.size.height)
if short < skia_image_placeholder_glyph_min_side {
return None
}
let side = (short * 0.34).max(skia_image_placeholder_glyph_min_side * 0.34)
let side = side.min(40.0)
Some(
@core.Rect::new(
x=frame.origin.x + (frame.size.width - side) / 2.0,
y=frame.origin.y + (frame.size.height - side) / 2.0,
width=side,
height=side,
),
)
}
///|
/// The "picture" glyph: a rounded frame, a sun, a peak — and a slash across it
/// when the load failed. Coordinates are fractions of `box` so one geometry
/// serves every size.
fn SkiaRasterRenderer::draw_image_placeholder_glyph(
self : SkiaRasterRenderer,
canvas : @skia_native.Canvas,
box : @core.Rect,
run : @core.ImageRun,
failed : Bool,
) -> Unit {
let brush = skia_image_placeholder_glyph_brush(run, failed)
let stroke = skia_image_placeholder_glyph_stroke
let at = (fx : Double, fy : Double) => {
@core.Point::new(
x=box.origin.x + box.size.width * fx,
y=box.origin.y + box.size.height * fy,
)
}
let side = box.size.width.min(box.size.height)
// Frame: the outer rounded rectangle, inset by half the stroke so the line
// stays inside the glyph box.
let inset = stroke / 2.0
self.draw_brush_rounded_rect(
canvas,
@core.RoundedRect::new(
rect=@core.Rect::new(
x=box.origin.x + inset,
y=box.origin.y + inset,
width=box.size.width - stroke,
height=box.size.height - stroke,
),
radius=side * 0.18,
),
brush,
stroke_width=Some(stroke),
)
// Sun + peak travel as one filled path: two closed subpaths, so a single
// Skia draw fills both.
let sun_radius = side * 0.085
let sun_center = at(0.33, 0.34)
self.draw_path(
canvas,
@core.PathSpec::new(
verbs=skia_image_placeholder_sun_and_peak(at, sun_center, sun_radius),
fill=Some(brush),
stroke=None,
stroke_width=0.0,
),
)
if failed {
// Slash: the one universally read "this did not load" mark. Drawn corner to
// corner across the frame, past it at both ends so it cannot be mistaken
// for part of the picture.
self.draw_path(
canvas,
@core.PathSpec::new(
verbs=[MoveTo(at(0.06, 0.06)), LineTo(at(0.94, 0.94))],
fill=None,
stroke=Some(brush),
stroke_width=stroke,
),
)
}
}
///|
let skia_image_placeholder_glyph_stroke : Double = 1.5
///|
/// Sun and peak as one vertex list: the sun is four cubic Béziers (control
/// offset `r * 0.5523`, the standard circle approximation — `PathVerb` has no
/// arc primitive), then a closed triangle for the mountain.
fn skia_image_placeholder_sun_and_peak(
at : (Double, Double) -> @core.Point,
center : @core.Point,
radius : Double,
) -> Array[@core.PathVerb] {
let k = radius * 0.5523
let p = (dx : Double, dy : Double) => {
@core.Point::new(x=center.x + dx, y=center.y + dy)
}
[
MoveTo(p(0.0, -radius)),
CubicTo(p(k, -radius), p(radius, -k), p(radius, 0.0)),
CubicTo(p(radius, k), p(k, radius), p(0.0, radius)),
CubicTo(p(-k, radius), p(-radius, k), p(-radius, 0.0)),
CubicTo(p(-radius, -k), p(-k, -radius), p(0.0, -radius)),
Close,
MoveTo(at(0.16, 0.76)),
LineTo(at(0.45, 0.44)),
LineTo(at(0.74, 0.76)),
Close,
]
}
///|
fn SkiaRasterRenderer::skia_image_for_source(
self : SkiaRasterRenderer,
source : String,
) -> @skia_native.Image? {
match self.image_cache.get(source) {
Some(image) => Some(image)
None => {
match self.failed_images.get(source) {
Some(diagnostic) =>
if skia_image_failed_cache_can_retry(source) {
self.failed_images.remove(source)
} else {
self.mark_image_failed(source, diagnostic~)
return None
}
None => ()
}
if self.async_image_loading {
match self.image_lifecycle.lookup(source) {
Some({ status: ImageLoading, .. }) => return None
Some({ status: ImageReady, .. }) =>
return self.decode_and_cache_image_source(
source,
mark_loading=false,
)
Some({ status: ImageFailed, diagnostic, .. }) => {
self.failed_images[source] = diagnostic
return None
}
_ => {
self.mark_image_loading(source)
return None
}
}
}
self.decode_and_cache_image_source(source, mark_loading=true)
}
}
}
///|
fn SkiaRasterRenderer::decode_and_cache_image_source(
self : SkiaRasterRenderer,
source : String,
mark_loading~ : Bool,
) -> @skia_native.Image? {
match skia_image_source_bytes(source) {
Some(bytes) => {
if mark_loading {
self.mark_image_loading(source)
}
match @skia_native.Image::from_encoded_bytes(bytes) {
Some(image) => {
self.failed_images.remove(source)
self.image_cache[source] = image
self.mark_image_ready(
source,
width=image.width(),
height=image.height(),
)
Some(image)
}
None => {
self.failed_images[source] = "Skia image decode failed"
self.mark_image_failed(source, diagnostic="Skia image decode failed")
None
}
}
}
None => {
self.failed_images[source] = "Skia image source bytes are unavailable"
self.mark_image_failed(
source,
diagnostic="Skia image source bytes are unavailable",
)
None
}
}
}
///|
fn SkiaRasterRenderer::mark_image_loading(
self : SkiaRasterRenderer,
source : String,
) -> Unit {
ignore(self.image_lifecycle.mark_loading(source))
}
///|
fn SkiaRasterRenderer::mark_image_ready(
self : SkiaRasterRenderer,
source : String,
width~ : Int,
height~ : Int,
) -> Unit {
ignore(self.image_lifecycle.mark_ready(source, width~, height~))
}
///|
fn SkiaRasterRenderer::mark_image_failed(
self : SkiaRasterRenderer,
source : String,
diagnostic~ : String,
) -> Unit {
ignore(self.image_lifecycle.mark_failed(source, diagnostic~))
}
///|
fn SkiaRasterRenderer::dispose_image_resource(
self : SkiaRasterRenderer,
source : String,
) -> Unit {
ignore(self.image_lifecycle.dispose(source))
}
///|
fn skia_image_failed_cache_can_retry(source : String) -> Bool {
let _ = source
false
}
///|
pub fn skia_image_load_completion(
source : String,
) -> @render.ImageResourceLoadCompletion {
match skia_image_source_bytes(source) {
Some(bytes) => skia_image_load_completion_from_bytes(source, bytes)
None =>
@render.ImageResourceLoadCompletion::failed(
source,
diagnostic="Skia image source bytes are unavailable",
)
}
}
///|
pub fn skia_image_load_completion_from_bytes(
source : String,
bytes : Bytes,
background_io? : Bool = false,
background_decode? : Bool = false,
) -> @render.ImageResourceLoadCompletion {
match @skia_native.Codec::from_bytes(bytes) {
Some(codec) =>
match codec.decode_n32_premul() {
Some(bitmap) =>
@render.ImageResourceLoadCompletion::ready_decoded_rgba(
source,
width=bitmap.width(),
height=bitmap.height(),
row_bytes=bitmap.row_bytes(),
bitmap.to_bytes(),
background_io~,
background_decode~,
)
None =>
@render.ImageResourceLoadCompletion::failed(
source,
diagnostic="Skia image decode failed",
background_io~,
)
}
None =>
@render.ImageResourceLoadCompletion::failed(
source,
diagnostic="Skia image decode failed",
background_io~,
)
}
}
///|
fn skia_image_source_bytes(source : String) -> Bytes? {
if source.has_prefix("data:") {
skia_image_data_uri_bytes(source)
} else if @fs.path_exists(source) {
Some(@fs.read_file_to_bytes(source)) catch {
_ => None
}
} else {
None
}
}
///|
fn skia_image_data_uri_bytes(source : String) -> Bytes? {
let parts = source.split(",").to_array()
if parts.length() < 2 {
return None
}
let meta = parts[0]
if !meta.has_prefix("data:") || !meta.contains(";base64") {
return None
}
Some(@base64.decode(parts[1], ignore_whitespace=true)) catch {
_ => None
}
}
///|
priv struct SkiaImagePlacement {
frame : @core.Rect
u0 : Double
v0 : Double
u1 : Double
v1 : Double
}
///|
fn skia_image_placement(
frame : @core.Rect,
image_size : @core.Size,
fit : @core.ImageFit,
) -> SkiaImagePlacement {
let placement = @render_common.image_placement(frame, image_size, fit)
match placement.crop_axis {
None => { frame: placement.frame, u0: 0.0, v0: 0.0, u1: 1.0, v1: 1.0, }
Horizontal => {
let inset = (1.0 - placement.visible) / 2.0
{ frame: placement.frame, u0: inset, v0: 0.0, u1: 1.0 - inset, v1: 1.0, }
}
Vertical => {
let inset = (1.0 - placement.visible) / 2.0
{ frame: placement.frame, u0: 0.0, v0: inset, u1: 1.0, v1: 1.0 - inset, }
}
}
}
///|
pub fn SkiaRasterRenderer::image_resources(
self : SkiaRasterRenderer,
) -> Array[@render.ImageResourceRecord] {
self.image_lifecycle.snapshot()
}
///|
pub fn SkiaRasterRenderer::apply_image_resource_load_completion(
self : SkiaRasterRenderer,
completion : @render.ImageResourceLoadCompletion,
) -> Bool {
let decoded_image = skia_image_from_decoded_completion(completion)
let completion_to_apply = if completion.has_decoded_rgba_payload() &&
decoded_image is None {
@render.ImageResourceLoadCompletion::failed(
completion.source,
diagnostic="Skia decoded image payload invalid",
background_io=completion.background_io,
)
} else {
completion
}
let changed = self.image_lifecycle.apply_load_completion(completion_to_apply)
if changed {
// Retained layers contain rasterized command output, including image
// placeholders. Image completion changes renderer-owned pixels without
// changing the runtime render tree/content revision. Advance a renderer
// generation consumed by retained-layer keys so the next redraw rebuilds
// the affected pixels instead of replaying the placeholder.
self.image_cache_generation = self.image_cache_generation + 1
// Drop old raster/picture entries and admission streaks as well. A
// document gallery can complete several images back-to-back; retaining
// old generations would consume the layer budget and classify otherwise
// stable chrome as churn-without-hit.
self.clear_layer_cache()
match completion_to_apply.status {
ImageLoadReady => {
self.failed_images.remove(completion.source)
match decoded_image {
Some(image) => self.image_cache[completion.source] = image
None => ()
}
}
ImageLoadFailed =>
self.failed_images[completion.source] = completion_to_apply.diagnostic
}
}
changed
}
///|
fn skia_image_from_decoded_completion(
completion : @render.ImageResourceLoadCompletion,
) -> @skia_native.Image? {
if !completion.has_decoded_rgba_payload() {
return None
}
match
@skia_native.Bitmap::from_pixels(
completion.decoded_pixels,
completion.width,
completion.height,
completion.decoded_row_bytes,
) {
Some(bitmap) => @skia_native.Image::from_bitmap(bitmap)
None => None
}
}
///|
pub fn SkiaRasterRenderer::dispose(self : SkiaRasterRenderer) -> Unit {
for source, _ in self.image_cache {
self.dispose_image_resource(source)
}
self.image_cache.clear()
self.failed_images.clear()
self.clear_layer_cache()
self.invalidate_gpu_backing_surface()
// HostGpu EGL surfaces own the platform window. Replace with a tiny raster
// placeholder so the old surface finalizer can destroy the EGL window before
// sticky raster present reuses the same OHNativeWindow / ANativeWindow.
if self.gpu_target is Some(_) {
match @skia_native.Surface::raster_n32_premul(@moui_skia.ISize::new(1, 1)) {
Some(placeholder) => {
self.surface = placeholder
self.gpu_surface_attached = false
}
None => ()
}
}
}