diff --git a/Cargo.lock b/Cargo.lock index c995347..41ac047 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,16 @@ dependencies = [ [[package]] name = "grafo" -version = "0.7.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24959ca18babe742a401befb3af2e2e2bfa3fecc0c23c8722d17e981ea0f044e" +checksum = "ab3c5679dd055e95e09c359576903feb606f558fa43236dd2805ee203cb0e885" dependencies = [ "ahash", "bytemuck", "easy-tree", "glyphon", "log", - "lru 0.14.0", + "lru 0.15.0", "lyon", "wgpu", ] @@ -1043,9 +1043,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", ] @@ -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 bb0bfda..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"] @@ -22,7 +22,8 @@ smol_str = "0.3" serde = { version = "1.0.219", features = ["derive"], optional = true } [dev-dependencies] -grafo = "0.7" +grafo = "0.9" +#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..e82142e 100644 --- a/examples/text.rs +++ b/examples/text.rs @@ -4,8 +4,9 @@ use protextinator::style::{ FontColor, FontFamily, FontSize, HorizontalTextAlignment, LineHeight, TextStyle, TextWrap, VerticalTextAlignment, }; -use protextinator::{cosmic_text::FontSystem, Id, Point, Rect, TextManager}; +use protextinator::{AlphaMode, Id, Point, Rect, TextManager}; use std::sync::Arc; +use std::time::Instant; use winit::{ application::ApplicationHandler, event::{ElementState, KeyEvent, WindowEvent}, @@ -18,10 +19,13 @@ use winit::{ struct App<'a> { window: Option>, renderer: Option>, - font_system: Option, text_content: String, cursor_position: usize, 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> { @@ -35,10 +39,11 @@ 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(), + text_texture_id: 123, + text_texture_dimenstions: None, } } fn setup_renderer(&mut self, event_loop: &ActiveEventLoop) { @@ -65,12 +70,14 @@ impl<'a> App<'a> { false, // transparent )); - // Initialize text systems - let font_system = FontSystem::new(); + // Renderer receives the initial scale factor at creation time. 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) { @@ -122,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), @@ -138,29 +145,12 @@ 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); 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(), - }; - - // 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 @@ -217,20 +207,81 @@ 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(), }; - renderer.add_text_buffer( - stats_buffer, - stats_area, - Color::rgb(97, 175, 239), - 0.0, - stats_id.0 as usize, + // TODO: in future, draw stats using a separate texture as well + // renderer.add_text_buffer( + // stats_buffer, + // stats_area, + // Color::rgb(97, 175, 239), + // 0.0, + // stats_id.0 as usize, + // None, + // ); + } + } + + // Rasterize all text states into CPU textures + let t_raster_start = Instant::now(); + self.text_manager + .rasterize_all_textures(AlphaMode::Premultiplied); + 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) { + 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(), + } + .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)); } } @@ -263,6 +314,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); @@ -271,6 +323,27 @@ 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); + // 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(); + } + } WindowEvent::RedrawRequested => { self.render_frame(); } diff --git a/src/buffer_utils.rs b/src/buffer_utils.rs index c8a21ab..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 @@ -113,13 +130,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 +156,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 +167,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 +181,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 +192,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/lib.rs b/src/lib.rs index 81e247d..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::{Selection, SelectionLine, TextState}; +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 fee9302..2711323 100644 --- a/src/state.rs +++ b/src/state.rs @@ -13,6 +13,7 @@ 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; @@ -23,6 +24,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 +119,11 @@ pub struct TextState { inner_dimensions: Size, buffer: Buffer, + // CPU-side cached rasterized texture of the current buffer (RGBA8, device pixels) + rasterized_texture: RasterizedTexture, + // Whether raster content needs to be regenerated + raster_dirty: bool, + // Settings /// Can text be selected? pub is_selectable: bool, @@ -179,6 +193,13 @@ impl TextState { inner_dimensions: Size::ZERO, buffer: Buffer::new(font_system, metrics), + rasterized_texture: RasterizedTexture { + pixels: Vec::new(), + width: 0, + height: 0, + }, + raster_dirty: true, + metadata, } } @@ -455,6 +476,11 @@ impl TextState { &self.buffer } + /// 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 /// string .len(), which returns the length in bytes. /// @@ -720,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; @@ -733,14 +760,15 @@ 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, } } @@ -766,41 +794,54 @@ 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; } - 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 @@ -826,16 +867,16 @@ 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), }); } } - None } @@ -877,16 +918,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) }) } @@ -897,10 +941,16 @@ 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); - scroll.vertical = vertical_scroll_to_align_text; - self.buffer.set_scroll(scroll); + 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; + } } /// Buffer needs to be shaped before calling this function, as it relies on the buffer's layout @@ -912,9 +962,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, @@ -922,18 +975,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; @@ -946,12 +1000,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(); @@ -959,8 +1015,9 @@ 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; @@ -969,16 +1026,25 @@ 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 { // 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 @@ -994,9 +1060,125 @@ 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; } } + /// 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, + 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); + 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 { + // 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 dims_changed = + self.rasterized_texture.width != width || self.rasterized_texture.height != height; + + // Skip if nothing changed and dimensions match + if !dims_changed && !self.raster_dirty { + return false; + } + + 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 (fast fill) + self.rasterized_texture.pixels.fill(0); + + 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, + base_color, + |x, y, mut w, mut h, color| { + // Clip to buffer bounds + 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 > text_width { + w = text_width - x0; + } + 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]; + + // 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; + } + } + }, + ); + + // Update texture dimensions and clear dirty flag + self.rasterized_texture.width = width; + self.rasterized_texture.height = height; + self.raster_dirty = false; + + 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); + } + fn copy_selected_text(&mut self) -> ActionResult { let selected_text = self.selected_text().unwrap_or(""); ActionResult::TextCopied(selected_text.to_string()) @@ -1196,8 +1378,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 @@ -1244,8 +1429,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( @@ -1363,3 +1551,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, +} diff --git a/src/style.rs b/src/style.rs index f976166..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); @@ -324,7 +326,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, @@ -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/tests/text_state.rs b/src/tests/text_state.rs index a71e8e2..c39f1c3 100644 --- a/src/tests/text_state.rs +++ b/src/tests/text_state.rs @@ -758,3 +758,69 @@ 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 + ); + } +} diff --git a/src/text_manager.rs b/src/text_manager.rs index a2f46e3..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}; @@ -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,62 @@ 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); + } + } + + /// 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, 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(); + 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 + 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; + 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 { 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 } } 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 +}