From e65ab4e579f6bc610bcf972a30e37a421585421a Mon Sep 17 00:00:00 2001 From: Raphael Amorim Date: Wed, 6 May 2026 23:18:10 +0200 Subject: [PATCH] update simd on rio-backend --- Cargo.lock | 12 +- Cargo.toml | 1 + rio-backend/Cargo.toml | 2 +- rio-backend/src/ansi/iterm2_image_protocol.rs | 12 +- .../src/ansi/kitty_graphics_protocol.rs | 23 +- rio-backend/src/batch_utf8.rs | 224 ------------------ rio-backend/src/codepoint_width.rs | 134 +++++++++++ rio-backend/src/crosswords/mod.rs | 147 +++++++++++- rio-backend/src/lib.rs | 3 +- rio-backend/src/performer/handler.rs | 46 ++++ rio-backend/src/performer/parser/mod.rs | 153 ++++++++++-- rio-backend/src/simd_base64.rs | 106 +++++++++ rio-backend/src/simd_utf8.rs | 178 +++++++++++--- sugarloaf/Cargo.toml | 5 +- 14 files changed, 735 insertions(+), 311 deletions(-) delete mode 100644 rio-backend/src/batch_utf8.rs create mode 100644 rio-backend/src/codepoint_width.rs create mode 100644 rio-backend/src/simd_base64.rs diff --git a/Cargo.lock b/Cargo.lock index 5e94c755..08b3e98c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4155,7 +4155,7 @@ dependencies = [ "rio-window", "rustc-hash 2.1.2", "serde", - "simdutf8", + "simdutf", "smallvec", "sugarloaf", "teletypewriter", @@ -4518,10 +4518,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] -name = "simdutf8" -version = "0.1.5" +name = "simdutf" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +checksum = "5f542752c7335a9174e7bb81112c3ca1415a7a6b6ec2c3e840aca347a30f9141" +dependencies = [ + "bitflags 2.11.1", + "cc", +] [[package]] name = "siphasher" diff --git a/Cargo.toml b/Cargo.toml index 5283651d..f378bb79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ smallvec = "1.15.1" # unicode-width = "0.2.0" unicode-width = { package = "unicode-width-16", version = "0.1.0" } +simdutf = "0.7.0" base64 = "0.22.1" image_rs = { package = "image", version = "0.25.10", default-features = false, features = [ "gif", diff --git a/rio-backend/Cargo.toml b/rio-backend/Cargo.toml index 29365937..475f614b 100644 --- a/rio-backend/Cargo.toml +++ b/rio-backend/Cargo.toml @@ -44,7 +44,7 @@ regex-automata = "0.4.9" cursor-icon = { version = "1.1.0", default-features = false } smallvec = { version = "1.13.2", default-features = false } rio-window = { workspace = true } -simdutf8 = { version = "0.1.5", default-features = false } +simdutf = { workspace = true } flate2 = { workspace = true } lazy_static = { workspace = true } diff --git a/rio-backend/src/ansi/iterm2_image_protocol.rs b/rio-backend/src/ansi/iterm2_image_protocol.rs index 2e637be9..61972988 100644 --- a/rio-backend/src/ansi/iterm2_image_protocol.rs +++ b/rio-backend/src/ansi/iterm2_image_protocol.rs @@ -12,9 +12,7 @@ use sugarloaf::{GraphicData, GraphicId, ResizeCommand, ResizeParameter}; use rustc_hash::FxHashMap; use std::str; -use base64::engine::general_purpose::STANDARD as Base64; -use base64::Engine; - +use crate::simd_base64; use crate::simd_utf8; /// Parse the OSC 1337 parameters to add a graphic to the grid. @@ -25,10 +23,10 @@ pub fn parse(params: &[&[u8]]) -> Option { return None; } - let buffer = match Base64.decode(contents) { - Ok(buffer) => buffer, - Err(err) => { - tracing::warn!("Can't decode base64 data: {}", err); + let buffer = match simd_base64::decode(contents) { + Some(buffer) => buffer, + None => { + tracing::warn!("Can't decode iTerm2 base64 image payload"); return None; } }; diff --git a/rio-backend/src/ansi/kitty_graphics_protocol.rs b/rio-backend/src/ansi/kitty_graphics_protocol.rs index 66f9a595..69dbb69b 100644 --- a/rio-backend/src/ansi/kitty_graphics_protocol.rs +++ b/rio-backend/src/ansi/kitty_graphics_protocol.rs @@ -1,7 +1,4 @@ -use base64::{ - engine::general_purpose::{STANDARD as BASE64, STANDARD_NO_PAD}, - Engine, -}; +use crate::simd_base64; use smallvec::SmallVec; use std::collections::HashMap; use std::time::{Duration, Instant}; @@ -935,16 +932,14 @@ fn decode_payload_base64(payload: &[u8]) -> Option> { if payload.is_empty() { return Some(Vec::new()); } - match BASE64.decode(payload) { - Ok(data) => Some(data), - Err(_) => match STANDARD_NO_PAD.decode(payload) { - Ok(data) => Some(data), - Err(e) => { - debug!("Base64 payload decode failed: {:?}", e); - None - } - }, + if let Some(data) = simd_base64::decode(payload) { + return Some(data); + } + if let Some(data) = simd_base64::decode_no_pad(payload) { + return Some(data); } + debug!("Base64 payload decode failed"); + None } /// Error emitted from `create_graphic_data`. Maps directly to kitty @@ -1492,6 +1487,8 @@ fn create_graphic_data(cmd: &KittyGraphicsCommand) -> Result, - /// Maximum buffer size before forcing a flush - max_buffer_size: usize, - /// Minimum chunk size to consider for batching - min_chunk_size: usize, -} - -impl Default for BatchUtf8Processor { - fn default() -> Self { - Self::new() - } -} - -impl BatchUtf8Processor { - /// Create a new batch processor with default settings - pub fn new() -> Self { - Self { - buffer: Vec::with_capacity(8192), // 8KB initial capacity - max_buffer_size: 16384, // 16KB max buffer - min_chunk_size: 64, // Only batch chunks >= 64 bytes - } - } - - /// Create a new batch processor with custom settings - pub fn with_capacity(max_buffer_size: usize, min_chunk_size: usize) -> Self { - Self { - buffer: Vec::with_capacity(max_buffer_size / 2), - max_buffer_size, - min_chunk_size, - } - } - - /// Add a chunk to the batch for validation - /// Returns true if the chunk was batched, false if it should be processed immediately - pub fn try_batch(&mut self, chunk: &[u8]) -> bool { - // Don't batch very small chunks - process them immediately - if chunk.len() < self.min_chunk_size { - return false; - } - - // Don't batch if it would exceed our buffer limit - if self.buffer.len() + chunk.len() > self.max_buffer_size { - return false; - } - - // Add to batch - self.buffer.extend_from_slice(chunk); - true - } - - /// Process all batched chunks and return the results - /// Returns a vector of (offset, length, validation_result) tuples - pub fn flush_batch( - &mut self, - ) -> Vec<(usize, usize, Result<(), simdutf8::basic::Utf8Error>)> { - if self.buffer.is_empty() { - return Vec::new(); - } - - let mut results = Vec::new(); - // For now, we validate the entire buffer at once - // In the future, we could implement chunk boundary tracking - let validation_result = simd_utf8::from_utf8_fast(&self.buffer); - results.push((0, self.buffer.len(), validation_result.map(|_| ()))); - - // Clear the buffer for next batch - self.buffer.clear(); - results - } - - /// Get the current buffer size - pub fn buffer_len(&self) -> usize { - self.buffer.len() - } - - /// Check if the buffer has data waiting to be processed - pub fn has_pending(&self) -> bool { - !self.buffer.is_empty() - } - - /// Force flush if buffer is getting full - pub fn should_flush(&self) -> bool { - self.buffer.len() > self.max_buffer_size / 2 - } -} - -/// Batch validation result -#[derive(Debug)] -pub struct BatchValidationResult { - /// Total bytes processed - pub bytes_processed: usize, - /// Number of chunks in the batch - pub chunk_count: usize, - /// Overall validation success - pub is_valid: bool, - /// First error position if validation failed - pub error_position: Option, -} - -/// High-level batch validation function -pub fn validate_utf8_batch(chunks: &[&[u8]]) -> BatchValidationResult { - if chunks.is_empty() { - return BatchValidationResult { - bytes_processed: 0, - chunk_count: 0, - is_valid: true, - error_position: None, - }; - } - - // Calculate total size - let total_size: usize = chunks.iter().map(|chunk| chunk.len()).sum(); - - // For small total sizes, don't bother batching - if total_size < 256 { - // Validate each chunk individually - let mut bytes_processed = 0; - for (i, chunk) in chunks.iter().enumerate() { - if simd_utf8::from_utf8_fast(chunk).is_err() { - return BatchValidationResult { - bytes_processed, - chunk_count: i, - is_valid: false, - error_position: Some(bytes_processed), - }; - } - bytes_processed += chunk.len(); - } - - return BatchValidationResult { - bytes_processed, - chunk_count: chunks.len(), - is_valid: true, - error_position: None, - }; - } - - // Create a single buffer for batch validation - let mut buffer = Vec::with_capacity(total_size); - for chunk in chunks { - buffer.extend_from_slice(chunk); - } - - // Validate the entire batch at once - let is_valid = simd_utf8::from_utf8_fast(&buffer).is_ok(); - - BatchValidationResult { - bytes_processed: total_size, - chunk_count: chunks.len(), - is_valid, - error_position: if is_valid { None } else { Some(0) }, // TODO: Find exact error position - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_batch_processor_basic() { - let mut processor = BatchUtf8Processor::new(); - - // Small chunk should not be batched - assert!(!processor.try_batch(b"small")); - - // Large chunk should be batched - let large_chunk = "a".repeat(100); - assert!(processor.try_batch(large_chunk.as_bytes())); - assert!(processor.has_pending()); - - // Flush and verify - let results = processor.flush_batch(); - assert_eq!(results.len(), 1); - assert!(results[0].2.is_ok()); - } - - #[test] - fn test_batch_validation() { - let chunks = vec![ - b"Hello, ".as_slice(), - b"world! ".as_slice(), - "🌍 UTF-8 text".as_bytes(), - ]; - - let result = validate_utf8_batch(&chunks); - assert!(result.is_valid); - assert_eq!(result.chunk_count, 3); - assert!(result.bytes_processed > 0); - } - - #[test] - fn test_batch_validation_invalid() { - let chunks = vec![ - b"Valid text".as_slice(), - b"\xFF\xFE invalid".as_slice(), // Invalid UTF-8 - ]; - - let result = validate_utf8_batch(&chunks); - assert!(!result.is_valid); - assert!(result.error_position.is_some()); - } - - #[test] - fn test_buffer_size_limits() { - let mut processor = BatchUtf8Processor::with_capacity(100, 10); - - // Should batch normal chunks - assert!(processor.try_batch(&[b'a'; 50])); - - // Should reject chunk that would exceed limit - assert!(!processor.try_batch(&[b'b'; 60])); - } -} diff --git a/rio-backend/src/codepoint_width.rs b/rio-backend/src/codepoint_width.rs new file mode 100644 index 00000000..f75aeeb8 --- /dev/null +++ b/rio-backend/src/codepoint_width.rs @@ -0,0 +1,134 @@ +//! Fast codepoint width lookup. +//! +//! Mirrors Ghostty's `src/simd/codepoint_width.zig` shape: a flat +//! BMP table indexed by `u32` codepoint, computed once at first use, +//! used for the bulk per-codepoint width queries the parser emits via +//! [`Handler::input_codepoints`]. The table is 64 KiB of `u8` and +//! amortises to zero on subsequent calls. +//! +//! For supplementary-plane codepoints (`U+10000..=U+10FFFF`) we fall +//! back to a scalar [`UnicodeWidthChar::width`] call β€” those are rare +//! in real terminal traffic (modern emoji, CJK extensions) and the +//! 1 MiB flat table that would cover them is not worth the working set. +//! +//! Encoding in the BMP table: +//! - `0xFF` β†’ width undefined (control / surrogate / unassigned). +//! - `0` / `1` / `2` β†’ cell width. +//! +//! [`Handler::input_codepoints`]: crate::performer::handler::Handler::input_codepoints + +use std::sync::OnceLock; +use unicode_width::UnicodeWidthChar; + +const BMP_LEN: usize = 0x1_0000; +const SENTINEL_NONE: u8 = 0xFF; + +static BMP_TABLE: OnceLock> = OnceLock::new(); + +#[inline] +fn bmp_table() -> &'static [u8] { + BMP_TABLE.get_or_init(build_bmp_table) +} + +fn build_bmp_table() -> Box<[u8]> { + let mut table = vec![SENTINEL_NONE; BMP_LEN].into_boxed_slice(); + for cp in 0..BMP_LEN as u32 { + if let Some(c) = char::from_u32(cp) { + if let Some(w) = UnicodeWidthChar::width(c) { + table[cp as usize] = w as u8; + } + } + } + table +} + +/// Lookup the cell width for a Unicode codepoint. +/// +/// Returns `None` for codepoints with no defined width (controls, +/// unassigned, surrogates). For supplementary-plane codepoints this +/// falls back to a scalar `unicode-width` lookup; for BMP codepoints +/// it's a single indexed load from a 64 KiB table populated on first +/// call. +#[inline] +pub fn codepoint_width(cp: u32) -> Option { + if cp < BMP_LEN as u32 { + let w = bmp_table()[cp as usize]; + if w == SENTINEL_NONE { + None + } else { + Some(w) + } + } else { + let c = char::from_u32(cp)?; + UnicodeWidthChar::width(c).map(|w| w as u8) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ascii_printable_is_one() { + for cp in 0x20u32..=0x7E { + assert_eq!(codepoint_width(cp), Some(1), "cp = U+{cp:04X}"); + } + } + + #[test] + fn ascii_control_matches_unicode_width() { + // Whatever `unicode-width-16` decides for control bytes, our + // table must agree with it. Currently `Some(0)` for ASCII + // controls β€” they don't reach this code path in the parser + // (controls are dispatched as `execute`, not `print`), but + // table consistency matters for any caller that probes them. + for cp in [0x00u32, 0x1B, 0x7F] { + let scalar = char::from_u32(cp) + .and_then(UnicodeWidthChar::width) + .map(|w| w as u8); + assert_eq!(codepoint_width(cp), scalar, "cp = U+{cp:04X}"); + } + } + + #[test] + fn cjk_ideograph_is_wide() { + assert_eq!(codepoint_width(0x4E2D), Some(2)); // δΈ­ + assert_eq!(codepoint_width(0x65E5), Some(2)); // ζ—₯ + } + + #[test] + fn vs15_vs16_zero_width() { + assert_eq!(codepoint_width(0xFE0E), Some(0)); + assert_eq!(codepoint_width(0xFE0F), Some(0)); + } + + #[test] + fn supplementary_plane_emoji_wide() { + // πŸŽ‰ U+1F389 + assert_eq!(codepoint_width(0x1F389), Some(2)); + } + + #[test] + fn surrogate_is_none() { + assert_eq!(codepoint_width(0xD800), None); + assert_eq!(codepoint_width(0xDFFF), None); + } + + #[test] + fn invalid_codepoint_is_none() { + assert_eq!(codepoint_width(0x11_0000), None); + } + + #[test] + fn matches_unicode_width_crate_for_bmp_sample() { + // Spot-check that the table produces identical results to the + // scalar crate across a range we care about (printable BMP). + for cp in (0x20u32..0xFFFF).step_by(7) { + let table = codepoint_width(cp); + let scalar = char::from_u32(cp) + .and_then(UnicodeWidthChar::width) + .map(|w| w as u8); + assert_eq!(table, scalar, "cp = U+{cp:04X}"); + } + } +} diff --git a/rio-backend/src/crosswords/mod.rs b/rio-backend/src/crosswords/mod.rs index 0a60c2a7..9531e607 100644 --- a/rio-backend/src/crosswords/mod.rs +++ b/rio-backend/src/crosswords/mod.rs @@ -62,7 +62,6 @@ use std::ptr; use std::sync::Arc; use sugarloaf::{GraphicData, MAX_GRAPHIC_DIMENSIONS}; use tracing::{debug, info, trace, warn}; -use unicode_width::UnicodeWidthChar; use vi_mode::{ViModeCursor, ViMotion}; pub type NamedColor = colors::NamedColor; @@ -2560,7 +2559,7 @@ impl Handler for Crosswords { _ => return, }; - if let Ok(bytes) = general_purpose::STANDARD.decode(base64) { + if let Some(bytes) = crate::simd_base64::decode(base64) { if let Ok(text) = simd_utf8::from_utf8_to_string(&bytes) { self.event_proxy.send_event( RioEvent::ClipboardStore(clipboard_type, text), @@ -2582,8 +2581,8 @@ impl Handler for Crosswords { #[inline(never)] fn input(&mut self, c: char) { - let width = match c.width() { - Some(width) => width, + let width = match crate::codepoint_width::codepoint_width(c as u32) { + Some(w) => w as usize, None => return, }; @@ -2690,6 +2689,146 @@ impl Handler for Crosswords { } } + fn input_codepoints(&mut self, codepoints: &[u32]) { + // Insert mode falls back: cell-rotation is per-char and the cost of + // bulk-handling it correctly outweighs the win. + if self.mode.contains(Mode::INSERT) { + for &cp in codepoints { + let c = char::from_u32(cp).unwrap_or('\u{FFFD}'); + self.input(c); + } + return; + } + + for &cp in codepoints { + let c = char::from_u32(cp).unwrap_or('\u{FFFD}'); + let width = match crate::codepoint_width::codepoint_width(cp) { + Some(w) => w, + None => continue, + }; + + if width == 0 { + // Combining marks, VS15/VS16. Defer to scalar `input` which + // owns the emoji-presentation flip and grapheme-extension + // logic (attaches to preceding cell rather than writing a + // new one). + self.input(c); + continue; + } + + if self.grid.cursor.should_wrap { + self.wrapline(); + } + + let columns = self.grid.columns(); + + // Kitty placeholder bookkeeping: cp can't be ASCII here (parser + // routes ASCII through `input_str`) but the placeholder lives at + // U+10EEEE, so the check is still needed. + if cp == crate::ansi::kitty_virtual::PLACEHOLDER as u32 { + let row = self.grid.cursor.pos.row; + self.grid[row].kitty_virtual_placeholder = true; + } + + if width == 2 { + if self.grid.cursor.pos.col + 1 >= columns { + if self.mode.contains(Mode::LINE_WRAP) { + self.write_at_cursor(' '); + self.grid + .cursor_cell() + .set_wide(crate::crosswords::square::Wide::LeadingSpacer); + self.wrapline(); + } else { + self.grid.cursor.should_wrap = true; + continue; + } + } + + self.write_at_cursor(c); + self.grid + .cursor_cell() + .set_wide(crate::crosswords::square::Wide::Wide); + self.grid.cursor.pos.col += 1; + self.write_at_cursor(' '); + self.grid + .cursor_cell() + .set_wide(crate::crosswords::square::Wide::Spacer); + } else { + // width == 1 + self.write_at_cursor(c); + } + + let cursor_line = self.grid.cursor.pos.row.0 as usize; + self.damage.damage_line(cursor_line); + + if self.grid.cursor.pos.col + 1 < columns { + self.grid.cursor.pos.col += 1; + } else { + self.grid.cursor.should_wrap = true; + } + } + } + + fn input_str(&mut self, s: &str) { + // Fast path: ASCII printable runs are the common case (vim redraws, + // log tails, prompt rendering). Side-step the per-char `input()` + // dispatch which does width lookup, wide-char/zero-width checks, + // kitty placeholder bookkeeping, and per-byte wrap branching. + let active = self.grid.cursor.charsets[self.active_charset]; + if !s.is_ascii() + || active != crate::crosswords::pos::StandardCharset::Ascii + || self.mode.contains(Mode::INSERT) + { + for c in s.chars() { + self.input(c); + } + return; + } + + let bytes = s.as_bytes(); + let mut idx = 0; + while idx < bytes.len() { + if self.grid.cursor.should_wrap { + if !self.mode.contains(Mode::LINE_WRAP) { + // LINE_WRAP off: cursor is parked on the last column and + // each new char overwrites that cell. Defer to scalar + // `input()` for those exact semantics. + for c in s[idx..].chars() { + self.input(c); + } + return; + } + self.wrapline(); + } + + let columns = self.grid.columns(); + let cursor_col = self.grid.cursor.pos.col.0; + let remaining_in_row = columns.saturating_sub(cursor_col); + if remaining_in_row == 0 { + for c in s[idx..].chars() { + self.input(c); + } + return; + } + + let take = (bytes.len() - idx).min(remaining_in_row); + for i in 0..take { + let c = bytes[idx + i] as char; + self.write_at_cursor(c); + if self.grid.cursor.pos.col + 1 < columns { + self.grid.cursor.pos.col += 1; + } else { + self.grid.cursor.should_wrap = true; + break; + } + } + + let row = self.grid.cursor.pos.row; + self.damage.damage_line(row.0 as usize); + idx += take; + } + } + #[inline] fn identify_terminal(&mut self, intermediate: Option) { match intermediate { diff --git a/rio-backend/src/lib.rs b/rio-backend/src/lib.rs index 1037db97..ecfe624d 100644 --- a/rio-backend/src/lib.rs +++ b/rio-backend/src/lib.rs @@ -1,12 +1,13 @@ pub mod ansi; -pub mod batch_utf8; pub mod clipboard; +pub mod codepoint_width; pub mod config; pub mod crosswords; pub mod error; pub mod event; pub mod performer; pub mod selection; +pub mod simd_base64; pub mod simd_utf8; #[cfg(test)] diff --git a/rio-backend/src/performer/handler.rs b/rio-backend/src/performer/handler.rs index 28458b7d..36513ad1 100644 --- a/rio-backend/src/performer/handler.rs +++ b/rio-backend/src/performer/handler.rs @@ -104,6 +104,29 @@ pub trait Handler { /// A character to be displayed. fn input(&mut self, _c: char) {} + /// A contiguous run of characters to be displayed. The default + /// implementation iterates and calls [`Handler::input`]; implementers + /// can specialize to batch the cell writes when the run satisfies a + /// fast-path predicate (e.g. ASCII printable, default charset, no + /// insert mode). + fn input_str(&mut self, s: &str) { + for c in s.chars() { + self.input(c); + } + } + + /// A contiguous run of pre-decoded Unicode codepoints to be displayed. + /// Default implementation iterates and calls [`Handler::input`]. + /// Implementers can specialize to bulk-process codepoints (SIMD width + /// lookup + bulk cell write), which is what makes the parser's + /// `simdutf` UTF-8 β†’ u32 transcode worthwhile end-to-end. + fn input_codepoints(&mut self, codepoints: &[u32]) { + for &cp in codepoints { + let c = char::from_u32(cp).unwrap_or('\u{FFFD}'); + self.input(c); + } + } + /// Set cursor to position. fn goto(&mut self, _: Line, _: Column) {} @@ -869,6 +892,29 @@ impl Perform for Performer<'_, U> { self.state.preceding_char = Some(c); } + #[inline] + fn print_str(&mut self, s: &str) { + if s.is_empty() { + return; + } + self.handler.input_str(s); + // `preceding_char` is used by REP (CSI Ps b) β€” it just needs the + // last printed char, so keep it cheap by reading the last char of + // `s` rather than tracking per-byte. + self.state.preceding_char = s.chars().next_back(); + } + + #[inline] + fn print_codepoints(&mut self, codepoints: &[u32]) { + if codepoints.is_empty() { + return; + } + self.handler.input_codepoints(codepoints); + if let Some(&last) = codepoints.last() { + self.state.preceding_char = char::from_u32(last); + } + } + fn execute(&mut self, byte: u8) { tracing::trace!("[execute] {byte:04x}"); diff --git a/rio-backend/src/performer/parser/mod.rs b/rio-backend/src/performer/parser/mod.rs index 6424236a..d9946e0c 100644 --- a/rio-backend/src/performer/parser/mod.rs +++ b/rio-backend/src/performer/parser/mod.rs @@ -43,6 +43,10 @@ pub(crate) struct Parser { ignoring: bool, partial_utf8: [u8; 4], partial_utf8_len: usize, + /// Reused output buffer for [`simdutf::convert_utf8_to_utf32`]. Grows + /// to fit the largest non-ASCII ground-state chunk seen so far; empty + /// in steady state when sessions are pure ASCII. + decode_buf: Vec, } /// OSC accumulator with a fixed-size inline buffer and a heap fallback. @@ -666,9 +670,9 @@ impl Parser { return 1; } - match simdutf8::basic::from_utf8(&bytes[..plain_chars]) { + match crate::simd_utf8::validate(&bytes[..plain_chars]) { Ok(parsed) => { - Self::ground_dispatch(performer, parsed); + self.ground_dispatch(performer, parsed); let mut processed = plain_chars; // If there's another character, it must be escape so process it directly. @@ -681,17 +685,13 @@ impl Parser { processed } // Handle invalid and partial utf8. - Err(_) => { - // Use simdutf8::compat::from_utf8 to get detailed error information - let compat_err = - simdutf8::compat::from_utf8(&bytes[..plain_chars]).unwrap_err(); - + Err(err) => { // Dispatch all the valid bytes. - let valid_bytes = compat_err.valid_up_to(); + let valid_bytes = err.valid_up_to(); let parsed = unsafe { str::from_utf8_unchecked(&bytes[..valid_bytes]) }; - Self::ground_dispatch(performer, parsed); + self.ground_dispatch(performer, parsed); - match compat_err.error_len() { + match err.error_len() { Some(len) => { // Execute C1 escapes or emit replacement character. if len == 1 && bytes[valid_bytes] <= 0x9F { @@ -746,7 +746,7 @@ impl Parser { self.partial_utf8_len += to_copy; // Parse the unicode character. - match simdutf8::basic::from_utf8(&self.partial_utf8[..self.partial_utf8_len]) { + match crate::simd_utf8::validate(&self.partial_utf8[..self.partial_utf8_len]) { // If the entire buffer is valid, use the first character and continue parsing. Ok(parsed) => { // SAFETY: `partial_utf8_len >= 1` (caller guarantee) and `parsed` @@ -758,21 +758,16 @@ impl Parser { self.partial_utf8_len = 0; c.len_utf8() - old_bytes } - Err(_) => { - // Use simdutf8::compat::from_utf8 to get detailed error information - let compat_err = simdutf8::compat::from_utf8( - &self.partial_utf8[..self.partial_utf8_len], - ) - .unwrap_err(); - let valid_bytes = compat_err.valid_up_to(); + Err(err) => { + let valid_bytes = err.valid_up_to(); // If we have any valid bytes, that means we partially copied another // utf8 character into `partial_utf8`. Since we only care about the // first character, we just ignore the rest. if valid_bytes > 0 { // SAFETY: `valid_bytes > 0` and the slice up to `valid_bytes` was - // reported as valid UTF-8 by the compat decoder, so it contains - // at least one full character. + // reported as valid UTF-8, so it contains at least one full + // character. let c = unsafe { let parsed = str::from_utf8_unchecked(&self.partial_utf8[..valid_bytes]); @@ -785,7 +780,7 @@ impl Parser { return valid_bytes - old_bytes; } - match compat_err.error_len() { + match err.error_len() { // If the partial character was also invalid, emit the replacement // character. Some(invalid_len) => { @@ -802,14 +797,97 @@ impl Parser { } /// Handle ground dispatch of print/execute for all characters in a string. + /// + /// Three batched paths: + /// - ASCII printable runs (`0x20..=0x7E`) β†’ one [`Perform::print_str`]. + /// - ASCII control bytes (`0x00..=0x1F`, `0x7F`) β†’ [`Perform::execute`]. + /// - Multi-byte UTF-8 runs (any byte β‰₯ `0x80`) β†’ SIMD-decoded via + /// `simdutf::convert_utf8_to_utf32` into [`Self::decode_buf`], then + /// dispatched as one [`Perform::print_codepoints`] call (with C1 + /// controls `U+0080..U+009F` split out as individual `execute`). #[inline] - fn ground_dispatch(performer: &mut P, text: &str) { - for c in text.chars() { - match c { - '\x00'..='\x1f' | '\u{80}'..='\u{9f}' => performer.execute(c as u8), - _ => performer.print(c), + fn ground_dispatch(&mut self, performer: &mut P, text: &str) { + let bytes = text.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + + if (0x20..=0x7E).contains(&b) { + // LLVM auto-vectorizes this byte-range scan into a SIMD compare. + let end = bytes[i..] + .iter() + .position(|&b| !(0x20..=0x7E).contains(&b)) + .map(|p| i + p) + .unwrap_or(bytes.len()); + // SAFETY: every byte in 0x20..=0x7E is valid 1-byte UTF-8. + let chunk = unsafe { std::str::from_utf8_unchecked(&bytes[i..end]) }; + performer.print_str(chunk); + i = end; + } else if b < 0x80 { + performer.execute(b); + i += 1; + } else { + // Multi-byte UTF-8 run. Find its end (next ASCII byte). + let end = bytes[i..] + .iter() + .position(|&b| b < 0x80) + .map(|p| i + p) + .unwrap_or(bytes.len()); + // SAFETY: caller already validated that `bytes` is well-formed + // UTF-8, and `i` lands on a codepoint boundary because every + // preceding byte was 1-byte ASCII. + self.decode_codepoints(&bytes[i..end]); + Self::dispatch_codepoints(performer, &self.decode_buf); + i = end; + } + } + } + + /// SIMD-transcode a validated UTF-8 byte slice into [`Self::decode_buf`] + /// as `u32` codepoints, sized exactly to the decoded count. + #[inline] + fn decode_codepoints(&mut self, src: &[u8]) { + self.decode_buf.clear(); + // Worst-case 1 codepoint per byte (only true for ASCII; for the + // β‰₯0x80 runs we feed in here it's ≀ src.len() / 2). Reserve to + // src.len() so the FFI pointer write is always in-bounds. + self.decode_buf.reserve(src.len()); + // SAFETY: + // - `src` is valid UTF-8 (caller invariant). + // - `decode_buf.capacity() >= src.len() β‰₯ #codepoints`. + // - The buffer is exclusively borrowed. + let written = unsafe { + simdutf::convert_utf8_to_utf32( + src.as_ptr(), + src.len(), + self.decode_buf.as_mut_ptr(), + ) + }; + // SAFETY: `simdutf` wrote exactly `written` `u32` values starting + // at the buffer's data pointer. + unsafe { + self.decode_buf.set_len(written); + } + } + + /// Split a decoded codepoint slice on C1 control codepoints + /// (`U+0080..=U+009F`) and emit non-control runs as + /// [`Perform::print_codepoints`], C1 codepoints as [`Perform::execute`]. + #[inline] + fn dispatch_codepoints(performer: &mut P, codepoints: &[u32]) { + let mut start = 0; + for (i, &cp) in codepoints.iter().enumerate() { + if (0x80..=0x9F).contains(&cp) { + if start < i { + performer.print_codepoints(&codepoints[start..i]); + } + performer.execute(cp as u8); + start = i + 1; } } + if start < codepoints.len() { + performer.print_codepoints(&codepoints[start..]); + } } } @@ -846,6 +924,29 @@ pub(crate) trait Perform { /// Draw a character to the screen and update states. fn print(&mut self, _c: char) {} + /// Draw a contiguous run of characters in one batch. The default + /// implementation iterates and calls [`Perform::print`]; implementers + /// can specialize to skip per-char trait dispatch and per-char width + /// lookup when the run is known to be all narrow / printable / non- + /// combining (e.g. ASCII). + fn print_str(&mut self, s: &str) { + for c in s.chars() { + self.print(c); + } + } + + /// Draw a contiguous run of pre-decoded Unicode codepoints in one batch. + /// The default implementation iterates, converts each `u32` to `char` + /// (replacing invalid encodings with U+FFFD), and calls + /// [`Perform::print`]. Implementers can specialize to bulk-process + /// codepoints β€” e.g. SIMD width lookup + bulk cell write. + fn print_codepoints(&mut self, codepoints: &[u32]) { + for &cp in codepoints { + let c = char::from_u32(cp).unwrap_or('\u{FFFD}'); + self.print(c); + } + } + /// Execute a C0 or C1 control function. fn execute(&mut self, _byte: u8) {} diff --git a/rio-backend/src/simd_base64.rs b/rio-backend/src/simd_base64.rs new file mode 100644 index 00000000..853ff2e4 --- /dev/null +++ b/rio-backend/src/simd_base64.rs @@ -0,0 +1,106 @@ +//! SIMD base64 helpers backed by `simdutf`. +//! +//! Replaces the scalar `base64` crate's `Engine::decode` for the three hot +//! base64 paths in rio: +//! - OSC 52 clipboard_store (small, but synchronous on every paste). +//! - iTerm2 OSC 1337 inline images. +//! - Kitty graphics protocol APC chunks (4 KB+ per chunk, very hot during +//! image-heavy TUIs). + +use simdutf::{ + base64_to_binary, maximal_binary_length_from_base64, Base64Options, ErrorCode, + LastChunkHandlingOptions, +}; + +/// Decode a standard-alphabet base64 byte slice (`+` and `/`, with padding) +/// to a freshly-allocated `Vec`. Returns `None` on invalid input. +#[inline] +pub fn decode(input: &[u8]) -> Option> { + decode_with_options( + input, + Base64Options::Default, + LastChunkHandlingOptions::Loose, + ) +} + +/// Decode a standard-alphabet base64 byte slice without padding. +#[inline] +pub fn decode_no_pad(input: &[u8]) -> Option> { + decode_with_options( + input, + Base64Options::DefaultNoPadding, + LastChunkHandlingOptions::Loose, + ) +} + +#[inline] +fn decode_with_options( + input: &[u8], + options: Base64Options, + last_chunk: LastChunkHandlingOptions, +) -> Option> { + if input.is_empty() { + return Some(Vec::new()); + } + // SAFETY: `input` is a valid byte slice. + let max_len = + unsafe { maximal_binary_length_from_base64(input.as_ptr(), input.len()) }; + let mut out = Vec::with_capacity(max_len); + // SAFETY: + // - `input` is valid for reads of `input.len()` bytes. + // - `out` has capacity for `max_len` bytes; `simdutf` writes at most that. + // - `out.as_mut_ptr()` is an exclusive write pointer. + let result = unsafe { + base64_to_binary( + input.as_ptr(), + input.len(), + out.as_mut_ptr(), + options, + last_chunk, + ) + }; + if result.error != ErrorCode::Success { + return None; + } + // SAFETY: `simdutf` wrote exactly `result.count` bytes into the buffer. + unsafe { + out.set_len(result.count); + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_empty() { + assert_eq!(decode(b"").unwrap(), b""); + } + + #[test] + fn decode_basic_padded() { + assert_eq!(decode(b"aGVsbG8gd29ybGQ=").unwrap(), b"hello world"); + } + + #[test] + fn decode_basic_unpadded_via_no_pad() { + assert_eq!(decode_no_pad(b"aGVsbG8gd29ybGQ").unwrap(), b"hello world"); + } + + #[test] + fn decode_invalid() { + assert!(decode(b"not!valid#base64").is_none()); + } + + #[test] + fn decode_round_trip_kitty_chunk() { + // ~4 KB payload typical of kitty graphics. + let bytes: Vec = (0..4096).map(|i| (i & 0xff) as u8).collect(); + use base64::engine::general_purpose::STANDARD; + use base64::Engine; + let encoded = STANDARD.encode(&bytes); + let decoded = decode(encoded.as_bytes()).unwrap(); + assert_eq!(decoded, bytes); + } +} diff --git a/rio-backend/src/simd_utf8.rs b/rio-backend/src/simd_utf8.rs index aa039d4c..4cd8114e 100644 --- a/rio-backend/src/simd_utf8.rs +++ b/rio-backend/src/simd_utf8.rs @@ -1,41 +1,137 @@ -//! SIMD-accelerated UTF-8 validation and conversion utilities for Rio terminal. -//! -//! This module provides high-performance UTF-8 processing using the simdutf8 crate, -//! which can be up to 23x faster than std library validation on non-ASCII text. +use std::fmt; -/// Fast UTF-8 validation and conversion to string slice. +/// UTF-8 validation error with the same shape as `simdutf8`'s compat error +/// type: a successful prefix length, plus an optional invalid-sequence +/// length (`None` = the input ended mid-sequence and more bytes are +/// needed). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Utf8Error { + valid_up_to: usize, + error_len: Option, +} + +impl Utf8Error { + /// Number of leading bytes that successfully validated. + #[inline] + pub fn valid_up_to(&self) -> usize { + self.valid_up_to + } + + /// Length of the invalid sequence, or `None` if the input ended + /// mid-codepoint (caller should buffer for the next chunk). + #[inline] + pub fn error_len(&self) -> Option { + self.error_len + } +} + +impl fmt::Display for Utf8Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.error_len { + Some(len) => write!( + f, + "invalid utf-8 sequence of {} bytes from index {}", + len, self.valid_up_to + ), + None => write!( + f, + "incomplete utf-8 byte sequence from index {}", + self.valid_up_to + ), + } + } +} + +impl std::error::Error for Utf8Error {} + +/// Validate `bytes` and return the corresponding `&str` on success. /// -/// Uses SIMD acceleration when available, falls back to std implementation otherwise. -/// This is optimized for valid UTF-8 (common case in terminal processing). +/// Single SIMD pass via `simdutf::validate_utf8_with_errors` β€” gives both +/// the success-as-`&str` view and the (`valid_up_to`, `error_len`) pair on +/// failure, replacing the `simdutf8` "basic + compat" two-call dance. #[inline] -pub fn from_utf8_fast(bytes: &[u8]) -> Result<&str, simdutf8::basic::Utf8Error> { - simdutf8::basic::from_utf8(bytes) +pub fn validate(bytes: &[u8]) -> Result<&str, Utf8Error> { + let result = simdutf::validate_utf8_with_errors(bytes); + if result.error == simdutf::ErrorCode::Success { + // SAFETY: `simdutf` confirmed the entire byte slice is valid UTF-8. + return Ok(unsafe { std::str::from_utf8_unchecked(bytes) }); + } + let valid_up_to = result.count; + let error_len = compute_error_len(bytes, valid_up_to); + Err(Utf8Error { + valid_up_to, + error_len, + }) } -/// Fast UTF-8 validation with basic error information. -/// -/// Uses SIMD acceleration with basic error reporting. -/// Use this when you don't need detailed error position information. +/// Determine the length of the invalid UTF-8 sequence starting at +/// `valid_up_to`, mirroring `simdutf8::compat::from_utf8`'s `error_len()` +/// semantics: `Some(n)` for a definitively-invalid `n`-byte sequence, +/// `None` for an unfinished sequence at end-of-input. +fn compute_error_len(bytes: &[u8], valid_up_to: usize) -> Option { + if valid_up_to >= bytes.len() { + return None; + } + let lead = bytes[valid_up_to]; + + // Determine the expected sequence length from the lead byte. + let expected_len = match lead { + 0x00..=0x7F => return Some(1), // unreachable in practice + 0x80..=0xBF => return Some(1), // unexpected continuation + 0xC0..=0xC1 => return Some(1), // overlong 2-byte lead + 0xC2..=0xDF => 2, + 0xE0..=0xEF => 3, + 0xF0..=0xF4 => 4, + 0xF5..=0xFF => return Some(1), // out-of-range lead + }; + + let remaining = bytes.len() - valid_up_to; + if remaining < expected_len { + // Either truncated (None) or invalid continuation in the partial bytes. + let mut i = 1; + while i < remaining { + if bytes[valid_up_to + i] & 0xC0 != 0x80 { + return Some(i); + } + i += 1; + } + return None; + } + + // Find the first non-continuation byte in the expected range. + let mut i = 1; + while i < expected_len { + if bytes[valid_up_to + i] & 0xC0 != 0x80 { + return Some(i); + } + i += 1; + } + // All continuation bytes look right but `simdutf` flagged the sequence β€” + // overlong, surrogate, or out-of-range codepoint. + Some(expected_len) +} + +/// Compatibility shim β€” same as [`validate`]. #[inline] -pub fn from_utf8_compat(bytes: &[u8]) -> Result<&str, simdutf8::basic::Utf8Error> { - simdutf8::basic::from_utf8(bytes) +pub fn from_utf8_fast(bytes: &[u8]) -> Result<&str, Utf8Error> { + validate(bytes) } -/// Fast UTF-8 validation and conversion to owned String. -/// -/// Optimized for terminal text processing where we often need owned strings. +/// Compatibility shim β€” same as [`validate`]. #[inline] -pub fn from_utf8_to_string(bytes: &[u8]) -> Result { - simdutf8::basic::from_utf8(bytes).map(|s| s.to_string()) +pub fn from_utf8_compat(bytes: &[u8]) -> Result<&str, Utf8Error> { + validate(bytes) } -/// Fast UTF-8 validation with lossy conversion fallback. -/// -/// Uses SIMD validation first, falls back to lossy conversion for invalid UTF-8. -/// This is useful for handling potentially corrupted terminal input. +#[inline] +pub fn from_utf8_to_string(bytes: &[u8]) -> Result { + validate(bytes).map(|s| s.to_string()) +} + +/// Validate; on failure fall back to `String::from_utf8_lossy`. #[inline] pub fn from_utf8_lossy_fast(bytes: &[u8]) -> String { - match simdutf8::basic::from_utf8(bytes) { + match validate(bytes) { Ok(s) => s.to_string(), Err(_) => String::from_utf8_lossy(bytes).to_string(), } @@ -57,7 +153,6 @@ mod tests { let bytes = b"Hello, \xFF invalid UTF-8!"; assert!(from_utf8_fast(bytes).is_err()); - // Test lossy conversion let result = from_utf8_lossy_fast(bytes); assert!(result.contains("Hello")); assert!(result.contains("invalid UTF-8!")); @@ -74,7 +169,36 @@ mod tests { fn test_compat_error_info() { let bytes = b"Valid\xFF\xFEInvalid"; let err = from_utf8_compat(bytes).unwrap_err(); - // Basic error type doesn't provide detailed position info assert!(err.to_string().contains("invalid utf-8")); + assert_eq!(err.valid_up_to(), 5); + assert_eq!(err.error_len(), Some(1)); + } + + #[test] + fn truncated_utf8_returns_none() { + // Lead byte for a 4-byte sequence, only 2 bytes provided. + let bytes = b"\xF0\x9F"; + let err = validate(bytes).unwrap_err(); + assert_eq!(err.valid_up_to(), 0); + assert_eq!(err.error_len(), None); + } + + #[test] + fn truncated_with_bad_continuation_returns_some() { + // Lead byte for 3-byte sequence, second byte is invalid continuation. + let bytes = b"\xE0\x20"; + let err = validate(bytes).unwrap_err(); + assert_eq!(err.valid_up_to(), 0); + assert_eq!(err.error_len(), Some(1)); + } + + #[test] + fn complete_invalid_sequence_returns_some_len() { + // Surrogate codepoint encoded in UTF-8 (3 bytes) β€” valid bytes, + // invalid Unicode value. + let bytes = b"\xED\xA0\x80"; // U+D800, surrogate + let err = validate(bytes).unwrap_err(); + assert_eq!(err.valid_up_to(), 0); + assert_eq!(err.error_len(), Some(3)); } } diff --git a/sugarloaf/Cargo.toml b/sugarloaf/Cargo.toml index f3df3a0a..59a9ff7a 100644 --- a/sugarloaf/Cargo.toml +++ b/sugarloaf/Cargo.toml @@ -118,10 +118,7 @@ ash = "0.38.0" # discovery (see `font/windows.rs`). Pure DirectWrite via `IDWriteFontFallback` # would be the moral twin of CoreText's `CTFontCreateForString`, but the # only Debian-packaged DirectWrite binding (winapi 0.3) is fading out and the -# higher-level `windows` crate isn't in Debian at all. Ghostty itself -# doesn't ship Windows discovery yet β€” the font-kit walk keeps Windows on -# par with Linux's previous (no-cascade) state plus codepoint discovery -# from the installed font set, which is the win we actually care about. +# higher-level `windows` crate isn't in Debian at all. [dev-dependencies] rio-window = { workspace = true } -- 2.51.2