From 2afd1353c489596c9eb4ce9b5c11f8645e91dd67 Mon Sep 17 00:00:00 2001 From: Zacharias-Brohn Date: Tue, 16 Dec 2025 17:26:01 +0100 Subject: [PATCH] bold/italic support --- src/config.rs | 5 + src/renderer.rs | 587 ++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 497 insertions(+), 95 deletions(-) diff --git a/src/config.rs b/src/config.rs index 17a8a79..3bb61ae 100644 --- a/src/config.rs +++ b/src/config.rs @@ -353,6 +353,10 @@ impl Keybindings { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct Config { + /// Font family name to use. The terminal will look for Regular, Bold, Italic, and BoldItalic + /// variants of this font. If not specified, falls back to system monospace fonts. + /// Example: "JetBrainsMono Nerd Font" or "0xProto Nerd Font" + pub font_family: Option, /// Font size in points. pub font_size: f32, /// Position of the tab bar: "top", "bottom", or "hidden". @@ -382,6 +386,7 @@ pub struct Config { impl Default for Config { fn default() -> Self { Self { + font_family: None, font_size: 16.0, tab_bar_position: TabBarPosition::Top, background_opacity: 1.0, diff --git a/src/renderer.rs b/src/renderer.rs index 138d82b..efe9a53 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -107,9 +107,46 @@ struct GlyphInfo { struct ShapingContext { face: rustybuzz::Face<'static>, /// OpenType features to enable during shaping (liga, calt, etc.) + /// Note: This field is kept for potential future use when we need to modify + /// features per-context. Currently shaping_features on Renderer is used instead. + #[allow(dead_code)] features: Vec, } +/// Font style variant indices. +/// These map to the indices in font_variants array. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(usize)] +pub enum FontStyle { + Regular = 0, + Bold = 1, + Italic = 2, + BoldItalic = 3, +} + +impl FontStyle { + /// Get the font style from bold and italic flags. + pub fn from_flags(bold: bool, italic: bool) -> Self { + match (bold, italic) { + (false, false) => FontStyle::Regular, + (true, false) => FontStyle::Bold, + (false, true) => FontStyle::Italic, + (true, true) => FontStyle::BoldItalic, + } + } +} + +/// A font variant with its data and parsed references. +struct FontVariant { + /// Owned font data (kept alive for the lifetime of the font references). + #[allow(dead_code)] + data: Box<[u8]>, + /// ab_glyph font reference for rasterization. + font: FontRef<'static>, + /// rustybuzz face for text shaping. + face: rustybuzz::Face<'static>, +} + /// Result of shaping a text sequence. #[derive(Clone, Debug)] struct ShapedGlyphs { @@ -256,6 +293,10 @@ pub struct Renderer { font_data: Box<[u8]>, /// Primary font for rasterization (borrows font_data) primary_font: FontRef<'static>, + /// Font style variants: [Regular, Bold, Italic, BoldItalic] + /// Each entry is Option because some variants may not be available. + /// Index 0 (Regular) is always Some (same as primary_font's data). + font_variants: [Option; 4], /// Fallback fonts with their owned data fallback_fonts: Vec<(Box<[u8]>, FontRef<'static>)>, /// Fontconfig handle for dynamic font discovery (lazy initialized) @@ -263,9 +304,13 @@ pub struct Renderer { /// Set of font paths we've already tried (to avoid reloading) tried_font_paths: HashSet, shaping_ctx: ShapingContext, + /// OpenType features for shaping (shared across all font variants) + shaping_features: Vec, char_cache: HashMap, // cache char -> rendered glyph ligature_cache: HashMap, // cache multi-char -> shaped glyphs - glyph_cache: HashMap<(usize, u16), GlyphInfo>, // keyed by (font_index, glyph ID) + /// Glyph cache keyed by (font_style, font_index, glyph_id) + /// font_style is FontStyle as usize, font_index is 0 for primary, 1+ for fallbacks + glyph_cache: HashMap<(usize, usize, u16), GlyphInfo>, /// Cache for text run sprites (Kitty-style texture healing). /// Keyed by the text string of the run. Value contains UV coords for each cell. text_run_cache: HashMap, @@ -387,6 +432,191 @@ fn find_font_for_char(_fc: &Fontconfig, c: char) -> Option { } } +// ═══════════════════════════════════════════════════════════════════════════════ +// FONT LOADING HELPER FUNCTIONS +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Try to load a font file and create both ab_glyph and rustybuzz handles. +/// Returns None if the file doesn't exist or can't be parsed. +fn load_font_variant(path: &std::path::Path) -> Option { + let data = std::fs::read(path).ok()?.into_boxed_slice(); + + // Parse with ab_glyph + let font: FontRef<'static> = { + let font = FontRef::try_from_slice(&data).ok()?; + // SAFETY: We keep data alive in the FontVariant struct + unsafe { std::mem::transmute(font) } + }; + + // Parse with rustybuzz + let face: rustybuzz::Face<'static> = { + let face = rustybuzz::Face::from_slice(&data, 0)?; + // SAFETY: We keep data alive in the FontVariant struct + unsafe { std::mem::transmute(face) } + }; + + Some(FontVariant { data, font, face }) +} + +/// Find font files for a font family using fontconfig. +/// Returns paths for (regular, bold, italic, bold_italic). +/// Any variant that can't be found will be None. +fn find_font_family_variants(family: &str) -> [Option; 4] { + use fontconfig_sys as fcsys; + use fcsys::*; + use fcsys::constants::{FC_FAMILY, FC_WEIGHT, FC_SLANT, FC_FILE}; + use std::ffi::CString; + + let mut results: [Option; 4] = [None, None, None, None]; + + // Style queries: (weight, slant) pairs for each variant + // FC_WEIGHT_REGULAR = 80, FC_WEIGHT_BOLD = 200 + // FC_SLANT_ROMAN = 0, FC_SLANT_ITALIC = 100 + let styles: [(i32, i32); 4] = [ + (80, 0), // Regular + (200, 0), // Bold + (80, 100), // Italic + (200, 100), // BoldItalic + ]; + + unsafe { + let family_cstr = match CString::new(family) { + Ok(s) => s, + Err(_) => return results, + }; + + for (idx, (weight, slant)) in styles.iter().enumerate() { + let pat = FcPatternCreate(); + if pat.is_null() { + continue; + } + + // Set family name + FcPatternAddString(pat, FC_FAMILY.as_ptr() as *const i8, family_cstr.as_ptr() as *const u8); + // Set weight + FcPatternAddInteger(pat, FC_WEIGHT.as_ptr() as *const i8, *weight); + // Set slant + FcPatternAddInteger(pat, FC_SLANT.as_ptr() as *const i8, *slant); + + FcConfigSubstitute(std::ptr::null_mut(), pat, FcMatchPattern); + FcDefaultSubstitute(pat); + + let mut result: FcResult = FcResultMatch; + let matched = FcFontMatch(std::ptr::null_mut(), pat, &mut result); + + if result == FcResultMatch && !matched.is_null() { + let mut file_ptr: *mut u8 = std::ptr::null_mut(); + if FcPatternGetString(matched, FC_FILE.as_ptr() as *const i8, 0, &mut file_ptr) == FcResultMatch { + if !file_ptr.is_null() { + let path_cstr = std::ffi::CStr::from_ptr(file_ptr as *const i8); + if let Ok(path_str) = path_cstr.to_str() { + results[idx] = Some(PathBuf::from(path_str)); + } + } + } + FcPatternDestroy(matched); + } + + FcPatternDestroy(pat); + } + } + + results +} + +/// Load font variants for a font family. +/// Returns array of font variants, with index 0 being the regular font. +/// Falls back to hardcoded paths if fontconfig fails. +fn load_font_family(font_family: Option<&str>) -> (Box<[u8]>, FontRef<'static>, [Option; 4]) { + // Try to use fontconfig to find the font family + if let Some(family) = font_family { + let paths = find_font_family_variants(family); + log::info!("Font family '{}' resolved to:", family); + for (i, path) in paths.iter().enumerate() { + let style = match i { + 0 => "Regular", + 1 => "Bold", + 2 => "Italic", + 3 => "BoldItalic", + _ => "Unknown", + }; + if let Some(p) = path { + log::info!(" {}: {:?}", style, p); + } + } + + // Load the regular font (required) + if let Some(regular_path) = &paths[0] { + if let Some(regular) = load_font_variant(regular_path) { + let primary_font = regular.font.clone(); + let font_data = regular.data.clone(); + + // Load other variants + let variants: [Option; 4] = [ + Some(regular), + paths[1].as_ref().and_then(|p| load_font_variant(p)), + paths[2].as_ref().and_then(|p| load_font_variant(p)), + paths[3].as_ref().and_then(|p| load_font_variant(p)), + ]; + + return (font_data, primary_font, variants); + } + } + log::warn!("Failed to load font family '{}', falling back to defaults", family); + } + + // Fallback: try hardcoded paths + let fallback_fonts = [ + ("/usr/share/fonts/TTF/0xProtoNerdFont-Regular.ttf", + "/usr/share/fonts/TTF/0xProtoNerdFont-Bold.ttf", + "/usr/share/fonts/TTF/0xProtoNerdFont-Italic.ttf", + "/usr/share/fonts/TTF/0xProtoNerdFont-BoldItalic.ttf"), + ("/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Regular.ttf", + "/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Bold.ttf", + "/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Italic.ttf", + "/usr/share/fonts/TTF/JetBrainsMonoNerdFont-BoldItalic.ttf"), + ("/usr/share/fonts/TTF/JetBrainsMono-Regular.ttf", + "/usr/share/fonts/TTF/JetBrainsMono-Bold.ttf", + "/usr/share/fonts/TTF/JetBrainsMono-Italic.ttf", + "/usr/share/fonts/TTF/JetBrainsMono-BoldItalic.ttf"), + ]; + + for (regular, bold, italic, bold_italic) in fallback_fonts { + let regular_path = std::path::Path::new(regular); + if let Some(regular_variant) = load_font_variant(regular_path) { + let primary_font = regular_variant.font.clone(); + let font_data = regular_variant.data.clone(); + + let variants: [Option; 4] = [ + Some(regular_variant), + load_font_variant(std::path::Path::new(bold)), + load_font_variant(std::path::Path::new(italic)), + load_font_variant(std::path::Path::new(bold_italic)), + ]; + + log::info!("Loaded font from fallback paths:"); + log::info!(" Regular: {}", regular); + if variants[1].is_some() { log::info!(" Bold: {}", bold); } + if variants[2].is_some() { log::info!(" Italic: {}", italic); } + if variants[3].is_some() { log::info!(" BoldItalic: {}", bold_italic); } + + return (font_data, primary_font, variants); + } + } + + // Last resort: try NotoSansMono + let noto_regular = std::path::Path::new("/usr/share/fonts/noto/NotoSansMono-Regular.ttf"); + if let Some(regular_variant) = load_font_variant(noto_regular) { + let primary_font = regular_variant.font.clone(); + let font_data = regular_variant.data.clone(); + let variants: [Option; 4] = [Some(regular_variant), None, None, None]; + log::info!("Loaded NotoSansMono as fallback"); + return (font_data, primary_font, variants); + } + + panic!("Failed to load any monospace font"); +} + // ═══════════════════════════════════════════════════════════════════════════════ // BOX DRAWING HELPER TYPES // ═══════════════════════════════════════════════════════════════════════════════ @@ -848,41 +1078,17 @@ impl Renderer { }; surface.configure(&device, &surface_config); - // Load primary font - let font_data: Box<[u8]> = std::fs::read("/usr/share/fonts/TTF/0xProtoNerdFont-Regular.ttf") - .or_else(|_| std::fs::read("/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Regular.ttf")) - .or_else(|_| std::fs::read("/usr/share/fonts/TTF/JetBrainsMono-Regular.ttf")) - .or_else(|_| std::fs::read("/usr/share/fonts/noto/NotoSansMono-Regular.ttf")) - .expect("Failed to load any monospace font") - .into_boxed_slice(); - - // Parse font with ab_glyph for rasterization - // SAFETY: We transmute to 'static because font_data lives as long as Renderer. - let primary_font: FontRef<'static> = { - let font = FontRef::try_from_slice(&font_data) - .expect("Failed to parse font with ab_glyph"); - unsafe { std::mem::transmute(font) } - }; + // Load primary font and font variants (regular, bold, italic, bold-italic) + let (font_data, primary_font, font_variants) = load_font_family(config.font_family.as_deref()); // Fontconfig will be initialized lazily on first fallback font lookup - // Start with empty fallback fonts - will be loaded on-demand via fontconfig let fallback_fonts: Vec<(Box<[u8]>, FontRef<'static>)> = Vec::new(); let tried_font_paths: HashSet = HashSet::new(); - // Create rustybuzz Face for text shaping (ligatures). - // SAFETY: We transmute to 'static because font_data lives as long as Renderer. - // The Face only borrows the data, so this is safe as long as we don't drop font_data - // before dropping the Face, which is guaranteed by struct drop order. - let face: rustybuzz::Face<'static> = { - let face = rustybuzz::Face::from_slice(&font_data, 0) - .expect("Failed to parse font for shaping"); - unsafe { std::mem::transmute(face) } - }; - // Enable OpenType features for ligatures and contextual alternates // These are the standard features used by coding fonts like Fira Code, JetBrains Mono, etc. - let features = vec![ + let shaping_features = vec![ // Standard ligatures (fi, fl, etc.) rustybuzz::Feature::new(Tag::from_bytes(b"liga"), 1, ..), // Contextual alternates (programming ligatures like ->, =>, etc.) @@ -890,7 +1096,17 @@ impl Renderer { // Discretionary ligatures (optional ligatures) rustybuzz::Feature::new(Tag::from_bytes(b"dlig"), 1, ..), ]; - let shaping_ctx = ShapingContext { face: face.clone(), features }; + + // Create shaping context using the regular font variant's face + // The face is borrowed from font_variants[0], which is always Some + let shaping_ctx = { + let regular_variant = font_variants[0].as_ref() + .expect("Regular font variant should always be present"); + ShapingContext { + face: regular_variant.face.clone(), + features: shaping_features.clone(), + } + }; // Calculate cell dimensions from font metrics using ab_glyph // @@ -909,42 +1125,6 @@ impl Renderer { // ab_glyph's height() = ascent - descent (where descent is negative) let cell_height = scaled_font.height().round(); - // DEBUG: Compare ab_glyph and rustybuzz advance calculations - { - let units_per_em = face.units_per_em(); - let height_unscaled = primary_font.height_unscaled(); - - // ab_glyph uses: scale / height_unscaled as the scale factor for h_advance - let ab_scale_factor = font_size / height_unscaled; - // We were using: scale / units_per_em (WRONG!) - let our_wrong_scale = font_size / units_per_em as f32; - - // Get 'M' glyph advance from rustybuzz's underlying ttf_parser - let _m_glyph_rb = face.glyph_index('M').map(|g| g.0).unwrap_or(0); - // Let's shape a single 'M' and see what advance we get - let mut buffer = rustybuzz::UnicodeBuffer::new(); - buffer.push_str("M"); - let glyph_buffer = rustybuzz::shape(&face, &[], buffer); - let positions = glyph_buffer.glyph_positions(); - if !positions.is_empty() { - let rb_advance_font_units = positions[0].x_advance; - let rb_advance_px_wrong = rb_advance_font_units as f32 * our_wrong_scale; - let rb_advance_px_correct = rb_advance_font_units as f32 * ab_scale_factor; - let ab_advance_raw = scaled_font.h_advance(m_glyph_id); - eprintln!("DEBUG font metrics:"); - eprintln!(" font_size = {} px", font_size); - eprintln!(" units_per_em = {}", units_per_em); - eprintln!(" height_unscaled = {} (ascent - descent)", height_unscaled); - eprintln!(" ab_glyph scale factor = font_size / height_unscaled = {}", ab_scale_factor); - eprintln!(" our WRONG scale factor = font_size / units_per_em = {}", our_wrong_scale); - eprintln!(" ab_glyph 'M' h_advance (raw) = {}", ab_advance_raw); - eprintln!(" ab_glyph 'M' h_advance (rounded) = {}", cell_width); - eprintln!(" rustybuzz 'M' x_advance (font units) = {}", rb_advance_font_units); - eprintln!(" rustybuzz 'M' x_advance (WRONG pixels) = {}", rb_advance_px_wrong); - eprintln!(" rustybuzz 'M' x_advance (CORRECT pixels) = {}", rb_advance_px_correct); - } - } - // Calculate the correct scale factor for converting font units to pixels. // This matches ab_glyph's calculation: scale / height_unscaled // where height_unscaled = ascent - descent (the font's natural line height). @@ -1297,10 +1477,12 @@ impl Renderer { atlas_dirty: false, font_data, primary_font, + font_variants, fallback_fonts, fontconfig: OnceCell::new(), tried_font_paths, shaping_ctx, + shaping_features, char_cache: HashMap::new(), ligature_cache: HashMap::new(), glyph_cache: HashMap::new(), @@ -3010,8 +3192,12 @@ impl Renderer { /// Get or rasterize a glyph by its glyph ID from the primary font. /// Used for ligatures where we have the glyph ID from rustybuzz. + /// Note: Kept for potential fallback use. Use get_glyph_by_id_with_style for styled text. + #[allow(dead_code)] fn get_glyph_by_id(&mut self, glyph_id: u16) -> GlyphInfo { - let cache_key = (0usize, glyph_id); // font index 0 = primary font + // Cache key: (font_style, font_index, glyph_id) + // For now, we use Regular style (0) and primary font index (0) + let cache_key = (FontStyle::Regular as usize, 0usize, glyph_id); if let Some(info) = self.glyph_cache.get(&cache_key) { return *info; } @@ -3093,8 +3279,107 @@ impl Renderer { info } + /// Get or rasterize a glyph by its glyph ID from a specific font variant. + /// Uses bold/italic font if available, otherwise falls back to regular. + fn get_glyph_by_id_with_style(&mut self, glyph_id: u16, style: FontStyle) -> GlyphInfo { + // Cache key: (font_style, font_index, glyph_id) + // font_index 0 = primary/regular font + let cache_key = (style as usize, 0usize, glyph_id); + if let Some(info) = self.glyph_cache.get(&cache_key) { + return *info; + } + + // Get the font for the requested style + let font = if style == FontStyle::Regular { + self.primary_font.clone() + } else if let Some(ref variant) = self.font_variants[style as usize] { + variant.font.clone() + } else { + // Fall back to regular font if variant not available + self.primary_font.clone() + }; + + // Rasterize the glyph by ID using ab_glyph + let ab_glyph_id = GlyphId(glyph_id); + let raster_result = self.rasterize_glyph_ab(&font, ab_glyph_id); + + let Some((glyph_width, glyph_height, bitmap, offset_x, offset_y)) = raster_result else { + // Empty glyph (e.g., space) + let info = GlyphInfo { + uv: [0.0, 0.0, 0.0, 0.0], + offset: [0.0, 0.0], + size: [0.0, 0.0], + }; + self.glyph_cache.insert(cache_key, info); + return info; + }; + + if bitmap.is_empty() || glyph_width == 0 || glyph_height == 0 { + // Empty glyph (e.g., space) + let info = GlyphInfo { + uv: [0.0, 0.0, 0.0, 0.0], + offset: [0.0, 0.0], + size: [0.0, 0.0], + }; + self.glyph_cache.insert(cache_key, info); + return info; + } + + // Check if we need to move to next row + if self.atlas_cursor_x + glyph_width > ATLAS_SIZE { + self.atlas_cursor_x = 0; + self.atlas_cursor_y += self.atlas_row_height + 1; + self.atlas_row_height = 0; + } + + // Check if atlas is full + if self.atlas_cursor_y + glyph_height > ATLAS_SIZE { + log::warn!("Glyph atlas is full!"); + let info = GlyphInfo { + uv: [0.0, 0.0, 0.0, 0.0], + offset: [0.0, 0.0], + size: [0.0, 0.0], + }; + self.glyph_cache.insert(cache_key, info); + return info; + } + + // Copy bitmap to atlas + for y in 0..glyph_height as usize { + for x in 0..glyph_width as usize { + let src_idx = y * glyph_width as usize + x; + let dst_x = self.atlas_cursor_x + x as u32; + let dst_y = self.atlas_cursor_y + y as u32; + let dst_idx = (dst_y * ATLAS_SIZE + dst_x) as usize; + self.atlas_data[dst_idx] = bitmap[src_idx]; + } + } + self.atlas_dirty = true; + + // Calculate UV coordinates + let uv_x = self.atlas_cursor_x as f32 / ATLAS_SIZE as f32; + let uv_y = self.atlas_cursor_y as f32 / ATLAS_SIZE as f32; + let uv_w = glyph_width as f32 / ATLAS_SIZE as f32; + let uv_h = glyph_height as f32 / ATLAS_SIZE as f32; + + let info = GlyphInfo { + uv: [uv_x, uv_y, uv_w, uv_h], + offset: [offset_x, offset_y], + size: [glyph_width as f32, glyph_height as f32], + }; + + // Update atlas cursor + self.atlas_cursor_x += glyph_width + 1; + self.atlas_row_height = self.atlas_row_height.max(glyph_height); + + self.glyph_cache.insert(cache_key, info); + info + } + /// Shape a text string using HarfBuzz/rustybuzz. /// Returns glyph IDs with advances and offsets for texture healing. + /// Note: Kept for potential fallback use. Use shape_text_with_style for styled text. + #[allow(dead_code)] fn shape_text(&mut self, text: &str) -> ShapedGlyphs { // Check cache first if let Some(cached) = self.ligature_cache.get(text) { @@ -3134,6 +3419,53 @@ impl Renderer { shaped } + /// Shape a text string using HarfBuzz/rustybuzz with a specific font style. + /// Uses the bold/italic font variant if available, otherwise falls back to regular. + fn shape_text_with_style(&mut self, text: &str, style: FontStyle) -> ShapedGlyphs { + // For now, we'll create a cache key that includes style + // TODO: Could optimize by having separate caches per style + let cache_key = format!("{}\x00{}", style as usize, text); + if let Some(cached) = self.ligature_cache.get(&cache_key) { + return cached.clone(); + } + + let mut buffer = UnicodeBuffer::new(); + buffer.push_str(text); + + // Get the face for the requested style, falling back to regular if not available + let face = if style == FontStyle::Regular { + &self.shaping_ctx.face + } else if let Some(ref variant) = self.font_variants[style as usize] { + &variant.face + } else { + // Fall back to regular font + &self.shaping_ctx.face + }; + + // Shape with OpenType features enabled (liga, calt, dlig) + let glyph_buffer = rustybuzz::shape(face, &self.shaping_features, buffer); + let glyph_infos = glyph_buffer.glyph_infos(); + let glyph_positions = glyph_buffer.glyph_positions(); + + let glyphs: Vec<(u16, f32, f32, f32, u32)> = glyph_infos + .iter() + .zip(glyph_positions.iter()) + .map(|(info, pos)| { + let glyph_id = info.glyph_id as u16; + // Note: We don't pre-rasterize here; that happens in render_glyphs_to_canvas_with_style + // Convert from font units to pixels using the correct scale factor. + let x_advance = pos.x_advance as f32 * self.font_units_to_px; + let x_offset = pos.x_offset as f32 * self.font_units_to_px; + let y_offset = pos.y_offset as f32 * self.font_units_to_px; + (glyph_id, x_advance, x_offset, y_offset, info.cluster) + }) + .collect(); + + let shaped = ShapedGlyphs { glyphs }; + self.ligature_cache.insert(cache_key, shaped.clone()); + shaped + } + // ═══════════════════════════════════════════════════════════════════════════ // KITTY-STYLE TEXTURE HEALING: CANVAS-BASED TEXT RUN RENDERING // ═══════════════════════════════════════════════════════════════════════════ @@ -3170,11 +3502,13 @@ impl Renderer { /// Render shaped glyphs into the canvas buffer using HarfBuzz positions. /// This is the core of Kitty's texture healing approach. + /// Note: Kept for potential fallback use. Use render_glyphs_to_canvas_with_style for styled text. /// /// Arguments: /// - `shaped`: The shaped glyph data from HarfBuzz /// - `num_cells`: Number of cells this text run spans /// - `baseline_offset`: Offset from top of cell to baseline (typically cell_height * 0.8) + #[allow(dead_code)] fn render_glyphs_to_canvas(&mut self, shaped: &ShapedGlyphs, num_cells: usize, baseline_offset: f32) { let canvas_width = (self.cell_width as usize) * num_cells; let canvas_height = self.cell_height as usize; @@ -3247,6 +3581,79 @@ impl Renderer { } } + /// Render shaped glyphs into the canvas buffer using a specific font style. + /// This version uses the appropriate font variant for bold/italic text. + fn render_glyphs_to_canvas_with_style(&mut self, shaped: &ShapedGlyphs, num_cells: usize, baseline_offset: f32, style: FontStyle) { + let canvas_width = (self.cell_width as usize) * num_cells; + let canvas_height = self.cell_height as usize; + + // Track cursor position in canvas coordinates + let mut cursor_x: f32 = 0.0; + + for &(glyph_id, x_advance, x_offset, y_offset, _cluster) in &shaped.glyphs { + // Get the rasterized glyph bitmap using the correct font variant + let glyph = self.get_glyph_by_id_with_style(glyph_id, style); + + if glyph.size[0] <= 0.0 || glyph.size[1] <= 0.0 { + // Empty glyph (e.g., space) - just advance cursor + cursor_x += x_advance; + continue; + } + + // Calculate glyph position in canvas: + // - cursor_x: accumulated from x_advance + // - x_offset: HarfBuzz adjustment for texture healing + // - glyph.offset[0]: left bearing from the font + let glyph_x = cursor_x + x_offset + glyph.offset[0]; + + // Y position: baseline_offset is from top to baseline + // glyph.offset[1] is distance from baseline to glyph bottom (negative = below baseline) + // For canvas coords (y=0 at top), we need: baseline_offset - glyph_top + // glyph_top = glyph.offset[1] + glyph.size[1] (since offset is to bottom) + let glyph_y = baseline_offset + y_offset - glyph.offset[1] - glyph.size[1]; + + // Round to integers for pixel placement + let dest_x = glyph_x.round() as i32; + let dest_y = glyph_y.round() as i32; + + // Get glyph bitmap from atlas + // The glyph.uv gives us the location in the atlas + let atlas_x = (glyph.uv[0] * ATLAS_SIZE as f32) as u32; + let atlas_y = (glyph.uv[1] * ATLAS_SIZE as f32) as u32; + let glyph_w = glyph.size[0] as u32; + let glyph_h = glyph.size[1] as u32; + + // Copy glyph bitmap from atlas to canvas, with bounds checking + for gy in 0..glyph_h { + let canvas_y = dest_y + gy as i32; + if canvas_y < 0 || canvas_y >= canvas_height as i32 { + continue; + } + + for gx in 0..glyph_w { + let canvas_x = dest_x + gx as i32; + if canvas_x < 0 || canvas_x >= canvas_width as i32 { + continue; + } + + // Source in atlas + let src_idx = ((atlas_y + gy) * ATLAS_SIZE + (atlas_x + gx)) as usize; + // Destination in canvas + let dst_idx = (canvas_y as usize) * canvas_width + (canvas_x as usize); + + // Blend: use max for overlapping glyphs (simple compositing) + let src_alpha = self.atlas_data[src_idx]; + let dst_alpha = self.canvas_buffer[dst_idx]; + self.canvas_buffer[dst_idx] = src_alpha.max(dst_alpha); + } + } + + // Advance cursor by glyph's advance width. + // Round the advance to ensure it aligns with cell boundaries for monospace fonts. + cursor_x += x_advance.round(); + } + } + /// Extract a single cell from the canvas and upload it to the atlas. /// Returns the GlyphInfo for this cell's sprite. fn extract_cell_from_canvas(&mut self, cell_index: usize, num_cells: usize) -> [f32; 4] { @@ -3295,26 +3702,15 @@ impl Renderer { /// Render a text run using Kitty's canvas-based approach for texture healing. /// Returns TextRunSprites containing UV coordinates for each cell. - fn render_text_run(&mut self, text: &str, num_cells: usize) -> TextRunSprites { - // Check cache first - if let Some(cached) = self.text_run_cache.get(text) { + fn render_text_run(&mut self, text: &str, num_cells: usize, style: FontStyle) -> TextRunSprites { + // Check cache first - include font style in cache key + let cache_key = format!("{}\x00{}", style as usize, text); + if let Some(cached) = self.text_run_cache.get(&cache_key) { return cached.clone(); } - // Shape the text - let shaped = self.shape_text(text); - - // DEBUG: Log shaping results - if num_cells >= 2 { - let total_advance: f32 = shaped.glyphs.iter().map(|g| g.1).sum(); - let expected_width = self.cell_width * num_cells as f32; - eprintln!("DEBUG shape_text: text='{}' num_cells={} num_glyphs={} total_advance={:.2} expected_width={:.2} cell_width={:.2}", - text, num_cells, shaped.glyphs.len(), total_advance, expected_width, self.cell_width); - for (i, &(glyph_id, x_advance, x_offset, _y_offset, cluster)) in shaped.glyphs.iter().enumerate() { - eprintln!(" glyph[{}]: id={} x_advance={:.2} x_offset={:.2} cluster={}", - i, glyph_id, x_advance, x_offset, cluster); - } - } + // Shape the text using the appropriate font variant + let shaped = self.shape_text_with_style(text, style); // Ensure canvas is big enough self.ensure_canvas_size(num_cells); @@ -3323,8 +3719,8 @@ impl Renderer { // Calculate baseline offset (typically ~80% down from top of cell) let baseline_offset = self.cell_height * 0.8; - // Render all glyphs into the canvas - self.render_glyphs_to_canvas(&shaped, num_cells, baseline_offset); + // Render all glyphs into the canvas using the appropriate font variant + self.render_glyphs_to_canvas_with_style(&shaped, num_cells, baseline_offset, style); // Extract each cell from the canvas let mut cells = Vec::with_capacity(num_cells); @@ -3334,7 +3730,7 @@ impl Renderer { } let sprites = TextRunSprites { cells }; - self.text_run_cache.insert(text.to_string(), sprites.clone()); + self.text_run_cache.insert(cache_key, sprites.clone()); sprites } @@ -3491,7 +3887,10 @@ impl Renderer { (color_to_rgba(&cell.fg_color, true), color_to_rgba(&cell.bg_color, false)) }; - // Collect a run of cells with the same fg/bg colors + // Track font style for bold/italic rendering + let base_style = FontStyle::from_flags(cell.bold, cell.italic); + + // Collect a run of cells with the same fg/bg colors AND font style let mut run_text = String::new(); let mut run_cells: Vec<(usize, char, bool)> = Vec::new(); // (col, char, is_box_drawing) @@ -3505,8 +3904,11 @@ impl Renderer { (color_to_rgba(&run_cell.fg_color, true), color_to_rgba(&run_cell.bg_color, false)) }; - if cell_fg != base_fg || cell_bg != base_bg { - break; // Different colors, end this run + // Check if font style (bold/italic) matches + let cell_style = FontStyle::from_flags(run_cell.bold, run_cell.italic); + + if cell_fg != base_fg || cell_bg != base_bg || cell_style != base_style { + break; // Different colors or font style, end this run } let c = run_cell.character; @@ -3587,12 +3989,7 @@ impl Renderer { // Render the text run using canvas approach if there's text to shape let sprites = if !shape_text.is_empty() && shape_indices.len() == run_cells.len() { // All cells are shapeable text - use canvas rendering - // DEBUG: Log what we're shaping - if run_cells.len() >= 2 { - eprintln!("DEBUG render_text_run: text='{}' num_cells={} shape_text='{}'", - run_text, run_cells.len(), shape_text); - } - Some(self.render_text_run(&run_text, run_cells.len())) + Some(self.render_text_run(&run_text, run_cells.len(), base_style)) } else { None };