gled-ui 2.31.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! egui textures for the thumbnails the GPU crate renders. `EffectPipelines`
//! only knows wgpu textures; this registers them with egui's renderer, again
//! when a pipeline is rebuilt, and frees them when the effect is gone.

use std::collections::{HashMap, HashSet};

#[cfg(not(target_arch = "wasm32"))]
use arboard::{Clipboard, ImageData};
use egui::TextureId;
use gled_gpu::effect_state::{EffectKey, EffectPipelines};
#[cfg(not(target_arch = "wasm32"))]
use kanal::bounded;
#[cfg(not(target_arch = "wasm32"))]
use wgpu::PollType;
use wgpu::{Extent3d, MapMode};

use crate::wgpu_render_state;
use gled_protocol::action::UiAction;

/// An egui texture id that is freed when dropped.
#[derive(Debug, PartialEq, Clone)]
pub(crate) struct OwnedTextureId(pub(crate) TextureId);

impl Drop for OwnedTextureId {
    fn drop(&mut self) {
        wgpu_render_state().renderer.write().free_texture(&self.0)
    }
}

/// The egui textures of one pipeline table's thumbnails.
#[derive(Debug, Default)]
pub(crate) struct ThumbnailTextures {
    textures: HashMap<EffectKey, (u64, OwnedTextureId)>,
}

impl ThumbnailTextures {
    /// Registers new and rebuilt thumbnails and frees those that are gone.
    /// UI thread only.
    pub(crate) fn sync(&mut self, pipelines: &EffectPipelines) {
        let render_state = wgpu_render_state();
        let mut seen = HashSet::new();
        for (key, thumbnail) in pipelines.thumbnails() {
            seen.insert(key);
            let current = self
                .textures
                .get(&key)
                .is_some_and(|(generation, _)| *generation == thumbnail.generation);
            if !current {
                let id = render_state.renderer.write().register_native_texture(
                    &render_state.device,
                    thumbnail.view,
                    wgpu::FilterMode::Nearest,
                );
                self.textures
                    .insert(key, (thumbnail.generation, OwnedTextureId(id)));
            }
        }
        self.textures.retain(|key, _| seen.contains(key));
    }

    /// The egui texture of an effect's thumbnail, once it exists.
    pub(crate) fn texture_id(&self, key: &EffectKey) -> Option<TextureId> {
        self.textures.get(key).map(|(_, id)| id.0)
    }
}

/// Copies the thumbnail into a buffer the CPU can map, on the UI device.
/// Returns the buffer and the image size.
fn read_back(pipelines: &EffectPipelines, key: &EffectKey) -> Option<(wgpu::Buffer, Extent3d)> {
    let thumbnail = pipelines.get(key).and_then(|state| state.thumbnail())?;
    let render_state = wgpu_render_state();
    let device = &render_state.device;

    let texture = thumbnail.texture;
    let texture_size = texture.size();
    let buffer_size = (texture_size.width * texture_size.height * 4) as wgpu::BufferAddress;
    let buffer = device.create_buffer(&wgpu::BufferDescriptor {
        label: Some("Texture Buffer"),
        size: buffer_size,
        usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
        mapped_at_creation: false,
    });
    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: Some("Copy Texture to Buffer"),
    });
    encoder.copy_texture_to_buffer(
        wgpu::TexelCopyTextureInfo {
            texture,
            mip_level: 0,
            origin: wgpu::Origin3d::ZERO,
            aspect: wgpu::TextureAspect::All,
        },
        wgpu::TexelCopyBufferInfo {
            buffer: &buffer,
            layout: wgpu::TexelCopyBufferLayout {
                offset: 0,
                bytes_per_row: Some(texture_size.width * 4),
                rows_per_image: Some(texture_size.height),
            },
        },
        texture_size,
    );
    // Submitted here rather than through egui's paint callback: the readback
    // waits for it. This runs on the UI thread, outside egui's paint, so it
    // cannot race the window surface.
    render_state.queue.submit([encoder.finish()]);
    Some((buffer, texture_size))
}

/// Turns the read back BGRA pixels into opaque RGBA in place.
fn to_opaque_rgba(data: &mut [u8]) {
    data.as_chunks_mut::<4>().0.iter_mut().for_each(|pixel| {
        pixel.swap(0, 2);
        pixel[3] = 255;
    });
}

/// Copies an effect's thumbnail into the clipboard as an image. Browsers do
/// not allow waiting for the GPU, so the copy happens once the read back
/// arrives, through egui's clipboard.
#[cfg(target_arch = "wasm32")]
pub(crate) fn copy_thumbnail_to_clipboard(
    ctx: &egui::Context,
    pipelines: &EffectPipelines,
    key: &EffectKey,
) {
    let Some((buffer, size)) = read_back(pipelines, key) else {
        return;
    };
    let ctx = ctx.clone();
    let mapped = buffer.clone();
    buffer.slice(..).map_async(MapMode::Read, move |result| {
        let data = result.map_err(|err| err.to_string()).and_then(|()| {
            mapped
                .slice(..)
                .get_mapped_range()
                .map(|range| range.to_vec())
                .map_err(|err| err.to_string())
        });
        mapped.unmap();
        match data {
            Ok(mut data) => {
                to_opaque_rgba(&mut data);
                ctx.copy_image(egui::ColorImage::from_rgba_unmultiplied(
                    [size.width as usize, size.height as usize],
                    &data,
                ));
            }
            Err(err) => {
                UiAction::Error(format!("Could not read the rendered image: {err}")).enqueue();
            }
        }
        ctx.request_repaint();
    });
}

/// Copies an effect's thumbnail into the system clipboard as an image.
/// UI thread only: waits for the GPU on the UI device.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn copy_thumbnail_to_clipboard(
    _ctx: &egui::Context,
    pipelines: &EffectPipelines,
    key: &EffectKey,
) {
    let Some((buffer, texture_size)) = read_back(pipelines, key) else {
        return;
    };
    let device = &wgpu_render_state().device;

    let buffer_slice = buffer.slice(..);
    let (tx, rx) = bounded(1);
    buffer_slice.map_async(MapMode::Read, move |result| {
        let _ = tx.send(result);
    });
    if let Err(err) = device.poll(PollType::wait_indefinitely()) {
        UiAction::Error(format!("Could not read the rendered image: {err}")).enqueue();
        return;
    }
    match rx.recv() {
        Ok(Ok(())) => {}
        Ok(Err(err)) => {
            UiAction::Error(format!("Could not read the rendered image: {err}")).enqueue();
            return;
        }
        Err(err) => {
            UiAction::Error(format!("Could not read the rendered image: {err}")).enqueue();
            return;
        }
    }
    let mut data = match buffer_slice.get_mapped_range() {
        Ok(range) => range.to_vec(),
        Err(err) => {
            UiAction::Error(format!("Could not read the rendered image: {err}")).enqueue();
            return;
        }
    };
    buffer.unmap();

    std::thread::Builder::new()
        .name("gled:clipboard:copy".to_string())
        .spawn(move || {
            #[cfg(feature = "profiling")]
            profiling::register_thread!("copy_image_to_clipboard");

            to_opaque_rgba(&mut data);

            if let Ok(mut clipboard) = Clipboard::new() {
                if let Err(err) = clipboard.set_image(ImageData {
                    bytes: data.into(),
                    width: texture_size.width as usize,
                    height: texture_size.height as usize,
                }) {
                    UiAction::Error(format!("Could not copy rendered image to clipboard: {err}"))
                        .enqueue();
                } else {
                    tracing::info!("Copied rendered image to clipboard");
                }
            }
        })
        .ok();
}

/// Thumbnails of a scene instance's effects that have one.
pub(crate) fn scene_instance_texture_ids(
    instance: &gled_model::asset::scene::instance::SceneInstance,
    textures: &ThumbnailTextures,
) -> Vec<TextureId> {
    gled_gpu::scene_render::scene_effect_keys(instance.id, &instance.scene)
        .filter_map(|key| textures.texture_id(&key))
        .collect()
}

/// The LED preview on the UI device and its egui texture.
#[derive(Debug)]
pub(crate) struct UiPreview {
    pub(crate) indices: gled_gpu::preview_indices::PreviewIndices,
    pub(crate) preview: gled_gpu::preview::Preview,
    pub(crate) texture_id: OwnedTextureId,
}

impl UiPreview {
    /// Creates the preview on egui's device. UI thread only.
    pub(crate) fn new(render_state: &eframe::egui_wgpu::RenderState) -> Self {
        let indices = gled_gpu::preview_indices::PreviewIndices::new(&render_state.device);
        let preview = gled_gpu::preview::Preview::new(&render_state.device, &indices);
        let texture_id = OwnedTextureId(render_state.renderer.write().register_native_texture(
            &render_state.device,
            preview.view(),
            wgpu::FilterMode::Nearest,
        ));
        Self {
            indices,
            preview,
            texture_id,
        }
    }
}