From 46886a527a5f0df36ab0710e395cbcd34faec382 Mon Sep 17 00:00:00 2001 From: Raphael Amorim Date: Wed, 22 Apr 2026 23:43:45 +0200 Subject: [PATCH] fixup! fixup! fixup! fixup! v4 new rendering system --- frontends/rioterm/src/screen/mod.rs | 18 ++- sugarloaf/src/grid/atlas.rs | 1 + sugarloaf/src/grid/metal.rs | 132 +++++++++++++++++--- sugarloaf/src/renderer/image_cache/atlas.rs | 11 ++ sugarloaf/src/sugarloaf.rs | 13 ++ 5 files changed, 152 insertions(+), 23 deletions(-) diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index a8058437..18d57ccd 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -3472,6 +3472,7 @@ impl Screen<'_> { rows: u32, cell_w: f32, cell_h: f32, + font_px: f32, visible_rows: Vec { // grid is actually drawn on removes the drift. let cell_w = dim.dimension.width.round().max(1.0); let cell_h = dim.dimension.height.round().max(1.0); + // Per-panel font size (zoom is per-rich-text, not root). + // Falls back to root × scale if the text id can't be + // found — shouldn't happen post-init but keeps the emit + // loop from dividing by zero. + let font_px = self + .sugarloaf + .text_scaled_font_size(&ctx.rich_text_id) + .unwrap_or_else(|| { + let s = self.sugarloaf.style(); + s.font_size * s.scale_factor + }); let (visible_rows, style_set, term_colors) = { let terminal = ctx.terminal.lock(); ( @@ -3528,6 +3540,7 @@ impl Screen<'_> { rows: dim.lines.max(1) as u32, cell_w, cell_h, + font_px, visible_rows, style_set, term_colors, @@ -3546,9 +3559,6 @@ impl Screen<'_> { // --- emit cells + build uniforms per panel --- let window_size = self.sugarloaf.window_size(); - let sugarloaf_style = self.sugarloaf.style(); - let font_px = - sugarloaf_style.font_size * sugarloaf_style.scale_factor; let font_library = self.sugarloaf.font_library().clone(); let bg_col = self.renderer.named_colors.background.0; let cursor_col_rgba = self.renderer.named_colors.cursor; @@ -3649,7 +3659,7 @@ impl Screen<'_> { &p.term_colors, rasterizer, grid, - font_px, + p.font_px, p.cell_h, &font_library, ) { diff --git a/sugarloaf/src/grid/atlas.rs b/sugarloaf/src/grid/atlas.rs index af8e2b08..5ec993a4 100644 --- a/sugarloaf/src/grid/atlas.rs +++ b/sugarloaf/src/grid/atlas.rs @@ -40,6 +40,7 @@ pub struct AtlasSlot { /// Raw rasterized glyph bitmap, caller-supplied. The atlas doesn't /// rasterize itself — that stays in whatever shaping / scaling path /// the caller uses (sugarloaf's swash-backed `ScaleContext`). +#[derive(Clone, Copy)] pub struct RasterizedGlyph<'a> { pub width: u16, pub height: u16, diff --git a/sugarloaf/src/grid/metal.rs b/sugarloaf/src/grid/metal.rs index 357b4e82..99bb7e37 100644 --- a/sugarloaf/src/grid/metal.rs +++ b/sugarloaf/src/grid/metal.rs @@ -17,8 +17,8 @@ //! FG storage lands in Phase 1c alongside `cell_text` shader port. use metal::{ - Buffer, CompileOptions, Device, MTLBlendFactor, MTLBlendOperation, MTLPixelFormat, - MTLPrimitiveType, MTLRegion, MTLResourceOptions, MTLTextureUsage, + Buffer, CommandQueue, CompileOptions, Device, MTLBlendFactor, MTLBlendOperation, + MTLPixelFormat, MTLPrimitiveType, MTLRegion, MTLResourceOptions, MTLTextureUsage, MTLVertexFormat, MTLVertexStepFunction, RenderCommandEncoderRef, RenderPipelineDescriptor, RenderPipelineState, Texture, TextureDescriptor, VertexDescriptor, @@ -39,13 +39,17 @@ const FRAMES_IN_FLIGHT: usize = 3; /// non-block-style cursor at the tail). const CURSOR_ROW_SLOTS: usize = 2; -/// Side of the square grayscale atlas texture. 2048² @ R8 = 4 MiB, -/// enough for a few thousand glyphs before we need multi-atlas -/// support. Ghostty starts at the same size and grows (`generic.zig` -/// in atlas/texture management). For now we're one-and-done — glyph -/// churn past 4 MiB is a Phase 2+ concern. +/// Initial square atlas texture side. 2048² @ R8 = 4 MiB, grown to +/// 4096² / 8192² on demand when the allocator reports full (see +/// `MetalGlyphAtlas::grow`). Mirrors Ghostty's `atlas.grow` in +/// `ghostty/src/font/Atlas.zig`. const ATLAS_SIZE: u16 = 2048; +/// Hard cap on atlas side — Metal textures support 16384² on Apple +/// Silicon but 8192² is the safe floor across Intel Mac + discrete +/// GPUs. Beyond this we'd need a multi-atlas strategy. +const ATLAS_MAX_SIZE: u16 = 8192; + /// Glyph atlas for grayscale OR color glyphs. A single instance /// holds one `MTLTexture`, an allocator, and the key→slot map; the /// `bytes_per_pixel` field lets the same struct serve both paths @@ -58,6 +62,10 @@ pub struct MetalGlyphAtlas { allocator: AtlasAllocator, slots: FxHashMap, bytes_per_pixel: u32, + format: MTLPixelFormat, + /// Persist for `set_label` on the grown texture so Xcode's GPU + /// debugger still identifies it after a grow. + label: &'static str, } impl MetalGlyphAtlas { @@ -76,23 +84,68 @@ impl MetalGlyphAtlas { device: &Device, format: MTLPixelFormat, bytes_per_pixel: u32, - label: &str, + label: &'static str, ) -> Self { - let descriptor = TextureDescriptor::new(); - descriptor.set_width(ATLAS_SIZE as u64); - descriptor.set_height(ATLAS_SIZE as u64); - descriptor.set_pixel_format(format); - descriptor.set_storage_mode(metal::MTLStorageMode::Managed); - descriptor.set_usage(MTLTextureUsage::ShaderRead); - let texture = device.new_texture(&descriptor); - texture.set_label(label); + let texture = create_atlas_texture(device, format, ATLAS_SIZE, label); Self { texture, allocator: AtlasAllocator::new(ATLAS_SIZE, ATLAS_SIZE), slots: FxHashMap::default(), bytes_per_pixel, + format, + label, + } + } + + /// Double the atlas texture + allocator dimensions, copying old + /// texel data into the top-left of the new texture via a blit. + /// Existing `AtlasSlot`s stay valid because their `(x, y)` fall + /// inside the unchanged old region. Returns `false` if the atlas + /// is already at `ATLAS_MAX_SIZE` (caller must handle the failure + /// — there's no eviction). + pub fn grow(&mut self, device: &Device, queue: &CommandQueue) -> bool { + let (old_w, old_h) = self.allocator.dimensions(); + if old_w >= ATLAS_MAX_SIZE { + return false; + } + let new_size = old_w.saturating_mul(2).min(ATLAS_MAX_SIZE); + if new_size <= old_w { + return false; } + + let new_texture = + create_atlas_texture(device, self.format, new_size, self.label); + + // Blit the old texture into the top-left of the new one. + // Slots are still addressed by their original (x, y) so we + // don't touch the allocator's shelf layout, just its bounds. + let cmd_buffer = queue.new_command_buffer(); + let blit = cmd_buffer.new_blit_command_encoder(); + blit.copy_from_texture( + &self.texture, + 0, + 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + metal::MTLSize { + width: old_w as u64, + height: old_h as u64, + depth: 1, + }, + &new_texture, + 0, + 0, + metal::MTLOrigin { x: 0, y: 0, z: 0 }, + ); + blit.end_encoding(); + cmd_buffer.commit(); + // Wait so subsequent `replace_region` writes to the new + // texture don't race the blit. + cmd_buffer.wait_until_completed(); + + self.texture = new_texture; + self.allocator.grow_to(new_size, new_size); + true } #[inline] @@ -161,8 +214,29 @@ impl MetalGlyphAtlas { } } +fn create_atlas_texture( + device: &Device, + format: MTLPixelFormat, + size: u16, + label: &str, +) -> Texture { + let descriptor = TextureDescriptor::new(); + descriptor.set_width(size as u64); + descriptor.set_height(size as u64); + descriptor.set_pixel_format(format); + descriptor.set_storage_mode(metal::MTLStorageMode::Managed); + descriptor.set_usage(MTLTextureUsage::ShaderRead); + let texture = device.new_texture(&descriptor); + texture.set_label(label); + texture +} + pub struct MetalGridRenderer { device: Device, + /// Needed for atlas-grow blits. Keeping a handle lets us submit + /// a one-off command buffer without threading the queue through + /// every emit-time call site. + command_queue: CommandQueue, /// Current grid size (cells). cols: u32, @@ -230,6 +304,7 @@ pub struct MetalGridRenderer { impl MetalGridRenderer { pub fn new(ctx: &MetalContext, cols: u32, rows: u32) -> Self { let device = ctx.device.to_owned(); + let command_queue = ctx.command_queue.to_owned(); let bg_buffers = std::array::from_fn(|_| alloc_bg_buffer(&device, cols, rows)); let initial_fg_capacity = (cols as usize) * (rows as usize).max(1); let fg_buffers = @@ -243,6 +318,7 @@ impl MetalGridRenderer { Self { device, + command_queue, cols, rows, bg_buffers, @@ -274,13 +350,23 @@ impl MetalGridRenderer { self.atlas_grayscale.lookup(key) } - /// Pack + upload a grayscale rasterized glyph. + /// Pack + upload a grayscale rasterized glyph. On atlas-full, + /// grows the atlas (doubles the texture, blits old texels into + /// the top-left) and retries once. Returns `None` only if the + /// atlas is at `ATLAS_MAX_SIZE` and still can't fit the glyph. pub fn insert_glyph( &mut self, key: GlyphKey, glyph: RasterizedGlyph<'_>, ) -> Option { - self.atlas_grayscale.insert(key, glyph) + if let Some(slot) = self.atlas_grayscale.insert(key, glyph) { + return Some(slot); + } + if self.atlas_grayscale.grow(&self.device, &self.command_queue) { + self.atlas_grayscale.insert(key, glyph) + } else { + None + } } /// Lookup a glyph in the color atlas. @@ -289,12 +375,20 @@ impl MetalGridRenderer { } /// Pack + upload a color (RGBA8-premultiplied) rasterized glyph. + /// Same grow-on-full behaviour as `insert_glyph`. pub fn insert_glyph_color( &mut self, key: GlyphKey, glyph: RasterizedGlyph<'_>, ) -> Option { - self.atlas_color.insert(key, glyph) + if let Some(slot) = self.atlas_color.insert(key, glyph) { + return Some(slot); + } + if self.atlas_color.grow(&self.device, &self.command_queue) { + self.atlas_color.insert(key, glyph) + } else { + None + } } pub fn resize(&mut self, cols: u32, rows: u32) { diff --git a/sugarloaf/src/renderer/image_cache/atlas.rs b/sugarloaf/src/renderer/image_cache/atlas.rs index 0644e457..29177023 100644 --- a/sugarloaf/src/renderer/image_cache/atlas.rs +++ b/sugarloaf/src/renderer/image_cache/atlas.rs @@ -145,6 +145,17 @@ impl AtlasAllocator { (self.width, self.height) } + /// Grow the atlas's free region to new dimensions. Existing shelf + /// coordinates (both x and y) stay valid because they're in the + /// top-left of the old region; the new area is appended on the + /// right and bottom. Caller is responsible for growing the backing + /// texture in lock-step. + pub fn grow_to(&mut self, new_width: u16, new_height: u16) { + debug_assert!(new_width >= self.width && new_height >= self.height); + self.width = new_width; + self.height = new_height; + } + /// Deallocates a rectangle (simplified - in practice this is complex) pub fn deallocate(&mut self, _x: u16, _y: u16, _width: u16) { // For now, we don't implement deallocation as it's complex diff --git a/sugarloaf/src/sugarloaf.rs b/sugarloaf/src/sugarloaf.rs index d07f955e..a4e9b2a0 100644 --- a/sugarloaf/src/sugarloaf.rs +++ b/sugarloaf/src/sugarloaf.rs @@ -485,6 +485,19 @@ impl Sugarloaf<'_> { self.state.content.get_text_by_id(id) } + /// Device-pixel font size for the given rich-text id. This is + /// `layout.font_size * scale_factor` — the size glyphs should be + /// rasterized at. Mirrors per-text zoom (set via + /// `set_text_font_size_action`) so each panel can carry its own + /// size. Returns None for non-text ids or missing ids. + #[inline] + pub fn text_scaled_font_size(&self, id: &usize) -> Option { + self.state + .content + .get_text_by_id(*id) + .map(|s| s.scaled_font_size) + } + #[inline] pub fn build_text_by_id(&mut self, id: usize) { self.state.content().sel(id).build(); -- 2.51.2