From 972d64946a2af88857699eb9cbe0ba28057b522b Mon Sep 17 00:00:00 2001 From: Anton Suprunchuk Date: Sat, 30 Aug 2025 19:48:49 -0300 Subject: [PATCH 01/14] add text rasterization into a texture --- Cargo.lock | 14 ++-- Cargo.toml | 3 +- examples/text.rs | 162 ++++++++++++++++++++++++++++++++++++++++++----- src/style.rs | 2 +- 4 files changed, 154 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c995347..455500e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -467,9 +467,9 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "easy-tree" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b81fe01e50aceec379af69772402529cfba1a5d53266b0bace467811e395b4f" +checksum = "05fda4988f15130ba24748b5d359fcd2f588a0896b2f4c6947ce36b3f779e496" [[package]] name = "env_filter" @@ -809,16 +809,14 @@ dependencies = [ [[package]] name = "grafo" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24959ca18babe742a401befb3af2e2e2bfa3fecc0c23c8722d17e981ea0f044e" +version = "0.8.1" dependencies = [ "ahash", "bytemuck", "easy-tree", "glyphon", "log", - "lru 0.14.0", + "lru 0.15.0", "lyon", "wgpu", ] @@ -1043,9 +1041,9 @@ checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" [[package]] name = "lru" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f8cc7106155f10bdf99a6f379688f543ad6596a415375b36a59a054ceda1198" +checksum = "0281c2e25e62316a5c9d98f2d2e9e95a37841afdaf4383c177dbb5c1dfab0568" dependencies = [ "hashbrown", ] diff --git a/Cargo.toml b/Cargo.toml index bb0bfda..3a99146 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,8 @@ smol_str = "0.3" serde = { version = "1.0.219", features = ["derive"], optional = true } [dev-dependencies] -grafo = "0.7" +#grafo = "0.7" +grafo = { path = "../grafo" } winit = "0.30" futures = "0.3" env_logger = "0.11" diff --git a/examples/text.rs b/examples/text.rs index 4692275..af0dd76 100644 --- a/examples/text.rs +++ b/examples/text.rs @@ -22,6 +22,8 @@ struct App<'a> { text_content: String, cursor_position: usize, text_manager: TextManager, + // Texture id for the rendered text for the renderer + text_texture_id: u64, } impl<'a> Default for App<'a> { @@ -39,6 +41,7 @@ impl<'a> App<'a> { text_content: "Welcome to Protextinator!\n\nThis example demonstrates the integration of:\n• Protextinator - for advanced text management and caching\n• Grafo 0.6 - for GPU-accelerated rendering\n• Winit 0.30 - for cross-platform windowing\n\nKey features being showcased:\n✓ Text shaping and layout via cosmic-text\n✓ Efficient text buffer caching\n✓ Direct buffer rendering with add_text_buffer()\n✓ Real-time text editing and reshaping\n✓ Word wrapping and text styling\n\nTry typing to see the text management in action!\nNotice how protextinator efficiently caches and manages the text buffers.".to_string(), cursor_position: 0, text_manager: TextManager::new(), + text_texture_id: 123, } } fn setup_renderer(&mut self, event_loop: &ActiveEventLoop) { @@ -151,16 +154,51 @@ impl<'a> App<'a> { max: (text_rect.max.x, text_rect.max.y).into(), }; + let text_area_size = text_area.size(); + + // TODO: move to into the app initialization, not each frame + let mut texture = vec![0u8; (text_area_size.width as u32 * text_area_size.height as u32 * 4) as usize]; + + rasterize_text_into_pixels( + self.font_system.as_mut().unwrap(), + &mut self.text_manager.text_context.swash_cache, + renderer.scale_factor() as f32, + (text_area_size.width, text_area_size.height), + &mut texture, + (text_area_size.width as u32, text_area_size.height as u32), + ); + + // TODO: don't allocate each frame, only reallocate when text area size changes + renderer.texture_manager().allocate_texture_with_data( + self.text_texture_id, + (text_area_size.width as u32, text_area_size.height as u32), + &texture + ); + + // TODO: cache shapes + let text_shape_id = renderer.add_shape( + Shape::rect( + [(0.0, 0.0), (text_area_size.width, text_area_size.height)], + Color::TRANSPARENT, + Stroke::new(0.0, Color::TRANSPARENT), + ), + None, + (text_rect.min.x, text_rect.min.y), + // TODO: that's not an actual cache key, but it's fine for now + Some(self.text_texture_id), + ); + + renderer.set_shape_texture(text_shape_id, Some(self.text_texture_id)); // Use grafo's add_text_buffer with protextinator's shaped buffer // This is the perfect integration of both libraries! - renderer.add_text_buffer( - buffer, // The cosmic-text buffer from protextinator - text_area, // Area to render in - Color::rgb(229, 229, 229), // Fallback color - 0.0, // Vertical offset - text_id.0 as usize, // Buffer ID (must match the metadata in buffer) - None, // No clipping - ); + // renderer.add_text_buffer( + // buffer, // The cosmic-text buffer from protextinator + // text_area, // Area to render in + // Color::rgb(229, 229, 229), // Fallback color + // 0.0, // Vertical offset + // text_id.0 as usize, // Buffer ID (must match the metadata in buffer) + // None, // No clipping + // ); } // Add a simple cursor indicator @@ -223,14 +261,15 @@ impl<'a> App<'a> { max: (stats_rect.max.x, stats_rect.max.y).into(), }; - renderer.add_text_buffer( - stats_buffer, - stats_area, - Color::rgb(97, 175, 239), - 0.0, - stats_id.0 as usize, - None, - ); + // TODO: fix dis, load text the same way as main text using a texture + // renderer.add_text_buffer( + // stats_buffer, + // stats_area, + // Color::rgb(97, 175, 239), + // 0.0, + // stats_id.0 as usize, + // None, + // ); } } @@ -318,6 +357,95 @@ impl<'a> ApplicationHandler for App<'a> { } } +fn rasterize_text_into_pixels( + font_system: &mut cosmic_text::FontSystem, + swash_cache: &mut cosmic_text::SwashCache, + scale_factor: f32, + logical_size: (f32, f32), + pixels: &mut [u8], // RGBA8 sRGB + dims: (u32, u32), +) { + let (tex_w, tex_h) = dims; + debug_assert_eq!(pixels.len(), (tex_w * tex_h * 4) as usize); + + // Clear to transparent + for px in pixels.chunks_exact_mut(4) { + px[0] = 0; + px[1] = 0; + px[2] = 0; + px[3] = 0; + } + + // Create and shape the buffer + let font_size = 24.0; + let line_height = 1.4 * font_size; + let metrics = cosmic_text::Metrics::new(font_size, line_height); + let mut buffer = cosmic_text::Buffer::new(font_system, metrics); + + let text = "Text-to-texture via cosmic_text::Buffer\nWith CPU raster + premultiplied alpha"; + buffer.set_wrap(font_system, cosmic_text::Wrap::Word); + buffer.set_text( + font_system, + text, + &cosmic_text::Attrs::new() + .family(cosmic_text::Family::SansSerif) + .color(cosmic_text::Color::rgb(255, 255, 255)), + cosmic_text::Shaping::Advanced, + ); + + // Buffer size is in device pixels, so scale logical size by scale factor + buffer.set_size( + font_system, + Some(logical_size.0 * scale_factor), + Some(logical_size.1 * scale_factor), + ); + buffer.shape_until_scroll(font_system, true); + + // Use Buffer::draw to iterate painted rects and alpha-blend into our pixel buffer + // Base color is white; spans can still carry their own colors if set + let base_color = cosmic_text::Color::rgb(255, 255, 255); + buffer.draw(font_system, swash_cache, base_color, |x, y, w, h, color| { + // Clip to buffer bounds + let (x0, y0) = (x.max(0) as u32, y.max(0) as u32); + let mut w = w; + let mut h = h; + if x0 >= tex_w || y0 >= tex_h || w == 0 || h == 0 { + return; + } + if x0 + w > tex_w { + w = tex_w - x0; + } + if y0 + h > tex_h { + h = tex_h - y0; + } + + let [r, g, b, a] = color.as_rgba(); + let src_a = a as f32 / 255.0; + for row in 0..h { + let dst_row_start = ((y0 + row) * tex_w * 4 + x0 * 4) as usize; + let row_slice = &mut pixels[dst_row_start..dst_row_start + (w as usize) * 4]; + for px in row_slice.chunks_exact_mut(4) { + let dst_r = px[0] as f32 / 255.0; + let dst_g = px[1] as f32 / 255.0; + let dst_b = px[2] as f32 / 255.0; + let dst_a = px[3] as f32 / 255.0; + + // Straight alpha blend in sRGB space (good enough for example) + let out_a = src_a + dst_a * (1.0 - src_a); + let inv = if out_a > 0.0 { 1.0 - src_a } else { 0.0 }; + let out_r = (r as f32 / 255.0) * src_a + dst_r * inv; + let out_g = (g as f32 / 255.0) * src_a + dst_g * inv; + let out_b = (b as f32 / 255.0) * src_a + dst_b * inv; + + px[0] = (out_r.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; + px[1] = (out_g.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; + px[2] = (out_b.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; + px[3] = (out_a.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; + } + } + }); +} + fn main() { env_logger::init(); @@ -328,4 +456,4 @@ fn main() { if let Err(e) = event_loop.run_app(&mut app) { eprintln!("Event loop error: {e:?}"); } -} +} \ No newline at end of file diff --git a/src/style.rs b/src/style.rs index f976166..e02f6d8 100644 --- a/src/style.rs +++ b/src/style.rs @@ -324,7 +324,7 @@ impl FontFamily { /// Converts this font family to a [`cosmic_text::Family`] for use with the text engine. /// /// This is used internally by the text rendering system. - pub fn to_fontdb_family(&self) -> Family { + pub fn to_fontdb_family(&self) -> Family<'_> { match self { FontFamily::Name(a) => Family::Name(a), FontFamily::SansSerif => Family::SansSerif, From 3335069ee4311f967be4653440b259cc374bea95 Mon Sep 17 00:00:00 2001 From: Anton Suprunchuk Date: Sat, 30 Aug 2025 21:59:50 -0300 Subject: [PATCH 02/14] make rasterization work --- examples/text.rs | 117 +++++++++++++++++++++++++++-------------------- 1 file changed, 67 insertions(+), 50 deletions(-) diff --git a/examples/text.rs b/examples/text.rs index af0dd76..b131373 100644 --- a/examples/text.rs +++ b/examples/text.rs @@ -6,6 +6,8 @@ use protextinator::style::{ }; use protextinator::{cosmic_text::FontSystem, Id, Point, Rect, TextManager}; use std::sync::Arc; +use std::time::Instant; +use cosmic_text::Buffer; use winit::{ application::ApplicationHandler, event::{ElementState, KeyEvent, WindowEvent}, @@ -24,6 +26,8 @@ struct App<'a> { text_manager: TextManager, // Texture id for the rendered text for the renderer text_texture_id: u64, + // Track allocated texture size to avoid reallocating each frame + text_texture_dimenstions: Option<(u32, u32)>, } impl<'a> Default for App<'a> { @@ -42,6 +46,7 @@ impl<'a> App<'a> { cursor_position: 0, text_manager: TextManager::new(), text_texture_id: 123, + text_texture_dimenstions: None, } } fn setup_renderer(&mut self, event_loop: &ActiveEventLoop) { @@ -68,6 +73,8 @@ impl<'a> App<'a> { false, // transparent )); + // Renderer receives the initial scale factor at creation time. + // Initialize text systems let font_system = FontSystem::new(); @@ -147,7 +154,7 @@ impl<'a> App<'a> { text_state.recalculate(&mut self.text_manager.text_context); // Now here's the key part: use protextinator's buffer with grafo's add_text_buffer! - let buffer = &text_state.buffer(); + let _buffer = &text_state.buffer(); // Define the area where the text should be rendered let text_area = MathRect { min: (text_rect.min.x, text_rect.min.y).into(), @@ -155,24 +162,48 @@ impl<'a> App<'a> { }; let text_area_size = text_area.size(); + let texture_dimensions = ( + text_area_size.width as u32, + text_area_size.height as u32, + ); - // TODO: move to into the app initialization, not each frame - let mut texture = vec![0u8; (text_area_size.width as u32 * text_area_size.height as u32 * 4) as usize]; + // Allocate or reallocate the texture only when size changes + if self.text_texture_dimenstions != Some(texture_dimensions) { + renderer + .texture_manager() + .allocate_texture(self.text_texture_id, texture_dimensions); + self.text_texture_dimenstions = Some(texture_dimensions); + } + // Rasterize into a CPU buffer every frame (to measure rasterization cost) + let mut texture = vec![0u8; (texture_dimensions.0 * texture_dimensions.1 * 4) as usize]; + + let t_raster_start = Instant::now(); rasterize_text_into_pixels( self.font_system.as_mut().unwrap(), &mut self.text_manager.text_context.swash_cache, renderer.scale_factor() as f32, (text_area_size.width, text_area_size.height), &mut texture, - (text_area_size.width as u32, text_area_size.height as u32), + texture_dimensions, + &text_state.buffer() ); + let raster_time = t_raster_start.elapsed(); + + let t_upload_start = Instant::now(); + match renderer + .texture_manager() + .load_data_into_texture(self.text_texture_id, texture_dimensions, &texture) + { + Ok(_) => {} + Err(err) => eprintln!("Failed to load text texture data: {err:?}"), + } + let upload_time = t_upload_start.elapsed(); - // TODO: don't allocate each frame, only reallocate when text area size changes - renderer.texture_manager().allocate_texture_with_data( - self.text_texture_id, - (text_area_size.width as u32, text_area_size.height as u32), - &texture + println!( + "rasterize: {} µs, load_texture: {} µs", + raster_time.as_micros(), + upload_time.as_micros() ); // TODO: cache shapes @@ -189,16 +220,6 @@ impl<'a> App<'a> { ); renderer.set_shape_texture(text_shape_id, Some(self.text_texture_id)); - // Use grafo's add_text_buffer with protextinator's shaped buffer - // This is the perfect integration of both libraries! - // renderer.add_text_buffer( - // buffer, // The cosmic-text buffer from protextinator - // text_area, // Area to render in - // Color::rgb(229, 229, 229), // Fallback color - // 0.0, // Vertical offset - // text_id.0 as usize, // Buffer ID (must match the metadata in buffer) - // None, // No clipping - // ); } // Add a simple cursor indicator @@ -255,8 +276,8 @@ impl<'a> App<'a> { stats_text_state.recalculate(&mut self.text_manager.text_context); // Render stats using add_text_buffer as well - let stats_buffer = &stats_text_state.buffer(); - let stats_area = MathRect { + let _stats_buffer = &stats_text_state.buffer(); + let _stats_area = MathRect { min: (stats_rect.min.x, stats_rect.min.y).into(), max: (stats_rect.max.x, stats_rect.max.y).into(), }; @@ -302,6 +323,7 @@ impl<'a> ApplicationHandler for App<'a> { event_loop.exit(); } WindowEvent::Resized(physical_size) => { + println!("Resized to {:?}", physical_size); if let Some(renderer) = &mut self.renderer { let new_size = (physical_size.width, physical_size.height); renderer.resize(new_size); @@ -310,6 +332,25 @@ impl<'a> ApplicationHandler for App<'a> { window.request_redraw(); } } + WindowEvent::ScaleFactorChanged { scale_factor, .. } => { + println!("Scale factor changed: {}", scale_factor); + if let Some(window) = &self.window { + let size = window.inner_size(); + let physical_size = (size.width, size.height); + // Recreate renderer with the new scale factor + let new_renderer = block_on(Renderer::new( + window.clone(), + physical_size, + scale_factor, + true, + false, + )); + self.renderer = Some(new_renderer); + // Force texture reallocation next frame if needed + self.text_texture_dimenstions = None; + window.request_redraw(); + } + } WindowEvent::RedrawRequested => { self.render_frame(); } @@ -360,12 +401,13 @@ impl<'a> ApplicationHandler for App<'a> { fn rasterize_text_into_pixels( font_system: &mut cosmic_text::FontSystem, swash_cache: &mut cosmic_text::SwashCache, - scale_factor: f32, - logical_size: (f32, f32), + _scale_factor: f32, + _logical_size: (f32, f32), pixels: &mut [u8], // RGBA8 sRGB - dims: (u32, u32), + dimensions: (u32, u32), + buffer: &Buffer ) { - let (tex_w, tex_h) = dims; + let (tex_w, tex_h) = dimensions; debug_assert_eq!(pixels.len(), (tex_w * tex_h * 4) as usize); // Clear to transparent @@ -376,31 +418,6 @@ fn rasterize_text_into_pixels( px[3] = 0; } - // Create and shape the buffer - let font_size = 24.0; - let line_height = 1.4 * font_size; - let metrics = cosmic_text::Metrics::new(font_size, line_height); - let mut buffer = cosmic_text::Buffer::new(font_system, metrics); - - let text = "Text-to-texture via cosmic_text::Buffer\nWith CPU raster + premultiplied alpha"; - buffer.set_wrap(font_system, cosmic_text::Wrap::Word); - buffer.set_text( - font_system, - text, - &cosmic_text::Attrs::new() - .family(cosmic_text::Family::SansSerif) - .color(cosmic_text::Color::rgb(255, 255, 255)), - cosmic_text::Shaping::Advanced, - ); - - // Buffer size is in device pixels, so scale logical size by scale factor - buffer.set_size( - font_system, - Some(logical_size.0 * scale_factor), - Some(logical_size.1 * scale_factor), - ); - buffer.shape_until_scroll(font_system, true); - // Use Buffer::draw to iterate painted rects and alpha-blend into our pixel buffer // Base color is white; spans can still carry their own colors if set let base_color = cosmic_text::Color::rgb(255, 255, 255); From 8cb3a2295af9c88f9b7622999a704f63e4ac085c Mon Sep 17 00:00:00 2001 From: Anton Suprunchuk Date: Sun, 31 Aug 2025 23:51:56 -0300 Subject: [PATCH 03/14] add set_scale factor to the TextState --- examples/text.rs | 47 ++++++++++++++++++++++++++++++--------------- src/buffer_utils.rs | 20 ++++++++++++------- src/state.rs | 5 +++++ src/style.rs | 4 +++- src/text_manager.rs | 19 ++++++++++++++++++ src/text_params.rs | 23 +++++++++++++++++++++- 6 files changed, 93 insertions(+), 25 deletions(-) diff --git a/examples/text.rs b/examples/text.rs index b131373..1e20c3e 100644 --- a/examples/text.rs +++ b/examples/text.rs @@ -7,7 +7,6 @@ use protextinator::style::{ use protextinator::{cosmic_text::FontSystem, Id, Point, Rect, TextManager}; use std::sync::Arc; use std::time::Instant; -use cosmic_text::Buffer; use winit::{ application::ApplicationHandler, event::{ElementState, KeyEvent, WindowEvent}, @@ -73,7 +72,7 @@ impl<'a> App<'a> { false, // transparent )); - // Renderer receives the initial scale factor at creation time. + // Renderer receives the initial scale factor at creation time. // Initialize text systems let font_system = FontSystem::new(); @@ -81,6 +80,10 @@ impl<'a> App<'a> { self.window = Some(window); self.renderer = Some(renderer); self.font_system = Some(font_system); + // Ensure text manager uses the same scale factor for shaping + if let Some(r) = self.renderer.as_ref() { + self.text_manager.set_scale_factor(r.scale_factor() as f32); + } } fn handle_text_input(&mut self, text: &str) { @@ -148,6 +151,8 @@ impl<'a> App<'a> { // Get the text state and reshape if needed if let Some(text_state) = self.text_manager.text_states.get_mut(&text_id) { text_state.set_text(&self.text_content); + + // Keep font sizes and outer sizes in logical pixels. We pass scale to the manager instead. text_state.set_outer_size(&text_rect.size().into()); text_state.set_style(&text_style); text_state.set_buffer_metadata(text_id.0 as usize); @@ -162,9 +167,11 @@ impl<'a> App<'a> { }; let text_area_size = text_area.size(); + let scale_factor = renderer.scale_factor() as f32; + // Use device-pixel dimensions for the texture to avoid blurriness on HiDPI let texture_dimensions = ( - text_area_size.width as u32, - text_area_size.height as u32, + (text_area_size.width * scale_factor).ceil() as u32, + (text_area_size.height * scale_factor).ceil() as u32, ); // Allocate or reallocate the texture only when size changes @@ -176,25 +183,28 @@ impl<'a> App<'a> { } // Rasterize into a CPU buffer every frame (to measure rasterization cost) - let mut texture = vec![0u8; (texture_dimensions.0 * texture_dimensions.1 * 4) as usize]; + let mut texture = + vec![0u8; (texture_dimensions.0 * texture_dimensions.1 * 4) as usize]; let t_raster_start = Instant::now(); rasterize_text_into_pixels( self.font_system.as_mut().unwrap(), &mut self.text_manager.text_context.swash_cache, - renderer.scale_factor() as f32, + // Buffer is already shaped in device pixels; no draw-time scaling + 1.0, (text_area_size.width, text_area_size.height), &mut texture, texture_dimensions, - &text_state.buffer() + &text_state.buffer(), ); let raster_time = t_raster_start.elapsed(); let t_upload_start = Instant::now(); - match renderer - .texture_manager() - .load_data_into_texture(self.text_texture_id, texture_dimensions, &texture) - { + match renderer.texture_manager().load_data_into_texture( + self.text_texture_id, + texture_dimensions, + &texture, + ) { Ok(_) => {} Err(err) => eprintln!("Failed to load text texture data: {err:?}"), } @@ -346,6 +356,8 @@ impl<'a> ApplicationHandler for App<'a> { false, )); self.renderer = Some(new_renderer); + // Propagate scale to TextManager so buffers reshape in device pixels + self.text_manager.set_scale_factor(scale_factor as f32); // Force texture reallocation next frame if needed self.text_texture_dimenstions = None; window.request_redraw(); @@ -405,7 +417,7 @@ fn rasterize_text_into_pixels( _logical_size: (f32, f32), pixels: &mut [u8], // RGBA8 sRGB dimensions: (u32, u32), - buffer: &Buffer + buffer: &cosmic_text::Buffer, ) { let (tex_w, tex_h) = dimensions; debug_assert_eq!(pixels.len(), (tex_w * tex_h * 4) as usize); @@ -418,14 +430,17 @@ fn rasterize_text_into_pixels( px[3] = 0; } + // Note: We do not modify, clone, or create buffers here; we only draw using the provided one. + // For best sharpness, ensure the provided buffer is shaped for device pixels upstream. + // Use Buffer::draw to iterate painted rects and alpha-blend into our pixel buffer // Base color is white; spans can still carry their own colors if set let base_color = cosmic_text::Color::rgb(255, 255, 255); buffer.draw(font_system, swash_cache, base_color, |x, y, w, h, color| { // Clip to buffer bounds - let (x0, y0) = (x.max(0) as u32, y.max(0) as u32); - let mut w = w; - let mut h = h; + let (x0, y0) = ((x as u32).min(tex_w), (y as u32).min(tex_h)); + let mut w = w as u32; + let mut h = h as u32; if x0 >= tex_w || y0 >= tex_h || w == 0 || h == 0 { return; } @@ -473,4 +488,4 @@ fn main() { if let Err(e) = event_loop.run_app(&mut app) { eprintln!("Event loop error: {e:?}"); } -} \ No newline at end of file +} diff --git a/src/buffer_utils.rs b/src/buffer_utils.rs index c8a21ab..c803127 100644 --- a/src/buffer_utils.rs +++ b/src/buffer_utils.rs @@ -113,13 +113,15 @@ pub(crate) fn update_buffer( let metadata = params.metadata(); let old_scroll = buffer.scroll(); + let scale_factor = params.scale_factor(); buffer.set_metrics(font_system, params.metrics()); buffer.set_wrap(font_system, wrap.into()); // Setting vertical size to None means that the buffer will use the height of the text. // This is needed to ensue that glyphs can be scrolled vertically by smaller amounts than // the line height. - buffer.set_size(font_system, Some(text_area_size.x), None); + // Apply scale for shaping to device pixels + buffer.set_size(font_system, Some(text_area_size.x * scale_factor), None); buffer.set_text( font_system, @@ -137,8 +139,8 @@ pub(crate) fn update_buffer( for layout_line in line .layout( font_system, - text_style.font_size.value(), - Some(text_area_size.x), + text_style.font_size.value() * scale_factor, + Some(text_area_size.x * scale_factor), text_style.wrap.unwrap_or_default().into(), None, // TODO: what is the default tab width? Make it configurable? @@ -148,12 +150,12 @@ pub(crate) fn update_buffer( { buffer_measurement.y += layout_line .line_height_opt - .unwrap_or(text_style.line_height_pt()); + .unwrap_or(text_style.line_height_pt() * scale_factor); buffer_measurement.x = buffer_measurement.x.max(layout_line.w); } } - if buffer_measurement.x > text_area_size.x { + if buffer_measurement.x > text_area_size.x * scale_factor { // If the buffer is smaller than the text area, we need to set the width to the text area // size to ensure that the text is centered. // After we've measured the buffer, we need to run layout() again to realign the lines @@ -162,7 +164,7 @@ pub(crate) fn update_buffer( line.set_align(horizontal_alignment.into()); line.layout( font_system, - text_style.font_size.value(), + text_style.font_size.value() * scale_factor, Some(buffer_measurement.x), wrap.into(), None, @@ -173,5 +175,9 @@ pub(crate) fn update_buffer( } buffer.set_scroll(old_scroll); - buffer_measurement + // We shaped at device pixels; convert inner_dimensions back to logical for API + Size::from(( + buffer_measurement.x / scale_factor, + buffer_measurement.y / scale_factor, + )) } diff --git a/src/state.rs b/src/state.rs index fee9302..43b94b3 100644 --- a/src/state.rs +++ b/src/state.rs @@ -997,6 +997,11 @@ impl TextState { } } + /// Updates the internal scale factor in params; will trigger reshape on next recalc if changed. + pub fn set_scale_factor(&mut self, scale: f32) { + self.params.set_scale_factor(scale); + } + fn copy_selected_text(&mut self) -> ActionResult { let selected_text = self.selected_text().unwrap_or(""); ActionResult::TextCopied(selected_text.to_string()) diff --git a/src/style.rs b/src/style.rs index e02f6d8..6c3bef1 100644 --- a/src/style.rs +++ b/src/style.rs @@ -70,6 +70,8 @@ impl Eq for LineHeight {} /// /// Font size determines the height of characters in the text. /// Typical font sizes range from 8pt to 72pt, with 12pt-16pt being common for body text. +/// This is a logical size - to apply scaling based on DPI, use [`crate::TextState::set_scale_factor`] +/// for a specific state, or [`crate::TextManager::set_scale_factor`] to apply it to all states. #[cfg_attr(feature = "serialization", derive(Serialize, Deserialize))] #[derive(Debug, Clone, Copy, PartialEq)] pub struct FontSize(pub f32); @@ -339,7 +341,7 @@ impl FontFamily { /// Comprehensive text styling configuration. /// /// `TextStyle` combines all visual aspects of text rendering, including font properties, -/// colors, alignment and wrapping behavior +/// colors, alignment, and wrapping behavior #[cfg_attr(feature = "serialization", derive(Serialize, Deserialize))] #[derive(Debug, Clone, PartialEq, Eq)] pub struct TextStyle { diff --git a/src/text_manager.rs b/src/text_manager.rs index a2f46e3..4f904ea 100644 --- a/src/text_manager.rs +++ b/src/text_manager.rs @@ -18,6 +18,8 @@ pub struct TextContext { pub font_system: FontSystem, /// Cache for rendered glyphs to improve performance. pub swash_cache: SwashCache, + /// Current device scale factor. 1.0 means logical pixels; >1.0 means HiDPI. + pub scale_factor: f32, /// Tracks which text states are being used for garbage collection. pub usage_tracker: TextUsageTracker, } @@ -28,6 +30,7 @@ impl Default for TextContext { Self { font_system: FontSystem::new(), swash_cache: SwashCache::new(), + scale_factor: 1.0, usage_tracker: TextUsageTracker::new(), } } @@ -166,6 +169,22 @@ impl TextManager { self.text_states .retain(|id, _| accessed_states.contains(id)); } + + /// Sets the global scale factor used for shaping and rasterization. + /// This keeps `FontSize` and sizes in logical pixels while shaping in device pixels. + /// Call this when the window scale factor changes. + pub fn set_scale_factor(&mut self, scale: f32) { + let scale = scale.max(0.01); + if (self.text_context.scale_factor - scale).abs() < 0.0001 { + return; + } + self.text_context.scale_factor = scale; + // Update each state's params with new scale; they'll mark themselves changed. + for state in self.text_states.values_mut() { + // This will mark params changed if different and reshape on next recalc + state.set_scale_factor(scale); + } + } } impl TextContext { diff --git a/src/text_params.rs b/src/text_params.rs index f84c6b1..cd653e0 100644 --- a/src/text_params.rs +++ b/src/text_params.rs @@ -10,6 +10,9 @@ pub(crate) struct TextParams { text: String, metadata: usize, + // Device scale factor; 1.0 == logical pixels + scale_factor: f32, + changed: bool, line_terminator_has_been_added: bool, } @@ -22,6 +25,7 @@ impl TextParams { style, text: "".to_string(), metadata, + scale_factor: 1.0, changed: true, line_terminator_has_been_added: false, @@ -151,6 +155,23 @@ impl TextParams { #[inline(always)] pub fn metrics(&self) -> Metrics { - Metrics::new(self.style().font_size.0, self.style().line_height_pt()) + let scale = self.scale_factor; + let font_size = self.style().font_size.0 * scale; + let line_height = self.style().line_height_pt() * scale; + Metrics::new(font_size, line_height) + } + + #[inline(always)] + pub fn set_scale_factor(&mut self, scale: f32) { + let scale = scale.max(0.01); + if (self.scale_factor - scale).abs() > SIZE_EPSILON { + self.scale_factor = scale; + self.changed = true; + } + } + + #[inline(always)] + pub fn scale_factor(&self) -> f32 { + self.scale_factor } } From b73d7f1f154fae684cb1969f80b692df6b7ca802 Mon Sep 17 00:00:00 2001 From: Anton Suprunchuk Date: Mon, 1 Sep 2025 00:21:42 -0300 Subject: [PATCH 04/14] move rasterization to the text state --- examples/text.rs | 209 +++++++++++++------------------------------- src/lib.rs | 2 +- src/state.rs | 119 +++++++++++++++++++++++++ src/text_manager.rs | 15 ++++ 4 files changed, 196 insertions(+), 149 deletions(-) diff --git a/examples/text.rs b/examples/text.rs index 1e20c3e..8dd124d 100644 --- a/examples/text.rs +++ b/examples/text.rs @@ -4,7 +4,7 @@ use protextinator::style::{ FontColor, FontFamily, FontSize, HorizontalTextAlignment, LineHeight, TextStyle, TextWrap, VerticalTextAlignment, }; -use protextinator::{cosmic_text::FontSystem, Id, Point, Rect, TextManager}; +use protextinator::{Id, Point, Rect, TextManager}; use std::sync::Arc; use std::time::Instant; use winit::{ @@ -19,7 +19,6 @@ use winit::{ struct App<'a> { window: Option>, renderer: Option>, - font_system: Option, text_content: String, cursor_position: usize, text_manager: TextManager, @@ -40,7 +39,6 @@ impl<'a> App<'a> { Self { window: None, renderer: None, - font_system: None, text_content: "Welcome to Protextinator!\n\nThis example demonstrates the integration of:\n• Protextinator - for advanced text management and caching\n• Grafo 0.6 - for GPU-accelerated rendering\n• Winit 0.30 - for cross-platform windowing\n\nKey features being showcased:\n✓ Text shaping and layout via cosmic-text\n✓ Efficient text buffer caching\n✓ Direct buffer rendering with add_text_buffer()\n✓ Real-time text editing and reshaping\n✓ Word wrapping and text styling\n\nTry typing to see the text management in action!\nNotice how protextinator efficiently caches and manages the text buffers.".to_string(), cursor_position: 0, text_manager: TextManager::new(), @@ -74,12 +72,8 @@ impl<'a> App<'a> { // Renderer receives the initial scale factor at creation time. - // Initialize text systems - let font_system = FontSystem::new(); - self.window = Some(window); self.renderer = Some(renderer); - self.font_system = Some(font_system); // Ensure text manager uses the same scale factor for shaping if let Some(r) = self.renderer.as_ref() { self.text_manager.set_scale_factor(r.scale_factor() as f32); @@ -157,79 +151,6 @@ impl<'a> App<'a> { text_state.set_style(&text_style); text_state.set_buffer_metadata(text_id.0 as usize); text_state.recalculate(&mut self.text_manager.text_context); - - // Now here's the key part: use protextinator's buffer with grafo's add_text_buffer! - let _buffer = &text_state.buffer(); - // Define the area where the text should be rendered - let text_area = MathRect { - min: (text_rect.min.x, text_rect.min.y).into(), - max: (text_rect.max.x, text_rect.max.y).into(), - }; - - let text_area_size = text_area.size(); - let scale_factor = renderer.scale_factor() as f32; - // Use device-pixel dimensions for the texture to avoid blurriness on HiDPI - let texture_dimensions = ( - (text_area_size.width * scale_factor).ceil() as u32, - (text_area_size.height * scale_factor).ceil() as u32, - ); - - // Allocate or reallocate the texture only when size changes - if self.text_texture_dimenstions != Some(texture_dimensions) { - renderer - .texture_manager() - .allocate_texture(self.text_texture_id, texture_dimensions); - self.text_texture_dimenstions = Some(texture_dimensions); - } - - // Rasterize into a CPU buffer every frame (to measure rasterization cost) - let mut texture = - vec![0u8; (texture_dimensions.0 * texture_dimensions.1 * 4) as usize]; - - let t_raster_start = Instant::now(); - rasterize_text_into_pixels( - self.font_system.as_mut().unwrap(), - &mut self.text_manager.text_context.swash_cache, - // Buffer is already shaped in device pixels; no draw-time scaling - 1.0, - (text_area_size.width, text_area_size.height), - &mut texture, - texture_dimensions, - &text_state.buffer(), - ); - let raster_time = t_raster_start.elapsed(); - - let t_upload_start = Instant::now(); - match renderer.texture_manager().load_data_into_texture( - self.text_texture_id, - texture_dimensions, - &texture, - ) { - Ok(_) => {} - Err(err) => eprintln!("Failed to load text texture data: {err:?}"), - } - let upload_time = t_upload_start.elapsed(); - - println!( - "rasterize: {} µs, load_texture: {} µs", - raster_time.as_micros(), - upload_time.as_micros() - ); - - // TODO: cache shapes - let text_shape_id = renderer.add_shape( - Shape::rect( - [(0.0, 0.0), (text_area_size.width, text_area_size.height)], - Color::TRANSPARENT, - Stroke::new(0.0, Color::TRANSPARENT), - ), - None, - (text_rect.min.x, text_rect.min.y), - // TODO: that's not an actual cache key, but it's fine for now - Some(self.text_texture_id), - ); - - renderer.set_shape_texture(text_shape_id, Some(self.text_texture_id)); } // Add a simple cursor indicator @@ -292,7 +213,7 @@ impl<'a> App<'a> { max: (stats_rect.max.x, stats_rect.max.y).into(), }; - // TODO: fix dis, load text the same way as main text using a texture + // TODO: in future, draw stats using a separate texture as well // renderer.add_text_buffer( // stats_buffer, // stats_area, @@ -304,6 +225,64 @@ impl<'a> App<'a> { } } + // Rasterize all text states into CPU textures + let t_raster_start = Instant::now(); + self.text_manager.rasterize_all_textures(); + let raster_time = t_raster_start.elapsed(); + + // Upload main text texture and draw + if let Some(text_state) = self.text_manager.text_states.get(&text_id) { + if let Some(rt) = text_state.rasterized_texture() { + let text_area_size = MathRect { + min: (text_rect.min.x, text_rect.min.y).into(), + max: (text_rect.max.x, text_rect.max.y).into(), + } + .size(); + + let texture_dimensions = (rt.width, rt.height); + + // Allocate or reallocate the texture only when size changes + if self.text_texture_dimenstions != Some(texture_dimensions) { + renderer + .texture_manager() + .allocate_texture(self.text_texture_id, texture_dimensions); + self.text_texture_dimenstions = Some(texture_dimensions); + } + + let t_upload_start = Instant::now(); + match renderer.texture_manager().load_data_into_texture( + self.text_texture_id, + texture_dimensions, + &rt.pixels, + ) { + Ok(_) => {} + Err(err) => eprintln!("Failed to load text texture data: {err:?}"), + } + let upload_time = t_upload_start.elapsed(); + + println!( + "rasterize: {} µs, load_texture: {} µs", + raster_time.as_micros(), + upload_time.as_micros() + ); + + // TODO: cache shapes + let text_shape_id = renderer.add_shape( + Shape::rect( + [(0.0, 0.0), (text_area_size.width, text_area_size.height)], + Color::TRANSPARENT, + Stroke::new(0.0, Color::TRANSPARENT), + ), + None, + (text_rect.min.x, text_rect.min.y), + // TODO: that's not an actual cache key, but it's fine for now + Some(self.text_texture_id), + ); + + renderer.set_shape_texture(text_shape_id, Some(self.text_texture_id)); + } + } + // Render the frame match renderer.render() { Ok(_) => {} @@ -410,73 +389,7 @@ impl<'a> ApplicationHandler for App<'a> { } } -fn rasterize_text_into_pixels( - font_system: &mut cosmic_text::FontSystem, - swash_cache: &mut cosmic_text::SwashCache, - _scale_factor: f32, - _logical_size: (f32, f32), - pixels: &mut [u8], // RGBA8 sRGB - dimensions: (u32, u32), - buffer: &cosmic_text::Buffer, -) { - let (tex_w, tex_h) = dimensions; - debug_assert_eq!(pixels.len(), (tex_w * tex_h * 4) as usize); - - // Clear to transparent - for px in pixels.chunks_exact_mut(4) { - px[0] = 0; - px[1] = 0; - px[2] = 0; - px[3] = 0; - } - - // Note: We do not modify, clone, or create buffers here; we only draw using the provided one. - // For best sharpness, ensure the provided buffer is shaped for device pixels upstream. - - // Use Buffer::draw to iterate painted rects and alpha-blend into our pixel buffer - // Base color is white; spans can still carry their own colors if set - let base_color = cosmic_text::Color::rgb(255, 255, 255); - buffer.draw(font_system, swash_cache, base_color, |x, y, w, h, color| { - // Clip to buffer bounds - let (x0, y0) = ((x as u32).min(tex_w), (y as u32).min(tex_h)); - let mut w = w as u32; - let mut h = h as u32; - if x0 >= tex_w || y0 >= tex_h || w == 0 || h == 0 { - return; - } - if x0 + w > tex_w { - w = tex_w - x0; - } - if y0 + h > tex_h { - h = tex_h - y0; - } - - let [r, g, b, a] = color.as_rgba(); - let src_a = a as f32 / 255.0; - for row in 0..h { - let dst_row_start = ((y0 + row) * tex_w * 4 + x0 * 4) as usize; - let row_slice = &mut pixels[dst_row_start..dst_row_start + (w as usize) * 4]; - for px in row_slice.chunks_exact_mut(4) { - let dst_r = px[0] as f32 / 255.0; - let dst_g = px[1] as f32 / 255.0; - let dst_b = px[2] as f32 / 255.0; - let dst_a = px[3] as f32 / 255.0; - - // Straight alpha blend in sRGB space (good enough for example) - let out_a = src_a + dst_a * (1.0 - src_a); - let inv = if out_a > 0.0 { 1.0 - src_a } else { 0.0 }; - let out_r = (r as f32 / 255.0) * src_a + dst_r * inv; - let out_g = (g as f32 / 255.0) * src_a + dst_g * inv; - let out_b = (b as f32 / 255.0) * src_a + dst_b * inv; - - px[0] = (out_r.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; - px[1] = (out_g.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; - px[2] = (out_b.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; - px[3] = (out_a.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; - } - } - }); -} +// Local rasterizer removed; textures are produced by TextManager fn main() { env_logger::init(); diff --git a/src/lib.rs b/src/lib.rs index 81e247d..a6ca795 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,5 +78,5 @@ pub use action::{Action, ActionResult}; pub use cosmic_text; pub use id::Id; pub use math::{Point, Rect}; -pub use state::{Selection, SelectionLine, TextState}; +pub use state::{RasterizedTexture, Selection, SelectionLine, TextState}; pub use text_manager::{TextContext, TextManager}; diff --git a/src/state.rs b/src/state.rs index 43b94b3..4db2489 100644 --- a/src/state.rs +++ b/src/state.rs @@ -23,6 +23,14 @@ use std::time::{Duration, Instant}; /// Size comparison epsilon for floating-point calculations. pub const SIZE_EPSILON: f32 = 0.0001; +/// CPU-side RGBA8 texture holding the rasterized contents of a text buffer. +#[derive(Debug, Clone)] +pub struct RasterizedTexture { + pub pixels: Vec, + pub width: u32, + pub height: u32, +} + /// Represents a single line of text selection with visual boundaries. /// /// Selection lines define the visual appearance of selected text, with start and end @@ -110,6 +118,9 @@ pub struct TextState { inner_dimensions: Size, buffer: Buffer, + // CPU-side cached rasterized texture of the current buffer (RGBA8, device pixels) + rasterized_texture: Option, + // Settings /// Can text be selected? pub is_selectable: bool, @@ -179,6 +190,8 @@ impl TextState { inner_dimensions: Size::ZERO, buffer: Buffer::new(font_system, metrics), + rasterized_texture: None, + metadata, } } @@ -455,6 +468,11 @@ impl TextState { &self.buffer } + /// Returns the last rasterized CPU texture if available. + pub fn rasterized_texture(&self) -> Option<&RasterizedTexture> { + self.rasterized_texture.as_ref() + } + /// Returns the length of the text in characters. Note that this is different from the /// string .len(), which returns the length in bytes. /// @@ -997,6 +1015,107 @@ impl TextState { } } + /// Rasterizes the current text buffer into an RGBA8 CPU texture using device-pixel dimensions. + /// + /// Returns true if rasterization was performed (and texture updated), false if skipped + /// (e.g., zero-sized target). + pub(crate) fn rasterize_into_texture(&mut self, ctx: &mut TextContext) -> bool { + // Compute device-pixel texture size from the logical outer size and scale factor + let size = self.outer_size(); + let scale = ctx.scale_factor.max(0.01); + let width = (size.x * scale).ceil().max(0.0) as u32; + let height = (size.y * scale).ceil().max(0.0) as u32; + if width == 0 || height == 0 { + self.rasterized_texture = None; + return false; + } + + let required_len = width as usize * height as usize * 4; + // Reuse allocation if dimensions match + let mut pixels = if let Some(rt) = &mut self.rasterized_texture { + if rt.width == width && rt.height == height { + // Clear existing buffer to transparent before drawing + for px in rt.pixels.chunks_exact_mut(4) { + px[0] = 0; + px[1] = 0; + px[2] = 0; + px[3] = 0; + } + std::mem::take(&mut rt.pixels) + } else { + vec![0u8; required_len] + } + } else { + vec![0u8; required_len] + }; + + // Safety check for size + if pixels.len() != required_len { + pixels.resize(required_len, 0); + } + + // Clear to transparent + for px in pixels.chunks_exact_mut(4) { + px[0] = 0; + px[1] = 0; + px[2] = 0; + px[3] = 0; + } + + let base_color = cosmic_text::Color::rgb(255, 255, 255); + let tex_w = width; + let tex_h = height; + self.buffer + .draw(&mut ctx.font_system, &mut ctx.swash_cache, base_color, |x, y, w, h, color| { + // Clip to buffer bounds + let (x0, y0) = ((x as u32).min(tex_w), (y as u32).min(tex_h)); + let mut w = w as u32; + let mut h = h as u32; + if x0 >= tex_w || y0 >= tex_h || w == 0 || h == 0 { + return; + } + if x0 + w > tex_w { + w = tex_w - x0; + } + if y0 + h > tex_h { + h = tex_h - y0; + } + + let [r, g, b, a] = color.as_rgba(); + let src_a = a as f32 / 255.0; + for row in 0..h { + let dst_row_start = ((y0 + row) * tex_w * 4 + x0 * 4) as usize; + let row_slice = &mut pixels[dst_row_start..dst_row_start + (w as usize) * 4]; + for px in row_slice.chunks_exact_mut(4) { + let dst_r = px[0] as f32 / 255.0; + let dst_g = px[1] as f32 / 255.0; + let dst_b = px[2] as f32 / 255.0; + let dst_a = px[3] as f32 / 255.0; + + let out_a = src_a + dst_a * (1.0 - src_a); + let inv = if out_a > 0.0 { 1.0 - src_a } else { 0.0 }; + let out_r = (r as f32 / 255.0) * src_a + dst_r * inv; + let out_g = (g as f32 / 255.0) * src_a + dst_g * inv; + let out_b = (b as f32 / 255.0) * src_a + dst_b * inv; + + px[0] = (out_r.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; + px[1] = (out_g.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; + px[2] = (out_b.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; + px[3] = (out_a.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; + } + } + }); + + // Store/replace texture + self.rasterized_texture = Some(RasterizedTexture { + pixels, + width, + height, + }); + + true + } + /// Updates the internal scale factor in params; will trigger reshape on next recalc if changed. pub fn set_scale_factor(&mut self, scale: f32) { self.params.set_scale_factor(scale); diff --git a/src/text_manager.rs b/src/text_manager.rs index 4f904ea..226092f 100644 --- a/src/text_manager.rs +++ b/src/text_manager.rs @@ -185,6 +185,21 @@ impl TextManager { state.set_scale_factor(scale); } } + + /// Rasterizes all text states into CPU-side RGBA textures and stores them in the states. + /// + /// This will recalculate the shaping/layout if needed prior to rasterization. + /// Currently runs on a single thread; the API is designed to be easily parallelized later. + pub fn rasterize_all_textures(&mut self) { + // In the future this can be parallelized by splitting the states into chunks and + // creating per-thread SwashCache/FontSystem references as needed. + for (_id, state) in self.text_states.iter_mut() { + // Ensure buffer is up to date + state.recalculate(&mut self.text_context); + // Rasterize into the state's texture storage + state.rasterize_into_texture(&mut self.text_context); + } + } } impl TextContext { From dca6a44a14e8fddb69d579c5f9d89f2a012d7a78 Mon Sep 17 00:00:00 2001 From: Anton Suprunchuk Date: Mon, 1 Sep 2025 00:49:25 -0300 Subject: [PATCH 05/14] add rasterization flag to not re-rasterize buffer that haven't changed --- examples/text.rs | 3 +- src/state.rs | 113 ++++++++++++++++++++++++++++------------------- 2 files changed, 70 insertions(+), 46 deletions(-) diff --git a/examples/text.rs b/examples/text.rs index 8dd124d..34fa666 100644 --- a/examples/text.rs +++ b/examples/text.rs @@ -232,7 +232,8 @@ impl<'a> App<'a> { // Upload main text texture and draw if let Some(text_state) = self.text_manager.text_states.get(&text_id) { - if let Some(rt) = text_state.rasterized_texture() { + let rt = text_state.rasterized_texture(); + if rt.width > 0 && rt.height > 0 { let text_area_size = MathRect { min: (text_rect.min.x, text_rect.min.y).into(), max: (text_rect.max.x, text_rect.max.y).into(), diff --git a/src/state.rs b/src/state.rs index 4db2489..92dfa52 100644 --- a/src/state.rs +++ b/src/state.rs @@ -119,7 +119,9 @@ pub struct TextState { buffer: Buffer, // CPU-side cached rasterized texture of the current buffer (RGBA8, device pixels) - rasterized_texture: Option, + rasterized_texture: RasterizedTexture, + // Whether raster content needs to be regenerated + raster_dirty: bool, // Settings /// Can text be selected? @@ -190,7 +192,8 @@ impl TextState { inner_dimensions: Size::ZERO, buffer: Buffer::new(font_system, metrics), - rasterized_texture: None, + rasterized_texture: RasterizedTexture { pixels: Vec::new(), width: 0, height: 0 }, + raster_dirty: true, metadata, } @@ -468,9 +471,9 @@ impl TextState { &self.buffer } - /// Returns the last rasterized CPU texture if available. - pub fn rasterized_texture(&self) -> Option<&RasterizedTexture> { - self.rasterized_texture.as_ref() + /// Returns the last rasterized CPU texture. + pub fn rasterized_texture(&self) -> &RasterizedTexture { + &self.rasterized_texture } /// Returns the length of the text in characters. Note that this is different from the @@ -818,7 +821,16 @@ impl TextState { new_scroll.vertical = scroll.y - accumulated_height; } - self.buffer.set_scroll(new_scroll); + // Apply only if changed + let old = self.buffer.scroll(); + if (old.horizontal - new_scroll.horizontal).abs() > SIZE_EPSILON + || (old.vertical - new_scroll.vertical).abs() > SIZE_EPSILON + || old.line != new_scroll.line + { + self.buffer.set_scroll(new_scroll); + // Any scroll change requires re-rasterization + self.raster_dirty = true; + } } /// Calculates physical selection area based on the selection start and end glyph indices @@ -853,7 +865,6 @@ impl TextState { }); } } - None } @@ -917,8 +928,12 @@ impl TextState { let text_area_size = self.params.size(); let vertical_scroll_to_align_text = calculate_vertical_offset(self.params.style(), text_area_size, self.inner_dimensions); - scroll.vertical = vertical_scroll_to_align_text; - self.buffer.set_scroll(scroll); + if (scroll.vertical - vertical_scroll_to_align_text).abs() > SIZE_EPSILON { + scroll.vertical = vertical_scroll_to_align_text; + self.buffer.set_scroll(scroll); + // Vertical alignment scroll change affects raster + self.raster_dirty = true; + } } /// Buffer needs to be shaped before calling this function, as it relies on the buffer's layout @@ -996,7 +1011,16 @@ impl TextState { // Do nothing? } - self.buffer.set_scroll(new_scroll); + // Apply only if changed + let old = self.buffer.scroll(); + if (old.horizontal - new_scroll.horizontal).abs() > SIZE_EPSILON + || (old.vertical - new_scroll.vertical).abs() > SIZE_EPSILON + || old.line != new_scroll.line + { + self.buffer.set_scroll(new_scroll); + // Scroll changes affect raster + self.raster_dirty = true; + } } None @@ -1012,6 +1036,8 @@ impl TextState { let new_size = update_buffer(&self.params, &mut self.buffer, &mut ctx.font_system); self.inner_dimensions = new_size; self.params.reset_changed(); + // Any layout/text/style/size change requires re-rasterization + self.raster_dirty = true; } } @@ -1026,36 +1052,30 @@ impl TextState { let width = (size.x * scale).ceil().max(0.0) as u32; let height = (size.y * scale).ceil().max(0.0) as u32; if width == 0 || height == 0 { - self.rasterized_texture = None; + // No room to rasterize; clear texture and mark clean + self.rasterized_texture.width = 0; + self.rasterized_texture.height = 0; + self.rasterized_texture.pixels.clear(); + self.raster_dirty = false; return false; } - let required_len = width as usize * height as usize * 4; - // Reuse allocation if dimensions match - let mut pixels = if let Some(rt) = &mut self.rasterized_texture { - if rt.width == width && rt.height == height { - // Clear existing buffer to transparent before drawing - for px in rt.pixels.chunks_exact_mut(4) { - px[0] = 0; - px[1] = 0; - px[2] = 0; - px[3] = 0; - } - std::mem::take(&mut rt.pixels) - } else { - vec![0u8; required_len] - } - } else { - vec![0u8; required_len] - }; + let dims_changed = + self.rasterized_texture.width != width || self.rasterized_texture.height != height; - // Safety check for size - if pixels.len() != required_len { - pixels.resize(required_len, 0); + // Skip if nothing changed and dimensions match + if !dims_changed && !self.raster_dirty { + return false; } - // Clear to transparent - for px in pixels.chunks_exact_mut(4) { + let required_len = width as usize * height as usize * 4; + // Ensure capacity and set length; reuse allocation when possible + if self.rasterized_texture.pixels.len() != required_len { + self.rasterized_texture.pixels.resize(required_len, 0); + } + + // Clear to transparent before drawing + for px in self.rasterized_texture.pixels.chunks_exact_mut(4) { px[0] = 0; px[1] = 0; px[2] = 0; @@ -1065,8 +1085,11 @@ impl TextState { let base_color = cosmic_text::Color::rgb(255, 255, 255); let tex_w = width; let tex_h = height; - self.buffer - .draw(&mut ctx.font_system, &mut ctx.swash_cache, base_color, |x, y, w, h, color| { + self.buffer.draw( + &mut ctx.font_system, + &mut ctx.swash_cache, + base_color, + |x, y, w, h, color| { // Clip to buffer bounds let (x0, y0) = ((x as u32).min(tex_w), (y as u32).min(tex_h)); let mut w = w as u32; @@ -1085,7 +1108,8 @@ impl TextState { let src_a = a as f32 / 255.0; for row in 0..h { let dst_row_start = ((y0 + row) * tex_w * 4 + x0 * 4) as usize; - let row_slice = &mut pixels[dst_row_start..dst_row_start + (w as usize) * 4]; + let row_slice = &mut self.rasterized_texture.pixels + [dst_row_start..dst_row_start + (w as usize) * 4]; for px in row_slice.chunks_exact_mut(4) { let dst_r = px[0] as f32 / 255.0; let dst_g = px[1] as f32 / 255.0; @@ -1104,14 +1128,13 @@ impl TextState { px[3] = (out_a.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; } } - }); - - // Store/replace texture - self.rasterized_texture = Some(RasterizedTexture { - pixels, - width, - height, - }); + }, + ); + + // Update texture dimensions and clear dirty flag + self.rasterized_texture.width = width; + self.rasterized_texture.height = height; + self.raster_dirty = false; true } From cb3d28bfec825770573fcf4636aa517df6fd2613 Mon Sep 17 00:00:00 2001 From: Anton Suprunchuk Date: Tue, 2 Sep 2025 18:33:04 -0300 Subject: [PATCH 06/14] make rasterize method to return rasterized textures in case they were updated --- examples/text.rs | 2 +- src/text_manager.rs | 33 +++++++++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/examples/text.rs b/examples/text.rs index 34fa666..f2ba412 100644 --- a/examples/text.rs +++ b/examples/text.rs @@ -129,7 +129,7 @@ impl<'a> App<'a> { let text_style = TextStyle { font_size: FontSize(18.0), line_height: LineHeight(1.5), - font_color: FontColor(protextinator::cosmic_text::Color::rgb(0xE5, 0xE5, 0xE5)), // Light gray + font_color: FontColor(protextinator::cosmic_text::Color::rgb(0xFF, 0xFF, 0xFF)), // Pure white horizontal_alignment: HorizontalTextAlignment::Start, vertical_alignment: VerticalTextAlignment::Start, wrap: Some(TextWrap::Wrap), diff --git a/src/text_manager.rs b/src/text_manager.rs index 226092f..ef48e78 100644 --- a/src/text_manager.rs +++ b/src/text_manager.rs @@ -190,18 +190,43 @@ impl TextManager { /// /// This will recalculate the shaping/layout if needed prior to rasterization. /// Currently runs on a single thread; the API is designed to be easily parallelized later. - pub fn rasterize_all_textures(&mut self) { + pub fn rasterize_all_textures(&mut self) -> Vec { // In the future this can be parallelized by splitting the states into chunks and // creating per-thread SwashCache/FontSystem references as needed. - for (_id, state) in self.text_states.iter_mut() { - // Ensure buffer is up to date + let mut changes = Vec::new(); + for (id, state) in self.text_states.iter_mut() { + let old_w = state.rasterized_texture().width; + let old_h = state.rasterized_texture().height; + // Ensure the buffer is up to date state.recalculate(&mut self.text_context); // Rasterize into the state's texture storage - state.rasterize_into_texture(&mut self.text_context); + let rerasterized = state.rasterize_into_texture(&mut self.text_context); + if rerasterized { + let new_w = state.rasterized_texture().width; + let new_h = state.rasterized_texture().height; + let resized = new_w != old_w || new_h != old_h; + changes.push(RasterizedTextureInfo { + id: *id, + width: new_w, + height: new_h, + resized, + }); + } } + changes } } +/// Information about a text state's rasterized texture after `rasterize_all_textures`. +#[derive(Debug, Clone, Copy)] +pub struct RasterizedTextureInfo { + pub id: Id, + pub width: u32, + pub height: u32, + /// True if the texture dimensions changed compared to the previous rasterization. + pub resized: bool, +} + impl TextContext { /// Loads fonts from the provided sources into the font database. /// From 73d02da92bb9d143e9e907560ac9283a40fd0f8a Mon Sep 17 00:00:00 2001 From: Anton Suprunchuk Date: Tue, 2 Sep 2025 23:06:30 -0300 Subject: [PATCH 07/14] add alpha multiplication mode for text rasterization --- examples/text.rs | 4 +-- src/lib.rs | 2 +- src/state.rs | 82 +++++++++++++++++++++++++++------------------ src/text_manager.rs | 6 ++-- src/utils.rs | 21 ++++++++++++ 5 files changed, 77 insertions(+), 38 deletions(-) diff --git a/examples/text.rs b/examples/text.rs index f2ba412..6718f92 100644 --- a/examples/text.rs +++ b/examples/text.rs @@ -4,7 +4,7 @@ use protextinator::style::{ FontColor, FontFamily, FontSize, HorizontalTextAlignment, LineHeight, TextStyle, TextWrap, VerticalTextAlignment, }; -use protextinator::{Id, Point, Rect, TextManager}; +use protextinator::{AlphaMode, Id, Point, Rect, TextManager}; use std::sync::Arc; use std::time::Instant; use winit::{ @@ -227,7 +227,7 @@ impl<'a> App<'a> { // Rasterize all text states into CPU textures let t_raster_start = Instant::now(); - self.text_manager.rasterize_all_textures(); + self.text_manager.rasterize_all_textures(AlphaMode::Premultiplied); let raster_time = t_raster_start.elapsed(); // Upload main text texture and draw diff --git a/src/lib.rs b/src/lib.rs index a6ca795..8e329bf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,5 +78,5 @@ pub use action::{Action, ActionResult}; pub use cosmic_text; pub use id::Id; pub use math::{Point, Rect}; -pub use state::{RasterizedTexture, Selection, SelectionLine, TextState}; +pub use state::{RasterizedTexture, Selection, SelectionLine, TextState, AlphaMode}; pub use text_manager::{TextContext, TextManager}; diff --git a/src/state.rs b/src/state.rs index 92dfa52..b2ec697 100644 --- a/src/state.rs +++ b/src/state.rs @@ -19,6 +19,7 @@ use cosmic_text::LayoutGlyph; use cosmic_text::{Buffer, Cursor, Edit, Editor, FontSystem, Motion}; use smol_str::SmolStr; use std::time::{Duration, Instant}; +use crate::utils::{linear_to_srgb_u8, srgb_to_linear_u8}; /// Size comparison epsilon for floating-point calculations. pub const SIZE_EPSILON: f32 = 0.0001; @@ -1045,7 +1046,7 @@ impl TextState { /// /// Returns true if rasterization was performed (and texture updated), false if skipped /// (e.g., zero-sized target). - pub(crate) fn rasterize_into_texture(&mut self, ctx: &mut TextContext) -> bool { + pub(crate) fn rasterize_into_texture(&mut self, ctx: &mut TextContext, alpha_mode: AlphaMode) -> bool { // Compute device-pixel texture size from the logical outer size and scale factor let size = self.outer_size(); let scale = ctx.scale_factor.max(0.01); @@ -1082,50 +1083,57 @@ impl TextState { px[3] = 0; } - let base_color = cosmic_text::Color::rgb(255, 255, 255); - let tex_w = width; - let tex_h = height; + let base_color = cosmic_text::Color::rgba(0, 0, 0, 0); + let text_width = width; + let text_height = height; self.buffer.draw( &mut ctx.font_system, &mut ctx.swash_cache, base_color, - |x, y, w, h, color| { + |x, y, mut w, mut h, color| { // Clip to buffer bounds - let (x0, y0) = ((x as u32).min(tex_w), (y as u32).min(tex_h)); - let mut w = w as u32; - let mut h = h as u32; - if x0 >= tex_w || y0 >= tex_h || w == 0 || h == 0 { + let (x0, y0) = ((x as u32).min(text_width), (y as u32).min(text_height)); + if x0 >= text_width || y0 >= text_height || w == 0 || h == 0 { return; } - if x0 + w > tex_w { - w = tex_w - x0; + if x0 + w > text_width { + w = text_width - x0; } - if y0 + h > tex_h { - h = tex_h - y0; + if y0 + h > text_height { + h = text_height - y0; } - let [r, g, b, a] = color.as_rgba(); - let src_a = a as f32 / 255.0; for row in 0..h { - let dst_row_start = ((y0 + row) * tex_w * 4 + x0 * 4) as usize; + let dst_row_start = ((y0 + row) * text_width * 4 + x0 * 4) as usize; let row_slice = &mut self.rasterized_texture.pixels [dst_row_start..dst_row_start + (w as usize) * 4]; - for px in row_slice.chunks_exact_mut(4) { - let dst_r = px[0] as f32 / 255.0; - let dst_g = px[1] as f32 / 255.0; - let dst_b = px[2] as f32 / 255.0; - let dst_a = px[3] as f32 / 255.0; - - let out_a = src_a + dst_a * (1.0 - src_a); - let inv = if out_a > 0.0 { 1.0 - src_a } else { 0.0 }; - let out_r = (r as f32 / 255.0) * src_a + dst_r * inv; - let out_g = (g as f32 / 255.0) * src_a + dst_g * inv; - let out_b = (b as f32 / 255.0) * src_a + dst_b * inv; - - px[0] = (out_r.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; - px[1] = (out_g.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; - px[2] = (out_b.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; - px[3] = (out_a.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8; + match alpha_mode { + AlphaMode::Premultiplied => { + for px in row_slice.chunks_exact_mut(4) { + let r_lin = srgb_to_linear_u8(color.r()); + let g_lin = srgb_to_linear_u8(color.g()); + let b_lin = srgb_to_linear_u8(color.b()); + let a = color.a() as f32 / 255.0; + + let r_pma = r_lin * a; + let g_pma = g_lin * a; + let b_pma = b_lin * a; + + px[0] = linear_to_srgb_u8(r_pma); + px[1] = linear_to_srgb_u8(g_pma); + px[2] = linear_to_srgb_u8(b_pma); + // keep alpha as-is + px[3] = color.a(); + } + } + AlphaMode::Unmultiplied => { + for px in row_slice.chunks_exact_mut(4) { + px[0] = color.r(); + px[1] = color.g(); + px[2] = color.b(); + px[3] = color.a(); + } + } } } }, @@ -1510,3 +1518,13 @@ impl UpdateReason { ) } } + +#[derive(Debug, Copy, Clone)] +pub enum AlphaMode { + /// Use premultiplied alpha for rendering. This is generally preferred for performance + /// and quality, especially when blending with other premultiplied content. + Premultiplied, + /// Use unmultiplied alpha for rendering. This may be necessary when compositing + /// with non-premultiplied content, but can lead to artifacts and is less efficient. + Unmultiplied, +} \ No newline at end of file diff --git a/src/text_manager.rs b/src/text_manager.rs index ef48e78..ffad47b 100644 --- a/src/text_manager.rs +++ b/src/text_manager.rs @@ -3,7 +3,7 @@ //! This module provides high-level management of multiple text states, font loading, //! and resource tracking for text rendering systems. -use crate::state::TextState; +use crate::state::{AlphaMode, TextState}; use crate::Id; use ahash::{HashMap, HashSet, HashSetExt}; use cosmic_text::{fontdb, FontSystem, SwashCache}; @@ -190,7 +190,7 @@ impl TextManager { /// /// This will recalculate the shaping/layout if needed prior to rasterization. /// Currently runs on a single thread; the API is designed to be easily parallelized later. - pub fn rasterize_all_textures(&mut self) -> Vec { + pub fn rasterize_all_textures(&mut self, alpha_mode: AlphaMode) -> Vec { // In the future this can be parallelized by splitting the states into chunks and // creating per-thread SwashCache/FontSystem references as needed. let mut changes = Vec::new(); @@ -200,7 +200,7 @@ impl TextManager { // Ensure the buffer is up to date state.recalculate(&mut self.text_context); // Rasterize into the state's texture storage - let rerasterized = state.rasterize_into_texture(&mut self.text_context); + let rerasterized = state.rasterize_into_texture(&mut self.text_context, alpha_mode); if rerasterized { let new_w = state.rasterized_texture().width; let new_h = state.rasterized_texture().height; diff --git a/src/utils.rs b/src/utils.rs index 2acd5f0..f8debc7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -92,3 +92,24 @@ impl<'de> Deserialize<'de> for ArcCowStr { Ok(ArcCowStr::from(s)) } } + +#[inline(always)] +pub fn srgb_to_linear_u8(c: u8) -> f32 { + let x = c as f32 / 255.0; + if x <= 0.04045 { + x / 12.92 + } else { + ((x + 0.055) / 1.055).powf(2.4) + } +} + +#[inline(always)] +pub fn linear_to_srgb_u8(x: f32) -> u8 { + let x = x.clamp(0.0, 1.0); + let y = if x <= 0.0031308 { + x * 12.92 + } else { + 1.055 * x.powf(1.0 / 2.4) - 0.055 + }; + (y.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8 +} From 1dcb11c292812f646f8c32f3d23cfe46543cb9c7 Mon Sep 17 00:00:00 2001 From: Anton Suprunchuk Date: Sat, 27 Sep 2025 15:21:40 -0300 Subject: [PATCH 08/14] add a comment about replacing draw with a proper renderer --- src/state.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/state.rs b/src/state.rs index b2ec697..738330c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1086,6 +1086,7 @@ impl TextState { let base_color = cosmic_text::Color::rgba(0, 0, 0, 0); let text_width = width; let text_height = height; + // TODO: make an atlas via an adapter trait or something that can be passed to here from the renderer self.buffer.draw( &mut ctx.font_system, &mut ctx.swash_cache, From 82e833907f802ff4bf3bb880e6278d98b075e068 Mon Sep 17 00:00:00 2001 From: Anton Suprunchuk Date: Sat, 27 Sep 2025 15:23:11 -0300 Subject: [PATCH 09/14] reformat --- examples/text.rs | 3 ++- src/lib.rs | 2 +- src/state.rs | 16 ++++++++++++---- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/examples/text.rs b/examples/text.rs index 6718f92..934238b 100644 --- a/examples/text.rs +++ b/examples/text.rs @@ -227,7 +227,8 @@ impl<'a> App<'a> { // Rasterize all text states into CPU textures let t_raster_start = Instant::now(); - self.text_manager.rasterize_all_textures(AlphaMode::Premultiplied); + self.text_manager + .rasterize_all_textures(AlphaMode::Premultiplied); let raster_time = t_raster_start.elapsed(); // Upload main text texture and draw diff --git a/src/lib.rs b/src/lib.rs index 8e329bf..e819fac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,5 +78,5 @@ pub use action::{Action, ActionResult}; pub use cosmic_text; pub use id::Id; pub use math::{Point, Rect}; -pub use state::{RasterizedTexture, Selection, SelectionLine, TextState, AlphaMode}; +pub use state::{AlphaMode, RasterizedTexture, Selection, SelectionLine, TextState}; pub use text_manager::{TextContext, TextManager}; diff --git a/src/state.rs b/src/state.rs index 738330c..a1781d6 100644 --- a/src/state.rs +++ b/src/state.rs @@ -13,13 +13,13 @@ use crate::math::Size; use crate::style::{TextStyle, VerticalTextAlignment}; use crate::text_manager::TextContext; use crate::text_params::TextParams; +use crate::utils::{linear_to_srgb_u8, srgb_to_linear_u8}; use crate::{Point, Rect}; #[cfg(test)] use cosmic_text::LayoutGlyph; use cosmic_text::{Buffer, Cursor, Edit, Editor, FontSystem, Motion}; use smol_str::SmolStr; use std::time::{Duration, Instant}; -use crate::utils::{linear_to_srgb_u8, srgb_to_linear_u8}; /// Size comparison epsilon for floating-point calculations. pub const SIZE_EPSILON: f32 = 0.0001; @@ -193,7 +193,11 @@ impl TextState { inner_dimensions: Size::ZERO, buffer: Buffer::new(font_system, metrics), - rasterized_texture: RasterizedTexture { pixels: Vec::new(), width: 0, height: 0 }, + rasterized_texture: RasterizedTexture { + pixels: Vec::new(), + width: 0, + height: 0, + }, raster_dirty: true, metadata, @@ -1046,7 +1050,11 @@ impl TextState { /// /// Returns true if rasterization was performed (and texture updated), false if skipped /// (e.g., zero-sized target). - pub(crate) fn rasterize_into_texture(&mut self, ctx: &mut TextContext, alpha_mode: AlphaMode) -> bool { + pub(crate) fn rasterize_into_texture( + &mut self, + ctx: &mut TextContext, + alpha_mode: AlphaMode, + ) -> bool { // Compute device-pixel texture size from the logical outer size and scale factor let size = self.outer_size(); let scale = ctx.scale_factor.max(0.01); @@ -1528,4 +1536,4 @@ pub enum AlphaMode { /// Use unmultiplied alpha for rendering. This may be necessary when compositing /// with non-premultiplied content, but can lead to artifacts and is less efficient. Unmultiplied, -} \ No newline at end of file +} From e61eba5372902ad55f17623b424639aefbde3841 Mon Sep 17 00:00:00 2001 From: Anton Suprunchuk Date: Sat, 27 Sep 2025 15:56:50 -0300 Subject: [PATCH 10/14] optimize CPU texture filling a little bit --- Cargo.lock | 4 +++- Cargo.toml | 4 ++-- src/state.rs | 67 ++++++++++++++++++++++++++-------------------------- 3 files changed, 38 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 455500e..932c2a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -809,7 +809,9 @@ dependencies = [ [[package]] name = "grafo" -version = "0.8.1" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3c5679dd055e95e09c359576903feb606f558fa43236dd2805ee203cb0e885" dependencies = [ "ahash", "bytemuck", diff --git a/Cargo.toml b/Cargo.toml index 3a99146..51c654e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,8 +22,8 @@ smol_str = "0.3" serde = { version = "1.0.219", features = ["derive"], optional = true } [dev-dependencies] -#grafo = "0.7" -grafo = { path = "../grafo" } +grafo = "0.9" +#grafo = { path = "../grafo" } winit = "0.30" futures = "0.3" env_logger = "0.11" diff --git a/src/state.rs b/src/state.rs index a1781d6..bc24f22 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1083,13 +1083,8 @@ impl TextState { self.rasterized_texture.pixels.resize(required_len, 0); } - // Clear to transparent before drawing - for px in self.rasterized_texture.pixels.chunks_exact_mut(4) { - px[0] = 0; - px[1] = 0; - px[2] = 0; - px[3] = 0; - } + // Clear to transparent before drawing (fast fill) + self.rasterized_texture.pixels.fill(0); let base_color = cosmic_text::Color::rgba(0, 0, 0, 0); let text_width = width; @@ -1111,38 +1106,42 @@ impl TextState { if y0 + h > text_height { h = text_height - y0; } + // Precompute the 4-byte pixel once per rectangle and use row-wise fills + let mut packed_px = [0u8; 4]; + match alpha_mode { + AlphaMode::Premultiplied => { + let r_lin = srgb_to_linear_u8(color.r()); + let g_lin = srgb_to_linear_u8(color.g()); + let b_lin = srgb_to_linear_u8(color.b()); + let a = color.a() as f32 / 255.0; + let r_pma = r_lin * a; + let g_pma = g_lin * a; + let b_pma = b_lin * a; + packed_px[0] = linear_to_srgb_u8(r_pma); + packed_px[1] = linear_to_srgb_u8(g_pma); + packed_px[2] = linear_to_srgb_u8(b_pma); + packed_px[3] = color.a(); + } + AlphaMode::Unmultiplied => { + packed_px[0] = color.r(); + packed_px[1] = color.g(); + packed_px[2] = color.b(); + packed_px[3] = color.a(); + } + } + // Fill each destination row with the precomputed pixel for row in 0..h { let dst_row_start = ((y0 + row) * text_width * 4 + x0 * 4) as usize; let row_slice = &mut self.rasterized_texture.pixels [dst_row_start..dst_row_start + (w as usize) * 4]; - match alpha_mode { - AlphaMode::Premultiplied => { - for px in row_slice.chunks_exact_mut(4) { - let r_lin = srgb_to_linear_u8(color.r()); - let g_lin = srgb_to_linear_u8(color.g()); - let b_lin = srgb_to_linear_u8(color.b()); - let a = color.a() as f32 / 255.0; - - let r_pma = r_lin * a; - let g_pma = g_lin * a; - let b_pma = b_lin * a; - - px[0] = linear_to_srgb_u8(r_pma); - px[1] = linear_to_srgb_u8(g_pma); - px[2] = linear_to_srgb_u8(b_pma); - // keep alpha as-is - px[3] = color.a(); - } - } - AlphaMode::Unmultiplied => { - for px in row_slice.chunks_exact_mut(4) { - px[0] = color.r(); - px[1] = color.g(); - px[2] = color.b(); - px[3] = color.a(); - } - } + + // Repeat-copy packed_px across the row + // Avoid per-pixel math; just copy the 4-byte pattern + let mut i = 0usize; + while i + 4 <= row_slice.len() { + row_slice[i..i + 4].copy_from_slice(&packed_px); + i += 4; } } }, From 821fb19d2a9e35fe7e57f7521b8f83c59cef0e61 Mon Sep 17 00:00:00 2001 From: Anton Suprunchuk Date: Sat, 27 Sep 2025 16:47:39 -0300 Subject: [PATCH 11/14] fix logical scaling --- src/buffer_utils.rs | 41 ++++++++++----- src/state.rs | 111 +++++++++++++++++++++++++--------------- src/tests/text_state.rs | 58 +++++++++++++++++++++ 3 files changed, 156 insertions(+), 54 deletions(-) diff --git a/src/buffer_utils.rs b/src/buffer_utils.rs index c803127..b431526 100644 --- a/src/buffer_utils.rs +++ b/src/buffer_utils.rs @@ -27,12 +27,15 @@ pub(crate) fn vertical_offset( } } +/// Ensures the caret is vertically visible by adjusting buffer scroll using DEVICE pixels. +/// Returns caret top-left in LOGICAL pixels relative to the viewport. pub(crate) fn adjust_vertical_scroll_to_make_caret_visible( buffer: &mut Buffer, current_char_byte_cursor: ByteCursor, font_system: &mut FontSystem, text_area_size: Size, style: &TextStyle, + scale_factor: f32, ) -> Option { let mut editor = Editor::new(&mut *buffer); editor.set_cursor(current_char_byte_cursor.cursor); @@ -41,22 +44,29 @@ pub(crate) fn adjust_vertical_scroll_to_make_caret_visible( match caret_position { Some(position) => { + // caret position from cosmic_text is in DEVICE pixels let mut caret_top_left_corner = Point::from(position); let mut scroll = buffer.scroll(); - let line_height = style.line_height_pt(); + let scale = scale_factor.max(0.01); + let line_height_device = style.line_height_pt() * scale; + let text_area_height_device = text_area_size.y * scale; // If the caret is not fully visible, we need to scroll it into view if caret_top_left_corner.y < 0.0 { scroll.vertical += caret_top_left_corner.y; caret_top_left_corner.y = 0.0; buffer.set_scroll(scroll); - } else if caret_top_left_corner.y + line_height > text_area_size.y { - scroll.vertical += caret_top_left_corner.y + line_height - text_area_size.y; - caret_top_left_corner.y = text_area_size.y - line_height; + } else if caret_top_left_corner.y + line_height_device > text_area_height_device { + scroll.vertical += + caret_top_left_corner.y + line_height_device - text_area_height_device; + caret_top_left_corner.y = text_area_height_device - line_height_device; buffer.set_scroll(scroll); } - - Some(caret_top_left_corner) + // Convert caret position back to LOGICAL pixels for the API + Some(Point::new( + caret_top_left_corner.x / scale, + caret_top_left_corner.y / scale, + )) } None => { // Caret is not visible, we need to shape the text and move the scroll @@ -82,20 +92,27 @@ pub(crate) fn adjust_vertical_scroll_to_make_caret_visible( // } // }); // } - editor.cursor_position().map(Point::from) + // Return caret position in LOGICAL pixels + editor.cursor_position().map(|p| { + let p = Point::from(p); + let scale = scale_factor.max(0.01); + Point::new(p.x / scale, p.y / scale) + }) } } } +/// Hit-test a character under a LOGICAL pixel coordinate, accounting for scroll and scale. pub fn char_under_position( buffer: &Buffer, interaction_position_relative_to_element: Point, + scale_factor: f32, ) -> Option { - let horizontal_scroll = buffer.scroll().horizontal; - buffer.hit( - interaction_position_relative_to_element.x + horizontal_scroll, - interaction_position_relative_to_element.y, - ) + let horizontal_scroll_device = buffer.scroll().horizontal; + let scale = scale_factor.max(0.01); + let x_device = interaction_position_relative_to_element.x * scale + horizontal_scroll_device; + let y_device = interaction_position_relative_to_element.y * scale; + buffer.hit(x_device, y_device) } /// Returns inner buffer dimensions diff --git a/src/state.rs b/src/state.rs index bc24f22..bc4f494 100644 --- a/src/state.rs +++ b/src/state.rs @@ -746,6 +746,7 @@ impl TextState { /// println!("Scrolled by: ({}, {})", scroll.x, scroll.y); /// ``` pub fn absolute_scroll(&self) -> Point { + let scale = self.params.scale_factor().max(0.01); let scroll = self.buffer.scroll(); let scroll_line = scroll.line; let scroll_vertical = scroll.vertical; @@ -759,14 +760,16 @@ impl TextState { } if let Some(layout_lines) = line.layout_opt() { for layout_line in layout_lines { - line_vertical_start += layout_line.line_height_opt.unwrap_or(line_height); + line_vertical_start += layout_line + .line_height_opt + .unwrap_or(line_height * scale); } } } - + // Convert to LOGICAL pixels Point { - x: scroll_horizontal, - y: scroll_vertical + line_vertical_start, + x: scroll_horizontal / scale, + y: (scroll_vertical + line_vertical_start) / scale, } } @@ -792,38 +795,42 @@ impl TextState { /// ``` pub fn set_absolute_scroll(&mut self, scroll: Point) { let mut new_scroll = self.buffer.scroll(); + let scale = self.params.scale_factor().max(0.01); let can_scroll_vertically = matches!(self.style().vertical_alignment, VerticalTextAlignment::None); - new_scroll.horizontal = scroll.x; + // Horizontal scroll is stored in DEVICE pixels + new_scroll.horizontal = scroll.x * scale; if can_scroll_vertically { let line_height = self.style().line_height_pt(); let mut line_index = 0; - let mut accumulated_height = 0.0; + let mut accumulated_height_device = 0.0; + let target_y_device = scroll.y * scale; for (i, line) in self.buffer.lines.iter().enumerate() { - let mut line_height_total = 0.0; + let mut line_height_total_device = 0.0; if let Some(layout_lines) = line.layout_opt() { for layout_line in layout_lines { - line_height_total += layout_line.line_height_opt.unwrap_or(line_height); + line_height_total_device += + layout_line.line_height_opt.unwrap_or(line_height * scale); } } - if accumulated_height + line_height_total > scroll.y { + if accumulated_height_device + line_height_total_device > target_y_device { line_index = i; break; } - accumulated_height += line_height_total; + accumulated_height_device += line_height_total_device; line_index = i + 1; // In case we don't break, this will be the last line } - // Set the line and calculate the remaining vertical offset + // Set the line and calculate the remaining vertical offset (device px) new_scroll.line = line_index; - new_scroll.vertical = scroll.y - accumulated_height; + new_scroll.vertical = target_y_device - accumulated_height_device; } // Apply only if changed @@ -861,12 +868,13 @@ impl TextState { self.selection.lines.clear(); for run in self.buffer.layout_runs() { if let Some((start_x, width)) = run.highlight(start_cursor.cursor, end_cursor.cursor) { + let scale = self.params.scale_factor().max(0.01); self.selection.lines.push(SelectionLine { - // TODO: cosmic test doesn't seem to correctly apply horizontal scrolling - start_x_pt: Some(start_x - self.buffer.scroll().horizontal), - end_x_pt: Some(start_x + width - self.buffer.scroll().horizontal), - start_y_pt: Some(run.line_top), - end_y_pt: Some(run.line_top + run.line_height), + // Convert to LOGICAL pixels + start_x_pt: Some((start_x - self.buffer.scroll().horizontal) / scale), + end_x_pt: Some((start_x + width - self.buffer.scroll().horizontal) / scale), + start_y_pt: Some(run.line_top / scale), + end_y_pt: Some((run.line_top + run.line_height) / scale), }); } } @@ -911,16 +919,19 @@ impl TextState { } fn calculate_caret_position(&mut self) -> Option { - let horizontal_scroll = self.buffer.scroll().horizontal; + // Return caret position in LOGICAL pixels relative to viewport + let horizontal_scroll_device = self.buffer.scroll().horizontal; + let scale = self.params.scale_factor().max(0.01); let mut editor = Editor::new(&mut self.buffer); editor.set_cursor(self.cursor.cursor); editor.cursor_position().map(|pos| { - let mut point = Point::from(pos); - // Adjust the point to account for horizontal scroll, as cosmic_text does not - // support horizontal scrolling natively. - point.x -= horizontal_scroll; - point + // pos from cosmic_text is in DEVICE pixels + let mut point_device = Point::from(pos); + // Adjust by horizontal scroll (device px) + point_device.x -= horizontal_scroll_device; + // Convert to logical + Point::new(point_device.x / scale, point_device.y / scale) }) } @@ -931,10 +942,12 @@ impl TextState { let mut scroll = self.buffer.scroll(); let text_area_size = self.params.size(); - let vertical_scroll_to_align_text = + let vertical_scroll_to_align_text_logical = calculate_vertical_offset(self.params.style(), text_area_size, self.inner_dimensions); - if (scroll.vertical - vertical_scroll_to_align_text).abs() > SIZE_EPSILON { - scroll.vertical = vertical_scroll_to_align_text; + let scale = self.params.scale_factor().max(0.01); + let target_vertical_device = vertical_scroll_to_align_text_logical * scale; + if (scroll.vertical - target_vertical_device).abs() > SIZE_EPSILON { + scroll.vertical = target_vertical_device; self.buffer.set_scroll(scroll); // Vertical alignment scroll change affects raster self.raster_dirty = true; @@ -950,9 +963,12 @@ impl TextState { ) -> Option<()> { if update_reason.is_cursor_updated() { let text_area_size = self.params.size(); + let scale = self.params.scale_factor().max(0.01); let old_scroll = self.buffer.scroll(); - let old_relative_caret_x = self.relative_caret_position.map_or(0.0, |p| p.x); - let old_absolute_caret_x = old_relative_caret_x + old_scroll.horizontal; + let old_relative_caret_x_logical = self.relative_caret_position.map_or(0.0, |p| p.x); + // Convert old absolute caret to logical coords + let old_absolute_caret_x_logical = + old_relative_caret_x_logical + old_scroll.horizontal / scale; let caret_position_relative_to_buffer = adjust_vertical_scroll_to_make_caret_visible( &mut self.buffer, @@ -960,18 +976,19 @@ impl TextState { font_system, self.params.size(), self.params.style(), + scale, )?; let mut new_scroll = self.buffer.scroll(); let text_area_width = text_area_size.x; // TODO: there was some other implementation that took horizontal alignment into account, // check if it is needed - let new_absolute_caret_offset = caret_position_relative_to_buffer.x; + let new_absolute_caret_offset = caret_position_relative_to_buffer.x; // logical // TODO: A little hack to set horizontal scroll let current_absolute_visible_text_area = ( - old_scroll.horizontal, - old_scroll.horizontal + text_area_width, + old_scroll.horizontal / scale, + old_scroll.horizontal / scale + text_area_width, ); let min = current_absolute_visible_text_area.0; let max = current_absolute_visible_text_area.1; @@ -984,12 +1001,14 @@ impl TextState { let is_moving_caret_without_updating_the_text = matches!(update_reason, UpdateReason::MoveCaret); if !is_moving_caret_without_updating_the_text { - let text_shift = old_absolute_caret_x - new_absolute_caret_offset; + let text_shift_logical = + old_absolute_caret_x_logical - new_absolute_caret_offset; // If a text was deleted (caret moved left), adjust the scroll to compensate - if text_shift > 0.0 { + if text_shift_logical > 0.0 { // Adjust scroll to keep the caret visually in the same position - new_scroll.horizontal = (old_scroll.horizontal - text_shift).max(0.0); + new_scroll.horizontal = + (old_scroll.horizontal - text_shift_logical * scale).max(0.0); // Ensure we don't scroll beyond the text boundaries let inner_dimensions = self.inner_size(); @@ -997,8 +1016,10 @@ impl TextState { if inner_dimensions.x > area_width { // Text is larger than viewport - clamp scroll to valid range - let max_scroll = inner_dimensions.x - area_width + self.caret_width; - new_scroll.horizontal = new_scroll.horizontal.min(max_scroll); + let max_scroll_device = + (inner_dimensions.x - area_width + self.caret_width) * scale; + new_scroll.horizontal = + new_scroll.horizontal.min(max_scroll_device); } else { // Text fits within the viewport - no scroll needed new_scroll.horizontal = 0.0; @@ -1007,9 +1028,9 @@ impl TextState { } } else if new_absolute_caret_offset > max { new_scroll.horizontal = - new_absolute_caret_offset - text_area_width + self.caret_width; + (new_absolute_caret_offset - text_area_width + self.caret_width) * scale; } else if new_absolute_caret_offset < min { - new_scroll.horizontal = new_absolute_caret_offset; + new_scroll.horizontal = new_absolute_caret_offset * scale; } else if new_absolute_caret_offset < 0.0 { new_scroll.horizontal = 0.0; } else { @@ -1359,8 +1380,11 @@ impl TextState { if self.is_selectable || self.is_editable { self.reset_selection(); - let byte_offset_cursor = - char_under_position(&self.buffer, click_position_relative_to_area)?; + let byte_offset_cursor = char_under_position( + &self.buffer, + click_position_relative_to_area, + self.params.scale_factor(), + )?; self.update_cursor_before_glyph_with_cursor(byte_offset_cursor); // Reset selection to start at the press location @@ -1407,8 +1431,11 @@ impl TextState { return None; } if self.is_selectable { - let byte_cursor_under_position = - char_under_position(&self.buffer, pointer_relative_position)?; + let byte_cursor_under_position = char_under_position( + &self.buffer, + pointer_relative_position, + self.params.scale_factor(), + )?; if let Some(_origin) = self.selection.origin_character_byte_cursor { self.selection.ends_before_character_byte_cursor = ByteCursor::from_cursor( diff --git a/src/tests/text_state.rs b/src/tests/text_state.rs index a71e8e2..a1b0e9b 100644 --- a/src/tests/text_state.rs +++ b/src/tests/text_state.rs @@ -758,3 +758,61 @@ pub fn test_combined_scroll_with_alignment() { "Vertical scrolling should not work with VerticalTextAlignment::Start" ); } + +#[test] +pub fn test_scale_factor_consistency() { + let mut ctx = TextContext::default(); + let text = "Line 1\nLine 2\nLine 3".to_string(); + + // Start with vertical centering to introduce a non-zero vertical alignment offset + let mut text_state = TextState::new_with_text(text.clone(), &mut ctx.font_system, ()); + text_state.set_style(&mono_style_with_alignment( + HorizontalTextAlignment::Left, + VerticalTextAlignment::Center, + )); + text_state.set_outer_size(&Size::new(200.0, 100.0)); + + // Base scale 1.0 + text_state.set_scale_factor(1.0); + text_state.recalculate(&mut ctx); + let scroll1 = text_state.absolute_scroll(); // logical + + // Increase scale factor; logical scroll should remain the same + text_state.set_scale_factor(2.0); + text_state.recalculate(&mut ctx); + let scroll2 = text_state.absolute_scroll(); // logical + + assert!(scroll1.approx_eq(&scroll2, 0.75), "Absolute scroll should be stable in logical pixels when scale changes. Before: {:?}, After: {:?}", scroll1, scroll2); + + // Hit-test should also be stable in logical coords + text_state.is_selectable = true; + text_state.is_editable = true; + text_state.are_actions_enabled = true; + // Click near the start of the first visible line + text_state.handle_press(&mut ctx, Point::new(2.0, 5.0)); + let cursor_idx_scale2 = text_state.cursor_char_index().unwrap_or(0); + + // Switch back to scale 1.0 and click the same logical position + text_state.set_scale_factor(1.0); + text_state.recalculate(&mut ctx); + text_state.handle_press(&mut ctx, Point::new(2.0, 5.0)); + let cursor_idx_scale1 = text_state.cursor_char_index().unwrap_or(0); + + assert_eq!(cursor_idx_scale1, cursor_idx_scale2, "Hit-test should map the same logical coords to the same character across scales"); + + // Selection bounds are exposed in logical px; they should be comparable across scales + // Use the public action API to select all + text_state.apply_action(&mut ctx, &Action::SelectAll); + let lines_scale1 = text_state.selection().lines().to_vec(); + + text_state.set_scale_factor(2.0); + text_state.recalculate(&mut ctx); + let lines_scale2 = text_state.selection().lines().to_vec(); + + // Compare first selection line heights (allow small epsilon due to layout/rounding) + if let (Some(l1), Some(l2)) = (lines_scale1.first(), lines_scale2.first()) { + let h1 = (l1.end_y_pt.unwrap_or(0.0) - l1.start_y_pt.unwrap_or(0.0)).abs(); + let h2 = (l2.end_y_pt.unwrap_or(0.0) - l2.start_y_pt.unwrap_or(0.0)).abs(); + assert!((h1 - h2).abs() < 1.0, "Selection line height should be stable in logical px across scales: h1={} h2={}", h1, h2); + } +} From 096cb7c4e4d039b9099babf4776d7886a659f3f2 Mon Sep 17 00:00:00 2001 From: Anton Suprunchuk Date: Sat, 27 Sep 2025 16:57:34 -0300 Subject: [PATCH 12/14] reformat --- src/state.rs | 8 +++----- src/tests/text_state.rs | 12 ++++++++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/state.rs b/src/state.rs index bc4f494..2711323 100644 --- a/src/state.rs +++ b/src/state.rs @@ -760,9 +760,8 @@ impl TextState { } if let Some(layout_lines) = line.layout_opt() { for layout_line in layout_lines { - line_vertical_start += layout_line - .line_height_opt - .unwrap_or(line_height * scale); + line_vertical_start += + layout_line.line_height_opt.unwrap_or(line_height * scale); } } } @@ -1018,8 +1017,7 @@ impl TextState { // Text is larger than viewport - clamp scroll to valid range let max_scroll_device = (inner_dimensions.x - area_width + self.caret_width) * scale; - new_scroll.horizontal = - new_scroll.horizontal.min(max_scroll_device); + new_scroll.horizontal = new_scroll.horizontal.min(max_scroll_device); } else { // Text fits within the viewport - no scroll needed new_scroll.horizontal = 0.0; diff --git a/src/tests/text_state.rs b/src/tests/text_state.rs index a1b0e9b..c39f1c3 100644 --- a/src/tests/text_state.rs +++ b/src/tests/text_state.rs @@ -798,7 +798,10 @@ pub fn test_scale_factor_consistency() { text_state.handle_press(&mut ctx, Point::new(2.0, 5.0)); let cursor_idx_scale1 = text_state.cursor_char_index().unwrap_or(0); - assert_eq!(cursor_idx_scale1, cursor_idx_scale2, "Hit-test should map the same logical coords to the same character across scales"); + assert_eq!( + cursor_idx_scale1, cursor_idx_scale2, + "Hit-test should map the same logical coords to the same character across scales" + ); // Selection bounds are exposed in logical px; they should be comparable across scales // Use the public action API to select all @@ -813,6 +816,11 @@ pub fn test_scale_factor_consistency() { if let (Some(l1), Some(l2)) = (lines_scale1.first(), lines_scale2.first()) { let h1 = (l1.end_y_pt.unwrap_or(0.0) - l1.start_y_pt.unwrap_or(0.0)).abs(); let h2 = (l2.end_y_pt.unwrap_or(0.0) - l2.start_y_pt.unwrap_or(0.0)).abs(); - assert!((h1 - h2).abs() < 1.0, "Selection line height should be stable in logical px across scales: h1={} h2={}", h1, h2); + assert!( + (h1 - h2).abs() < 1.0, + "Selection line height should be stable in logical px across scales: h1={} h2={}", + h1, + h2 + ); } } From bf9eb08f399fa8ff86f0609232eac56d4ba91d36 Mon Sep 17 00:00:00 2001 From: Anton Suprunchuk Date: Sat, 27 Sep 2025 17:57:13 -0300 Subject: [PATCH 13/14] bump version --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 932c2a9..41ac047 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1622,7 +1622,7 @@ checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d" [[package]] name = "protextinator" -version = "0.2.1" +version = "0.3.0" dependencies = [ "ahash", "cosmic-text", diff --git a/Cargo.toml b/Cargo.toml index 51c654e..3b6d640 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "protextinator" -version = "0.2.1" +version = "0.3.0" edition = "2021" description = "Text management, made simple" keywords = ["text", "rendering", "gui", "graphics", "image"] From 9760ddd17c1492cfcc9045c39311c87d0180c92d Mon Sep 17 00:00:00 2001 From: Anton Suprunchuk Date: Sat, 27 Sep 2025 18:18:23 -0300 Subject: [PATCH 14/14] remove useless comment --- examples/text.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/text.rs b/examples/text.rs index 934238b..e82142e 100644 --- a/examples/text.rs +++ b/examples/text.rs @@ -391,8 +391,6 @@ impl<'a> ApplicationHandler for App<'a> { } } -// Local rasterizer removed; textures are produced by TextManager - fn main() { env_logger::init();