fluor 0.0.3

First-principles GUI compositor library: center-origin RU coordinates, harmonic-mean span scaling, CPU softbuffer rendering, ARM-first.
Documentation
//! ANativeWindow surface management.
//!
//! Single-pass pipeline per frame: app renders α + darkness into `scratch`; `Surface::present` locks the next ANativeWindow back buffer, runs `finalize_into_screen` directly into the locked bits at the buffer's reported `stride`, stamps the magic pixel, unlocks + posts.
//!
//! Magic-pixel triple-buffer optimization: Android rotates ~three back buffers. Each gets the current `content_version` stamped into the top-right pixel after finalize. On the next frame, if the locked buffer's magic pixel already matches the latest `content_version`, the buffer is already up-to-date and we skip the finalize entirely. Samsung-mode falls back to unconditional finalize because their compositor mutates the magic pixel between lock/unlock cycles.
//!
//! Idle frames (`dirty = false` and magic-pixel matches) cost one lock + one read + one unlock+post — no per-pixel work. Stale-but-idle frames re-finalize from `scratch` (which retains the last full α + darkness frame), so we converge to the cached state across the buffer rotation without keeping an intermediate `Vec<u32>` cache.

use ndk::native_window::NativeWindow;
use ndk_sys::{ADataSpace, ANativeWindow_Buffer, ANativeWindow_lock, ANativeWindow_unlockAndPost};

use crate::canvas::PixelRect;

/// `ANativeWindow_setBuffersDataSpace` resolved at runtime via `dlsym`. Required because the symbol was added in NDK API 28 — linking it as a strong import would prevent the .so from loading at all on devices below 28 (the dynamic linker resolves all imports eagerly at `dlopen` time). Resolving via `dlsym` keeps the binary compatible with our `api-level-26` floor: on 26-27 the symbol comes back null and we silently skip the dataspace tag (the buffer stays at the compositor's default — sRGB — which is the correct fallback). On 28+ we get the proper BT.2020 tagging.
type SetBuffersDataSpaceFn =
    unsafe extern "C" fn(*mut ndk_sys::ANativeWindow, i32) -> i32;

fn lookup_set_buffers_data_space() -> Option<SetBuffersDataSpaceFn> {
    use core::sync::atomic::{AtomicPtr, Ordering};
    static CACHED: AtomicPtr<core::ffi::c_void> = AtomicPtr::new(core::ptr::null_mut());
    // Sentinel: we use 1 as "lookup done, symbol not present" so a non-zero non-1 value means "valid function pointer". This keeps the load-acquire path branch-free in the common case.
    const NOT_PRESENT: *mut core::ffi::c_void = 1 as *mut _;
    let cached = CACHED.load(Ordering::Acquire);
    if cached == NOT_PRESENT {
        return None;
    }
    if !cached.is_null() {
        return Some(unsafe { core::mem::transmute::<*mut core::ffi::c_void, SetBuffersDataSpaceFn>(cached) });
    }
    // RTLD_DEFAULT doesn't find post-link-time NDK symbols on Android (the dynamic linker namespaces hide libandroid.so from the default search). Explicitly dlopen libandroid.so first, then dlsym off that handle.
    unsafe {
        let libname = c"libandroid.so";
        let lib = libc::dlopen(libname.as_ptr(), libc::RTLD_NOW | libc::RTLD_NOLOAD);
        let lib = if lib.is_null() {
            // RTLD_NOLOAD returned null — try a real load.
            libc::dlopen(libname.as_ptr(), libc::RTLD_NOW)
        } else {
            lib
        };
        if lib.is_null() {
            CACHED.store(NOT_PRESENT, Ordering::Release);
            return None;
        }
        let name = c"ANativeWindow_setBuffersDataSpace";
        let handle = libc::dlsym(lib, name.as_ptr());
        let to_store = if handle.is_null() { NOT_PRESENT } else { handle };
        CACHED.store(to_store, Ordering::Release);
        if handle.is_null() {
            None
        } else {
            Some(core::mem::transmute::<*mut core::ffi::c_void, SetBuffersDataSpaceFn>(handle))
        }
    }
}

/// Samsung-device flag.
static mut SAMSUNG_MODE: bool = false;

pub fn set_samsung_mode(is_samsung: bool) {
    unsafe {
        SAMSUNG_MODE = is_samsung;
    }
}

#[inline]
fn is_samsung() -> bool {
    unsafe { SAMSUNG_MODE }
}

/// Lightweight surface state: tracks the surface dimensions, the monotonic content-version counter that drives the magic-pixel triple-buffer cache, and a one-shot flag that marks whether the ANativeWindow buffer dataspace has been pushed to BT.2020 yet. No intermediate pixel buffer — finalize writes directly into ANativeWindow's locked bits.
pub struct Surface {
    width: u32,
    height: u32,
    content_version: u32,
    /// True once `ANativeWindow_setBuffersDataSpace(BT2020 | GAMMA2_2 | FULL)` has been called for the current `NativeWindow`. Combined with the Activity's `colorMode = WIDE_COLOR_GAMUT` + `preferMinimalPostProcessing`, this gives the consumer pipeline a display-native target: the bytes we write are taken as BT.2020 RGB and land on the panel without an sRGB clamp or vendor saturation pass. Photon does its own colour-management later on the theme constants + chromatic wave, so any OS-side clamp would be actively destructive. Reset to `false` on resize because Android may re-create the buffer queue under a new geometry and lose the dataspace setting.
    dataspace_set: bool,
}

impl Surface {
    pub fn new(width: u32, height: u32) -> Self {
        Self {
            width,
            height,
            content_version: 1,
            dataspace_set: false,
        }
    }

    pub fn resize(&mut self, width: u32, height: u32) {
        if width == 0 || height == 0 {
            return;
        }
        self.width = width;
        self.height = height;
        self.content_version = self.content_version.wrapping_add(1);
        if self.content_version == 0 {
            self.content_version = 1;
        }
        // Force re-push of the BT.2020 dataspace next frame — surfaceChanged on Android can recreate the back-buffer queue, and a fresh queue defaults back to the implicit sRGB dataspace.
        self.dataspace_set = false;
    }

    pub fn dimensions(&self) -> (u32, u32) {
        (self.width, self.height)
    }

    /// Lock the next Android triple-buffer, finalize `scratch` (α + darkness) + `clip_mask` directly into the locked bits at the buffer's stride (skipping a magic-pixel-cache hit), stamp magic, unlock+post.
    ///
    /// `dirty = true` means the caller painted new content into `scratch` this frame — we bump `content_version` so any other Android buffer still holding the previous content reads as stale and gets re-finalized when it next rotates in. `dirty = false` means the caller didn't repaint; we still run the lock/unlock cycle to drive Choreographer timing, but skip the finalize entirely when the magic pixel already matches.
    ///
    /// Returns `true` if pixels were actually written this frame, `false` if the cache hit and we short-circuited.
    pub fn present(
        &mut self,
        window: &NativeWindow,
        scratch: &[u32],
        clip_mask: &[u8],
        win_w: usize,
        win_h: usize,
        damage_clip: PixelRect,
        dirty: bool,
    ) -> bool {
        unsafe {
            // One-shot per buffer-queue lifetime: declare our pixels are in BT.2020, not sRGB. Without this, the compositor treats the bytes we write as sRGB and runs them thru an sRGB→panel-native colour transform — exactly the desaturation/wash the photon pipeline is going to fight by doing its own colour management on theme + spectrum colours later. Resolved via dlsym (see [`lookup_set_buffers_data_space`]) so the binary stays loadable on pre-API-28 devices that don't ship the symbol.
            if !self.dataspace_set {
                match lookup_set_buffers_data_space() {
                    Some(set_ds) => {
                        // PHOTON COLOUR PIPELINE ON ANDROID. Photon writes γ=2.0 BT.2020 RGB into the buffer. We tag the buffer as γ=2.2 BT.2020 because γ=2.2 is the closest named transfer Android offers — there is no `TRANSFER_GAMMA2_0` constant and no way to pass a custom transfer function to SurfaceFlinger. The mismatch (we say γ=2.2, we send γ=2.0) means the panel renders slightly darker than authored because SF interprets each stored value as `code^2.2` when in fact we stored `code^2.0`. This trade-off is intentional and load-bearing: photon's pipeline uses square / sqrt for encode / decode at 2-4 CPU cycles per pixel; replacing them with `powf(x, 1.0/2.2)` or worse the sRGB piecewise transfer would cost 50-100 cycles per pixel running on every rasterizer, every glyph, every overlay — flatly unacceptable, and fractional gammas are numerically miserable to invert and compose. We pick γ=2.0 once at architecture time and stick with it. Users who want exact-correct rendering need to be on ferros (our own host, which we build to honour γ=2.0 end-to-end) or a non-Android platform — the slight darkness on Android is the documented cost of shipping on a platform whose colour-management API has no γ=2.0 entry; if a user complains, file a feature request against Android for `TRANSFER_GAMMA2_0`. BT.2020 primaries because photon's spectral pipeline can synthesize colours wider than P3 and we'd rather tag them honestly (and let SF tonemap into the display gamut) than clip into a narrower gamut ourselves. Constructed by OR since there's no matching `ADATASPACE_*` named constant for BT2020 + GAMMA2_2: layout 6<<16 | 4<<22 | 1<<27 = 151388160.
                        const STANDARD_BT2020: i32 = 6 << 16;
                        const TRANSFER_GAMMA2_2: i32 = 4 << 22;
                        const RANGE_FULL: i32 = 1 << 27;
                        let custom_dataspace = STANDARD_BT2020 | TRANSFER_GAMMA2_2 | RANGE_FULL;
                        let rc = set_ds(window.ptr().as_ptr(), custom_dataspace);
                        if rc == 0 {
                            log::info!(
                                "fluor::host::android::surface: setBuffersDataSpace(BT2020 | GAMMA2_2 | FULL = {}) ok",
                                custom_dataspace
                            );
                        } else {
                            log::warn!(
                                "fluor::host::android::surface: setBuffersDataSpace(BT2020 | GAMMA2_2 | FULL) returned {} — staying on compositor default",
                                rc
                            );
                        }
                    }
                    None => log::warn!(
                        "fluor::host::android::surface: ANativeWindow_setBuffersDataSpace symbol not found (API < 28); BT.2020 tag not applied"
                    ),
                }
                // Set the flag whether or not the call succeeded so we don't retry every frame.
                self.dataspace_set = true;
            }

            let mut android_buffer: ANativeWindow_Buffer = core::mem::zeroed();
            if ANativeWindow_lock(
                window.ptr().as_ptr(),
                &mut android_buffer,
                core::ptr::null_mut(),
            ) < 0
            {
                log::error!("fluor::host::android::surface: ANativeWindow_lock failed");
                return false;
            }

            let stride = android_buffer.stride as usize;
            let dst_height = android_buffer.height as usize;
            let dst_width = android_buffer.width as usize;
            let dst_pixels: &mut [u32] = core::slice::from_raw_parts_mut(
                android_buffer.bits as *mut u32,
                stride.saturating_mul(dst_height),
            );

            if dirty {
                self.content_version = self.content_version.wrapping_add(1);
                if self.content_version == 0 {
                    self.content_version = 1;
                }
            }

            let magic_idx = dst_width.saturating_sub(1);
            let buffer_is_current = !is_samsung()
                && magic_idx < stride
                && dst_pixels[magic_idx] == self.content_version;

            let wrote = if buffer_is_current {
                false
            } else {
                let copy_width = dst_width.min(win_w);
                let copy_height = dst_height.min(win_h);
                let clip = PixelRect::new(
                    damage_clip.x0.min(copy_width),
                    damage_clip.y0.min(copy_height),
                    damage_clip.x1.min(copy_width),
                    damage_clip.y1.min(copy_height),
                );
                crate::paint::finalize_into_screen(
                    scratch,
                    clip_mask,
                    win_w,
                    win_h,
                    dst_pixels,
                    stride,
                    0,
                    0,
                    clip,
                    true,
                );
                if magic_idx < stride {
                    dst_pixels[magic_idx] = self.content_version;
                }
                true
            };

            ANativeWindow_unlockAndPost(window.ptr().as_ptr());
            wrote
        }
    }
}