diff --git a/docs/docs/config.md b/docs/docs/config.md index 109850cb..dabc2a1c 100644 --- a/docs/docs/config.md +++ b/docs/docs/config.md @@ -1663,16 +1663,19 @@ Set an image as background. - Default: `None` -#### Using image as background: +#### Fields + +- `path` — required, absolute path to a PNG/JPG/etc. +- `opacity` — `0.0`–`1.0`, default `1.0`. Multiplied into the image's alpha channel before upload, so a lower value lets the terminal background bleed through. -If both properties `width` and `height` are occluded then background image will use the terminal width/height. +> The image is uploaded once into a dedicated GPU texture sized exactly to the source dimensions and stretched to fill the window. The `width`, `height`, `x`, and `y` fields are currently ignored. + +#### Using image as background: ```toml [window.background-image] path = "/Users/hugoamor/Desktop/musashi.png" opacity = 0.5 -x = 0.0 -y = -100.0 ``` ![Demo image as background](/assets/demos/demo-background-image.png) diff --git a/sugarloaf/src/renderer/mod.rs b/sugarloaf/src/renderer/mod.rs index 5e6b9040..c2e1a936 100644 --- a/sugarloaf/src/renderer/mod.rs +++ b/sugarloaf/src/renderer/mod.rs @@ -64,6 +64,10 @@ pub struct WgpuRenderer { image_pipeline: wgpu::RenderPipeline, image_bind_group_layout: wgpu::BindGroupLayout, image_vertex_buffer: wgpu::Buffer, + /// Dedicated one-instance vertex buffer for the background image, + /// kept separate from the kitty `image_vertex_buffer` so it cannot + /// collide with kitty placement slots. + background_image_vertex_buffer: wgpu::Buffer, } #[cfg(target_os = "macos")] @@ -85,6 +89,10 @@ pub struct MetalRenderer { // Image pipeline (separate from text) image_pipeline_state: RenderPipelineState, image_vertex_buffer: Buffer, + /// Dedicated one-instance vertex buffer for the background image, + /// kept separate from the kitty `image_vertex_buffer` so it cannot + /// collide with kitty placement slots. + background_image_vertex_buffer: Buffer, } #[cfg(target_os = "macos")] @@ -361,6 +369,13 @@ impl MetalRenderer { ); image_vertex_buffer.set_label("sugarloaf::image instance buffer"); + let background_image_vertex_buffer = context.device.new_buffer( + mem::size_of::() as u64, + MTLResourceOptions::StorageModeShared, + ); + background_image_vertex_buffer + .set_label("sugarloaf::background image instance buffer"); + Self { pipeline_state, vertex_buffer, @@ -370,6 +385,7 @@ impl MetalRenderer { uniform_buffer, image_pipeline_state, image_vertex_buffer, + background_image_vertex_buffer, } } @@ -549,6 +565,13 @@ struct ImageDraw { layer: ImageLayer, } +/// Decoded background image pixels (RGBA8) waiting to be uploaded to the GPU. +pub struct BackgroundImagePixels { + pub width: u32, + pub height: u32, + pub pixels: Vec, +} + pub struct Renderer { brush_type: RendererType, comp: Compositor, @@ -562,17 +585,99 @@ pub struct Renderer { image_textures: FxHashMap, /// Image draw commands for the current frame. image_draws: Vec, - /// Atlas-allocated background image, if any. Kept allocated until - /// `clear_background_image` so we can deallocate explicitly. - background_image_id: Option, + /// Pending background image upload (consumed by `prepare`). + background_image_dirty: Option, + /// Dedicated GPU texture for the background image, sized to the + /// image dimensions instead of going through the glyph atlas. + background_image_texture: Option, } -/// Atlas placement returned by `Renderer::register_background_image`, -/// containing everything `Sugarloaf::image_rect` needs to draw the image. -#[derive(Clone, Copy, Debug)] -pub struct BackgroundImagePlacement { - pub coords: [f32; 4], - pub atlas_layer: i32, +/// Upload `pixels` to a fresh GPU texture using whatever backend `context` +/// is bound to. Mirrors the per-image upload in `render_graphic_overlays`, +/// but produces a standalone `ImageTextureEntry` sized exactly to the image +/// instead of consuming a slot in the glyph atlas. +fn upload_background_image_texture( + context: &mut crate::context::Context, + pixels: &BackgroundImagePixels, +) -> Option { + if pixels.width == 0 || pixels.height == 0 { + return None; + } + let gpu = match &context.inner { + crate::context::ContextType::Cpu(_) => return None, + crate::context::ContextType::Wgpu(ctx) => { + let texture = ctx.device.create_texture(&wgpu::TextureDescriptor { + label: Some("sugarloaf::background image"), + size: wgpu::Extent3d { + width: pixels.width, + height: pixels.height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + ctx.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &pixels.pixels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(pixels.width * 4), + rows_per_image: Some(pixels.height), + }, + wgpu::Extent3d { + width: pixels.width, + height: pixels.height, + depth_or_array_layers: 1, + }, + ); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + ImageTexture::Wgpu { + _texture: texture, + view, + } + } + #[cfg(target_os = "macos")] + crate::context::ContextType::Metal(ctx) => { + let desc = metal::TextureDescriptor::new(); + desc.set_pixel_format(metal::MTLPixelFormat::RGBA8Unorm); + desc.set_width(pixels.width as u64); + desc.set_height(pixels.height as u64); + desc.set_usage( + metal::MTLTextureUsage::ShaderRead + | metal::MTLTextureUsage::ShaderWrite, + ); + let mtl_tex = ctx.device.new_texture(&desc); + mtl_tex.set_label("sugarloaf::background image"); + mtl_tex.replace_region( + metal::MTLRegion { + origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, + size: metal::MTLSize { + width: pixels.width as u64, + height: pixels.height as u64, + depth: 1, + }, + }, + 0, + pixels.pixels.as_ptr() as *const std::ffi::c_void, + (pixels.width * 4) as u64, + ); + ImageTexture::Metal(mtl_tex) + } + }; + Some(ImageTextureEntry { + gpu, + transmit_time: std::time::Instant::now(), + }) } impl Renderer { @@ -599,82 +704,26 @@ impl Renderer { current_frame: 0, image_textures: FxHashMap::default(), image_draws: Vec::new(), - background_image_id: None, + background_image_dirty: None, + background_image_texture: None, } } - /// Allocate a background image into the color atlas. Returns the atlas - /// UV coordinates and atlas layer the caller needs to pass to - /// `Sugarloaf::image_rect`. The previous background image (if any) is - /// deallocated first so reloads don't leak atlas slots. - pub fn register_background_image( + /// Replace the background image. Pass `None` to clear it. The pixels + /// are uploaded into a dedicated GPU texture on the next `prepare` + /// call (so we don't go through the glyph atlas). + pub fn set_background_image_pixels( &mut self, - pixels: &[u8], - width: u16, - height: u16, - ) -> Option { - if width == 0 || height == 0 || pixels.len() < (width as usize * height as usize * 4) { - return None; - } - // Drop the previous allocation, if any, before claiming a new one. - self.clear_background_image(); - - let request = image_cache::AddImage { - width, - height, - has_alpha: true, - data: image_cache::ImageData::Borrowed(pixels), - content_type: image_cache::ContentType::Color, - }; - let id = self.images.allocate(request)?; - let location = self.images.get(&id)?; - let atlas_layer = self - .images - .get_atlas_index(id) - .map(|idx| (idx + 1) as i32) - .unwrap_or(1); - self.background_image_id = Some(id); - Some(BackgroundImagePlacement { - coords: [ - location.min.0, - location.min.1, - location.max.0, - location.max.1, - ], - atlas_layer, - }) - } - - /// Free the atlas slot held by the current background image, if any. - pub fn clear_background_image(&mut self) { - if let Some(id) = self.background_image_id.take() { - let _ = self.images.deallocate(id); + pixels: Option, + ) { + if pixels.is_some() { + self.background_image_dirty = pixels; + } else { + self.background_image_dirty = None; + self.background_image_texture = None; } } - /// Look up the placement (atlas UVs + layer) of the currently registered - /// background image. Returns `None` if no background image is set. - pub fn current_background_image_placement( - &self, - ) -> Option { - let id = self.background_image_id?; - let location = self.images.get(&id)?; - let atlas_layer = self - .images - .get_atlas_index(id) - .map(|idx| (idx + 1) as i32) - .unwrap_or(1); - Some(BackgroundImagePlacement { - coords: [ - location.min.0, - location.min.1, - location.max.0, - location.max.1, - ], - atlas_layer, - }) - } - #[inline] pub fn prepare( &mut self, @@ -906,6 +955,14 @@ impl Renderer { self.image_draws.clear(); } + // Upload pending background image (if any) before the render pass + // begins. The texture stays cached until a new image arrives or + // `set_background_image_pixels(None)` is called. + if let Some(pixels) = self.background_image_dirty.take() { + self.background_image_texture = + upload_background_image_texture(context, &pixels); + } + self.vertices.clear(); self.images.process_atlases(context); self.comp.finish(&mut self.vertices); @@ -1695,6 +1752,59 @@ impl Renderer { render_encoder.set_fragment_sampler_state(0, Some(&brush.sampler)); } + /// Draw a single fullscreen background image quad through the image + /// pipeline. Mirrors `draw_images_metal` but uses the dedicated + /// `background_image_vertex_buffer` so it never collides with kitty + /// placements, and reads the bg texture from `background_image_texture`. + #[cfg(target_os = "macos")] + fn draw_background_image_metal( + background_image_texture: &Option, + brush: &MetalRenderer, + render_encoder: &metal::RenderCommandEncoderRef, + physical_size: (f32, f32), + ) { + let entry = match background_image_texture { + Some(e) => e, + None => return, + }; + let tex = match &entry.gpu { + ImageTexture::Metal(tex) => tex, + _ => return, + }; + + let instance = ImageInstance { + dest_pos: [0.0, 0.0], + dest_size: [physical_size.0, physical_size.1], + source_rect: [0.0, 0.0, 1.0, 1.0], + }; + unsafe { + *(brush.background_image_vertex_buffer.contents() as *mut ImageInstance) = + instance; + } + + render_encoder.set_render_pipeline_state(&brush.image_pipeline_state); + render_encoder.set_vertex_buffer(1, Some(&brush.uniform_buffer), 0); + render_encoder.set_fragment_sampler_state(0, Some(&brush.sampler)); + render_encoder.set_vertex_buffer( + 0, + Some(&brush.background_image_vertex_buffer), + 0, + ); + render_encoder.set_fragment_texture(0, Some(tex)); + render_encoder.draw_primitives_instanced( + metal::MTLPrimitiveType::TriangleStrip, + 0, + 4, + 1, + ); + + // Restore text pipeline state for downstream batches. + render_encoder.set_render_pipeline_state(&brush.pipeline_state); + render_encoder.set_vertex_buffer(0, Some(&brush.vertex_buffer), 0); + render_encoder.set_vertex_buffer(1, Some(&brush.uniform_buffer), 0); + render_encoder.set_fragment_sampler_state(0, Some(&brush.sampler)); + } + /// Find the least recently used graphic ID for eviction. /// Returns the GraphicId to evict, or None if cache is empty. fn find_oldest_graphic(&self) -> Option { @@ -1935,6 +2045,7 @@ impl Renderer { vertices, image_draws, image_textures, + background_image_texture, .. } = self; @@ -1944,10 +2055,56 @@ impl Renderer { let mask_texture_view = images.get_mask_texture_view(); let has_images = !image_draws.is_empty(); - if (color_views.is_empty() || vertices.is_empty()) && !has_images { + let has_background = background_image_texture.is_some(); + if (color_views.is_empty() || vertices.is_empty()) + && !has_images + && !has_background + { return; } + // Background image: drawn first so all subsequent text/rects + // composite on top. Single fullscreen instance, dedicated + // vertex buffer, reuses the kitty image pipeline + sampler. + if let Some(bg_tex) = background_image_texture.as_ref() { + if let ImageTexture::Wgpu { view, .. } = &bg_tex.gpu { + let instance = ImageInstance { + dest_pos: [0.0, 0.0], + dest_size: [ + ctx.size.width as f32, + ctx.size.height as f32, + ], + source_rect: [0.0, 0.0, 1.0, 1.0], + }; + ctx.queue.write_buffer( + &brush.background_image_vertex_buffer, + 0, + bytemuck::bytes_of(&instance), + ); + let bg_bind = ctx.device.create_bind_group( + &wgpu::BindGroupDescriptor { + label: Some("background image bind group"), + layout: &brush.image_bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(view), + }], + }, + ); + rpass.set_pipeline(&brush.image_pipeline); + rpass.set_bind_group(0, &brush.constant_bind_group, &[]); + rpass.set_bind_group(1, &bg_bind, &[]); + rpass.set_vertex_buffer( + 0, + brush.background_image_vertex_buffer.slice(..), + ); + rpass.draw(0..4, 0..1); + // Restore text pipeline state for downstream batches. + rpass.set_pipeline(&brush.pipeline); + rpass.set_bind_group(0, &brush.constant_bind_group, &[]); + } + } + if has_images && image_draws.iter().any(|d| d.layer == ImageLayer::BelowText) { // Each draw must use a unique slot in the shared vertex @@ -2104,6 +2261,15 @@ impl Renderer { if let RendererType::Metal(brush) = &mut self.brush_type { let has_images = !self.image_draws.is_empty(); + // Background image: drawn first so all subsequent text/rects + // composite on top. + Self::draw_background_image_metal( + &self.background_image_texture, + brush, + render_encoder, + (context.size.width as f32, context.size.height as f32), + ); + // BelowText images (z < 0): before text if has_images { Self::draw_images_metal( @@ -2540,6 +2706,14 @@ impl WgpuRenderer { mapped_at_creation: false, }); + let background_image_vertex_buffer = + context.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("background image instance buffer"), + size: mem::size_of::() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + WgpuRenderer { layout_bind_group, layout_bind_group_layout, @@ -2552,6 +2726,7 @@ impl WgpuRenderer { image_pipeline, image_bind_group_layout, image_vertex_buffer, + background_image_vertex_buffer, } } diff --git a/sugarloaf/src/sugarloaf.rs b/sugarloaf/src/sugarloaf.rs index cd01f3fa..e12eb6dd 100644 --- a/sugarloaf/src/sugarloaf.rs +++ b/sugarloaf/src/sugarloaf.rs @@ -21,11 +21,6 @@ use raw_window_handle::{ }; use state::SugarState; -/// Reserved content-state ID for the optional window background image. -/// Picked at the top of the address space so it cannot collide with the -/// dynamically-allocated rich-text IDs returned by `get_next_id`. -const BACKGROUND_IMAGE_CONTENT_ID: usize = usize::MAX; - pub struct Sugarloaf<'a> { pub ctx: Context<'a>, renderer: Renderer, @@ -326,23 +321,27 @@ impl Sugarloaf<'_> { } /// Try to load and install a window background image. Returns `Err` - /// with a human-readable message on failure (e.g. file missing, - /// decode failed, image too large for the atlas) so callers can - /// surface the message in a UI overlay. + /// with a human-readable message on failure (file missing, decode + /// failed, decoded image is empty, etc.) so callers can surface the + /// message in a UI overlay. The decoded pixels are uploaded to a + /// dedicated GPU texture sized to the image — the glyph atlas is not + /// touched, so a 4K wallpaper does not push glyphs out of cache. #[inline] pub fn set_background_image( &mut self, image: &ImageProperties, ) -> Result<(), String> { - // Skip if the same image is already configured. + // Skip if the same image is already configured. Both the path and + // the opacity must match — opacity is baked into the alpha channel + // at upload time, so an opacity change requires a reload. if let Some(current) = &self.background_image { - if current.path == image.path { + if current.path == image.path && current.opacity == image.opacity { return Ok(()); } } // Decode the file synchronously. - let decoded = match image_rs::open(&image.path) { + let mut decoded = match image_rs::open(&image.path) { Ok(img) => img.to_rgba8(), Err(e) => { let msg = format!("'{}': {}", image.path, e); @@ -351,29 +350,35 @@ impl Sugarloaf<'_> { } }; let (img_w, img_h) = decoded.dimensions(); + if img_w == 0 || img_h == 0 { + let msg = format!( + "'{}' decoded to a {}x{} image", + image.path, img_w, img_h + ); + tracing::warn!("background image {}", msg); + return Err(msg); + } - // Allocate into the color atlas. Returns atlas UVs + layer that the - // existing image_rect content path needs to draw the texture. - let placement = match self.renderer.register_background_image( - decoded.as_raw(), - img_w as u16, - img_h as u16, - ) { - Some(p) => p, - None => { - let msg = format!( - "'{}' ({}x{}) does not fit in the texture atlas", - image.path, img_w, img_h - ); - tracing::warn!("background image {}", msg); - return Err(msg); + // Apply per-image opacity by scaling the alpha channel before + // upload. The image fragment shader premultiplies alpha at sample + // time, so the GPU does the right thing for both fully-opaque and + // partially-translucent source images. + let opacity = image.opacity.clamp(0.0, 1.0); + if opacity < 1.0 { + let opacity_byte = (opacity * 255.0).round() as u16; + for pixel in decoded.pixels_mut() { + pixel[3] = ((pixel[3] as u16 * opacity_byte) / 255) as u8; } - }; + } + self.renderer.set_background_image_pixels(Some( + crate::renderer::BackgroundImagePixels { + width: img_w, + height: img_h, + pixels: decoded.into_raw(), + }, + )); self.background_image = Some(image.clone()); - let _ = placement; // refresh below queries the renderer directly so - // resize() can reuse the same path. - self.refresh_background_image_content(); Ok(()) } @@ -383,43 +388,10 @@ impl Sugarloaf<'_> { if self.background_image.is_none() { return; } - self.state - .content - .remove_state(&BACKGROUND_IMAGE_CONTENT_ID); - self.renderer.clear_background_image(); + self.renderer.set_background_image_pixels(None); self.background_image = None; } - /// Push (or refresh) the background image content state with current - /// window dimensions. Called from `set_background_image` and from - /// `resize` so the image always covers the full window. No-op if no - /// background image is currently registered with the renderer. - fn refresh_background_image_content(&mut self) { - let placement = match self.renderer.current_background_image_placement() { - Some(p) => p, - None => return, - }; - let physical = self.ctx.size(); - let scale = self.state.style.scale_factor.max(1.0); - let logical_w = physical.width / scale; - let logical_h = physical.height / scale; - self.image_rect( - Some(BACKGROUND_IMAGE_CONTENT_ID), - 0.0, - 0.0, - logical_w, - logical_h, - [1.0, 1.0, 1.0, 1.0], - placement.coords, - // Lowest depth so it sits behind cell rects (-0.1) and the - // panel borders. The terminal compositor has no depth buffer, - // so this is a sort key, not a real Z test — keeping it well - // below all other content keeps things robust. - -0.5, - placement.atlas_layer, - ); - } - /// Remove content by ID (any type) #[inline] pub fn remove_content(&mut self, id: usize) { @@ -950,16 +922,14 @@ impl Sugarloaf<'_> { pub fn resize(&mut self, width: u32, height: u32) { self.ctx.resize(width, height); self.renderer.resize(&mut self.ctx); - // Background image must follow the new window size. - self.refresh_background_image_content(); + // No content-state refresh needed for the background image — the + // dedicated draw call reads `ctx.size` directly each frame. } #[inline] pub fn rescale(&mut self, scale: f32) { self.ctx.set_scale(scale); self.state.compute_layout_rescale(scale); - // Background image must follow the new scale factor. - self.refresh_background_image_content(); } #[inline] diff --git a/sugarloaf/src/sugarloaf/primitives.rs b/sugarloaf/src/sugarloaf/primitives.rs index 90ec131f..e60235ec 100644 --- a/sugarloaf/src/sugarloaf/primitives.rs +++ b/sugarloaf/src/sugarloaf/primitives.rs @@ -147,7 +147,7 @@ pub struct SugarCursor { pub order: u8, } -#[derive(Default, Clone, Deserialize, Debug, PartialEq)] +#[derive(Clone, Deserialize, Debug, PartialEq)] pub struct ImageProperties { #[serde(default = "String::default")] pub path: String, @@ -159,6 +159,29 @@ pub struct ImageProperties { pub x: f32, #[serde(default = "f32::default")] pub y: f32, + /// Multiplier applied to the image's alpha channel before upload. + /// Clamped to `[0.0, 1.0]`. `1.0` (the default) means fully opaque; + /// lower values let the terminal background show through. + #[serde(default = "default_image_opacity")] + pub opacity: f32, +} + +#[inline] +fn default_image_opacity() -> f32 { + 1.0 +} + +impl Default for ImageProperties { + fn default() -> Self { + Self { + path: String::new(), + width: None, + height: None, + x: 0.0, + y: 0.0, + opacity: default_image_opacity(), + } + } } #[derive(Clone, Copy, Debug, PartialEq)]