diff --git a/Cargo.lock b/Cargo.lock index bde9f71ab8..aad6f2cb7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -853,7 +853,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "copa" -version = "0.3.11" +version = "0.3.12" dependencies = [ "arrayvec", "criterion", @@ -879,7 +879,7 @@ dependencies = [ [[package]] name = "corcovado" -version = "0.3.11" +version = "0.3.12" dependencies = [ "bytes", "cfg-if 0.1.10", @@ -984,6 +984,18 @@ dependencies = [ "libc", ] +[[package]] +name = "core-text" +version = "21.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" +dependencies = [ + "core-foundation 0.10.1", + "core-graphics 0.24.0", + "foreign-types", + "libc", +] + [[package]] name = "coreaudio-rs" version = "0.11.3" @@ -1544,7 +1556,7 @@ dependencies = [ "byteorder", "core-foundation 0.9.4", "core-graphics 0.23.2", - "core-text", + "core-text 20.1.0", "dirs", "dwrote", "float-ord", @@ -4095,7 +4107,7 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "rio-backend" -version = "0.3.11" +version = "0.3.12" dependencies = [ "base64", "bitflags 2.11.0", @@ -4133,7 +4145,7 @@ dependencies = [ [[package]] name = "rio-grapheme-width" -version = "0.3.11" +version = "0.3.12" dependencies = [ "phf", "ucd-trie", @@ -4141,7 +4153,7 @@ dependencies = [ [[package]] name = "rio-notifier" -version = "0.3.11" +version = "0.3.12" dependencies = [ "block2 0.5.1", "objc-rs 0.3.1", @@ -4154,7 +4166,7 @@ dependencies = [ [[package]] name = "rio-proc-macros" -version = "0.3.11" +version = "0.3.12" dependencies = [ "proc-macro2", "quote", @@ -4162,7 +4174,7 @@ dependencies = [ [[package]] name = "rio-window" -version = "0.3.11" +version = "0.3.12" dependencies = [ "ahash", "atomic-waker", @@ -4216,7 +4228,7 @@ dependencies = [ [[package]] name = "rioterm" -version = "0.3.11" +version = "0.3.12" dependencies = [ "ahash", "bitflags 2.11.0", @@ -4744,15 +4756,17 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "sugarloaf" -version = "0.3.11" +version = "0.3.12" dependencies = [ "approx", "block", "bytemuck", "console_error_panic_hook", "console_log", + "core-foundation 0.10.1", "core-graphics 0.24.0", "core-graphics-types 0.2.0", + "core-text 21.0.0", "criterion", "dashmap", "deflate", @@ -4853,7 +4867,7 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "teletypewriter" -version = "0.3.11" +version = "0.3.12" dependencies = [ "corcovado", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 816857d047..0d98b5fb28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.3.11" +version = "0.3.12" authors = ["Raphael Amorim "] edition = "2021" license = "MIT" @@ -30,17 +30,17 @@ readme = "README.md" # Note: https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#multiple-locations # Sugarloaf example uses path when used locally, but uses # version from crates.io when published. -teletypewriter = { path = "teletypewriter", version = "0.3.11" } -rio-backend = { path = "rio-backend", version = "0.3.11" } -rio-window = { path = "rio-window", version = "0.3.11", default-features = false } -rio-notifier = { path = "rio-notifier", version = "0.3.11" } -rio-grapheme-width = { path = "rio-grapheme-width", version = "0.3.11" } -sugarloaf = { path = "sugarloaf", version = "0.3.11" } +teletypewriter = { path = "teletypewriter", version = "0.3.12" } +rio-backend = { path = "rio-backend", version = "0.3.12" } +rio-window = { path = "rio-window", version = "0.3.12", default-features = false } +rio-notifier = { path = "rio-notifier", version = "0.3.12" } +rio-grapheme-width = { path = "rio-grapheme-width", version = "0.3.12" } +sugarloaf = { path = "sugarloaf", version = "0.3.12" } # Own dependencies -copa = { path = "copa", default-features = true, version = "0.3.11" } -rio-proc-macros = { path = "rio-proc-macros", version = "0.3.11" } -corcovado = { path = "corcovado", version = "0.3.11" } +copa = { path = "copa", default-features = true, version = "0.3.12" } +rio-proc-macros = { path = "rio-proc-macros", version = "0.3.12" } +corcovado = { path = "corcovado", version = "0.3.12" } raw-window-handle = { version = "0.6.2", features = ["std"] } parking_lot = { version = "0.12.5", features = [ "nightly", diff --git a/corcovado/test/test_broken_pipe.rs b/corcovado/test/test_broken_pipe.rs deleted file mode 100644 index 44ffe193c7..0000000000 --- a/corcovado/test/test_broken_pipe.rs +++ /dev/null @@ -1,31 +0,0 @@ -use corcovado::deprecated::{unix, EventLoop, Handler}; -use corcovado::{PollOpt, Ready, Token}; -use std::time::Duration; - -pub struct BrokenPipeHandler; - -impl Handler for BrokenPipeHandler { - type Timeout = (); - type Message = (); - fn ready(&mut self, _: &mut EventLoop, token: Token, _: Ready) { - if token == Token(1) { - panic!("Received ready() on a closed pipe."); - } - } -} - -#[test] -pub fn broken_pipe() { - let mut event_loop: EventLoop = EventLoop::new().unwrap(); - let (reader, _) = unix::pipe().unwrap(); - - event_loop - .register(&reader, Token(1), Ready::all(), PollOpt::edge()) - .unwrap(); - - let mut handler = BrokenPipeHandler; - drop(reader); - event_loop - .run_once(&mut handler, Some(Duration::from_millis(1000))) - .unwrap(); -} diff --git a/corcovado/test/test_close_on_drop.rs b/corcovado/test/test_close_on_drop.rs deleted file mode 100644 index 9679ea767c..0000000000 --- a/corcovado/test/test_close_on_drop.rs +++ /dev/null @@ -1,123 +0,0 @@ -// use bytes::ByteBuf; -// use corcovado::net::{TcpListener, TcpStream}; -// use corcovado::{Events, Poll, PollOpt, Ready, Token}; -// use {localhost, TryRead}; - -// use self::TestState::{AfterRead, Initial}; - -// const SERVER: Token = Token(0); -// const CLIENT: Token = Token(1); - -// #[derive(Debug, PartialEq)] -// enum TestState { -// Initial, -// AfterRead, -// } - -// struct TestHandler { -// srv: TcpListener, -// cli: TcpStream, -// state: TestState, -// shutdown: bool, -// } - -// impl TestHandler { -// fn new(srv: TcpListener, cli: TcpStream) -> TestHandler { -// TestHandler { -// srv, -// cli, -// state: Initial, -// shutdown: false, -// } -// } - -// fn handle_read(&mut self, poll: &mut Poll, tok: Token, events: Ready) { -// debug!("readable; tok={:?}; hint={:?}", tok, events); - -// match tok { -// SERVER => { -// debug!("server connection ready for accept"); -// let _ = self.srv.accept().unwrap(); -// } -// CLIENT => { -// debug!("client readable"); - -// match self.state { -// Initial => { -// let mut buf = [0; 4096]; -// debug!("GOT={:?}", self.cli.try_read(&mut buf[..])); -// self.state = AfterRead; -// } -// AfterRead => {} -// } - -// let mut buf = ByteBuf::mut_with_capacity(1024); - -// match self.cli.try_read_buf(&mut buf) { -// Ok(Some(0)) => self.shutdown = true, -// Ok(_) => panic!("the client socket should not be readable"), -// Err(e) => panic!("Unexpected error {:?}", e), -// } -// } -// _ => panic!("received unknown token {:?}", tok), -// } -// poll.reregister(&self.cli, CLIENT, Ready::readable(), PollOpt::edge()) -// .unwrap(); -// } - -// fn handle_write(&mut self, poll: &mut Poll, tok: Token, _: Ready) { -// match tok { -// SERVER => panic!("received writable for token 0"), -// CLIENT => { -// debug!("client connected"); -// poll.reregister(&self.cli, CLIENT, Ready::readable(), PollOpt::edge()) -// .unwrap(); -// } -// _ => panic!("received unknown token {:?}", tok), -// } -// } -// } - -// #[test] -// pub fn test_close_on_drop() { -// let _ = ::env_logger::init(); -// debug!("Starting TEST_CLOSE_ON_DROP"); -// let mut poll = Poll::new().unwrap(); - -// // The address to connect to - localhost + a unique port -// let addr = localhost(); - -// // == Create & setup server socket -// let srv = TcpListener::bind(&addr).unwrap(); - -// poll.register(&srv, SERVER, Ready::readable(), PollOpt::edge()) -// .unwrap(); - -// // == Create & setup client socket -// let sock = TcpStream::connect(&addr).unwrap(); - -// poll.register(&sock, CLIENT, Ready::writable(), PollOpt::edge()) -// .unwrap(); - -// // == Create storage for events -// let mut events = Events::with_capacity(1024); - -// // == Setup test handler -// let mut handler = TestHandler::new(srv, sock); - -// // == Run test -// while !handler.shutdown { -// poll.poll(&mut events, None).unwrap(); - -// for event in &events { -// if event.readiness().is_readable() { -// handler.handle_read(&mut poll, event.token(), event.readiness()); -// } - -// if event.readiness().is_writable() { -// handler.handle_write(&mut poll, event.token(), event.readiness()); -// } -// } -// } -// assert!(handler.state == AfterRead, "actual={:?}", handler.state); -// } diff --git a/corcovado/test/test_double_register.rs b/corcovado/test/test_double_register.rs deleted file mode 100644 index bc58d938ed..0000000000 --- a/corcovado/test/test_double_register.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! A smoke test for windows compatibility - -// #[test] -// #[cfg(any(target_os = "linux", target_os = "windows"))] -// pub fn test_double_register() { -// use std::net::TcpListener; -// use corcovado::*; - -// let poll = Poll::new().unwrap(); - -// // Create the listener -// let l = TcpListener::bind(&"127.0.0.1:0".parse().unwrap()).unwrap(); - -// // Register the listener with `Poll` -// poll.register(&l, Token(0), Ready::readable(), PollOpt::edge()) -// .unwrap(); -// assert!(poll -// .register(&l, Token(1), Ready::readable(), PollOpt::edge()) -// .is_err()); -// } diff --git a/corcovado/test/test_echo_server.rs b/corcovado/test/test_echo_server.rs deleted file mode 100644 index 34932a7ea1..0000000000 --- a/corcovado/test/test_echo_server.rs +++ /dev/null @@ -1,345 +0,0 @@ -// use bytes::{Buf, ByteBuf, MutByteBuf, SliceBuf}; -// use corcovado::net::{TcpListener, TcpStream}; -// use corcovado::{Events, Poll, PollOpt, Ready, Token}; -// use slab::Slab; -// use std::io; -// use {localhost, TryRead, TryWrite}; - -// const SERVER: Token = Token(10_000_000); -// const CLIENT: Token = Token(10_000_001); - -// struct EchoConn { -// sock: TcpStream, -// buf: Option, -// mut_buf: Option, -// token: Option, -// interest: Ready, -// } - -// impl EchoConn { -// fn new(sock: TcpStream) -> EchoConn { -// EchoConn { -// sock, -// buf: None, -// mut_buf: Some(ByteBuf::mut_with_capacity(2048)), -// token: None, -// interest: Ready::empty(), -// } -// } - -// fn writable(&mut self, poll: &mut Poll) -> io::Result<()> { -// let mut buf = self.buf.take().unwrap(); - -// match self.sock.try_write_buf(&mut buf) { -// Ok(None) => { -// debug!("client flushing buf; WOULDBLOCK"); - -// self.buf = Some(buf); -// self.interest.insert(Ready::writable()); -// } -// Ok(Some(r)) => { -// debug!("CONN : we wrote {} bytes!", r); - -// self.mut_buf = Some(buf.flip()); - -// self.interest.insert(Ready::readable()); -// self.interest.remove(Ready::writable()); -// } -// Err(e) => debug!("not implemented; client err={:?}", e), -// } - -// assert!( -// self.interest.is_readable() || self.interest.is_writable(), -// "actual={:?}", -// self.interest -// ); -// poll.reregister( -// &self.sock, -// self.token.unwrap(), -// self.interest, -// PollOpt::edge() | PollOpt::oneshot(), -// ) -// } - -// fn readable(&mut self, poll: &mut Poll) -> io::Result<()> { -// let mut buf = self.mut_buf.take().unwrap(); - -// match self.sock.try_read_buf(&mut buf) { -// Ok(None) => { -// debug!("CONN : spurious read wakeup"); -// self.mut_buf = Some(buf); -// } -// Ok(Some(r)) => { -// debug!("CONN : we read {} bytes!", r); - -// // prepare to provide this to writable -// self.buf = Some(buf.flip()); - -// self.interest.remove(Ready::readable()); -// self.interest.insert(Ready::writable()); -// } -// Err(e) => { -// debug!("not implemented; client err={:?}", e); -// self.interest.remove(Ready::readable()); -// } -// }; - -// assert!( -// self.interest.is_readable() || self.interest.is_writable(), -// "actual={:?}", -// self.interest -// ); -// poll.reregister( -// &self.sock, -// self.token.unwrap(), -// self.interest, -// PollOpt::edge(), -// ) -// } -// } - -// struct EchoServer { -// sock: TcpListener, -// conns: Slab, -// } - -// impl EchoServer { -// fn accept(&mut self, poll: &mut Poll) -> io::Result<()> { -// debug!("server accepting socket"); - -// let sock = self.sock.accept().unwrap().0; -// let conn = EchoConn::new(sock); -// let tok = self.conns.insert(conn); - -// // Register the connection -// self.conns[tok].token = Some(Token(tok)); -// poll.register( -// &self.conns[tok].sock, -// Token(tok), -// Ready::readable(), -// PollOpt::edge() | PollOpt::oneshot(), -// ) -// .expect("could not register socket with event loop"); - -// Ok(()) -// } - -// fn conn_readable(&mut self, poll: &mut Poll, tok: Token) -> io::Result<()> { -// debug!("server conn readable; tok={:?}", tok); -// self.conn(tok).readable(poll) -// } - -// fn conn_writable(&mut self, poll: &mut Poll, tok: Token) -> io::Result<()> { -// debug!("server conn writable; tok={:?}", tok); -// self.conn(tok).writable(poll) -// } - -// fn conn(&mut self, tok: Token) -> &mut EchoConn { -// &mut self.conns[tok.into()] -// } -// } - -// struct EchoClient { -// sock: TcpStream, -// msgs: Vec<&'static str>, -// tx: SliceBuf<'static>, -// rx: SliceBuf<'static>, -// mut_buf: Option, -// token: Token, -// interest: Ready, -// shutdown: bool, -// } - -// // Sends a message and expects to receive the same exact message, one at a time -// impl EchoClient { -// fn new(sock: TcpStream, token: Token, mut msgs: Vec<&'static str>) -> EchoClient { -// let curr = msgs.remove(0); - -// EchoClient { -// sock, -// msgs, -// tx: SliceBuf::wrap(curr.as_bytes()), -// rx: SliceBuf::wrap(curr.as_bytes()), -// mut_buf: Some(ByteBuf::mut_with_capacity(2048)), -// token, -// interest: Ready::empty(), -// shutdown: false, -// } -// } - -// fn readable(&mut self, poll: &mut Poll) -> io::Result<()> { -// debug!("client socket readable"); - -// let mut buf = self.mut_buf.take().unwrap(); - -// match self.sock.try_read_buf(&mut buf) { -// Ok(None) => { -// debug!("CLIENT : spurious read wakeup"); -// self.mut_buf = Some(buf); -// } -// Ok(Some(r)) => { -// debug!("CLIENT : We read {} bytes!", r); - -// // prepare for reading -// let mut buf = buf.flip(); - -// while buf.has_remaining() { -// let actual = buf.read_byte().unwrap(); -// let expect = self.rx.read_byte().unwrap(); - -// assert!(actual == expect, "actual={}; expect={}", actual, expect); -// } - -// self.mut_buf = Some(buf.flip()); - -// self.interest.remove(Ready::readable()); - -// if !self.rx.has_remaining() { -// self.next_msg(poll).unwrap(); -// } -// } -// Err(e) => { -// panic!("not implemented; client err={:?}", e); -// } -// }; - -// if !self.interest.is_empty() { -// assert!( -// self.interest.is_readable() || self.interest.is_writable(), -// "actual={:?}", -// self.interest -// ); -// poll.reregister( -// &self.sock, -// self.token, -// self.interest, -// PollOpt::edge() | PollOpt::oneshot(), -// )?; -// } - -// Ok(()) -// } - -// fn writable(&mut self, poll: &mut Poll) -> io::Result<()> { -// debug!("client socket writable"); - -// match self.sock.try_write_buf(&mut self.tx) { -// Ok(None) => { -// debug!("client flushing buf; WOULDBLOCK"); -// self.interest.insert(Ready::writable()); -// } -// Ok(Some(r)) => { -// debug!("CLIENT : we wrote {} bytes!", r); -// self.interest.insert(Ready::readable()); -// self.interest.remove(Ready::writable()); -// } -// Err(e) => debug!("not implemented; client err={:?}", e), -// } - -// if self.interest.is_readable() || self.interest.is_writable() { -// try!(poll.reregister( -// &self.sock, -// self.token, -// self.interest, -// PollOpt::edge() | PollOpt::oneshot() -// )); -// } - -// Ok(()) -// } - -// fn next_msg(&mut self, poll: &mut Poll) -> io::Result<()> { -// if self.msgs.is_empty() { -// self.shutdown = true; -// return Ok(()); -// } - -// let curr = self.msgs.remove(0); - -// debug!("client prepping next message"); -// self.tx = SliceBuf::wrap(curr.as_bytes()); -// self.rx = SliceBuf::wrap(curr.as_bytes()); - -// self.interest.insert(Ready::writable()); -// poll.reregister( -// &self.sock, -// self.token, -// self.interest, -// PollOpt::edge() | PollOpt::oneshot(), -// ) -// } -// } - -// struct Echo { -// server: EchoServer, -// client: EchoClient, -// } - -// impl Echo { -// fn new(srv: TcpListener, client: TcpStream, msgs: Vec<&'static str>) -> Echo { -// Echo { -// server: EchoServer { -// sock: srv, -// conns: Slab::with_capacity(128), -// }, -// client: EchoClient::new(client, CLIENT, msgs), -// } -// } -// } - -// #[test] -// pub fn test_echo_server() { -// debug!("Starting TEST_ECHO_SERVER"); -// let mut poll = Poll::new().unwrap(); - -// let addr = localhost(); -// let srv = TcpListener::bind(&addr).unwrap(); - -// info!("listen for connections"); -// poll.register( -// &srv, -// SERVER, -// Ready::readable(), -// PollOpt::edge() | PollOpt::oneshot(), -// ) -// .unwrap(); - -// let sock = TcpStream::connect(&addr).unwrap(); - -// // Connect to the server -// poll.register( -// &sock, -// CLIENT, -// Ready::writable(), -// PollOpt::edge() | PollOpt::oneshot(), -// ) -// .unwrap(); -// // == Create storage for events -// let mut events = Events::with_capacity(1024); - -// let mut handler = Echo::new(srv, sock, vec!["foo", "bar"]); - -// // Start the event loop -// while !handler.client.shutdown { -// poll.poll(&mut events, None).unwrap(); - -// for event in &events { -// debug!("ready {:?} {:?}", event.token(), event.readiness()); -// if event.readiness().is_readable() { -// match event.token() { -// SERVER => handler.server.accept(&mut poll).unwrap(), -// CLIENT => handler.client.readable(&mut poll).unwrap(), -// i => handler.server.conn_readable(&mut poll, i).unwrap(), -// } -// } - -// if event.readiness().is_writable() { -// match event.token() { -// SERVER => panic!("received writable for token 0"), -// CLIENT => handler.client.writable(&mut poll).unwrap(), -// i => handler.server.conn_writable(&mut poll, i).unwrap(), -// }; -// } -// } -// } -// } diff --git a/corcovado/test/test_poll.rs b/corcovado/test/test_poll.rs index 154dd26167..015ed86ae8 100644 --- a/corcovado/test/test_poll.rs +++ b/corcovado/test/test_poll.rs @@ -1,6 +1,14 @@ +//! Tests for Poll functionality +//! +//! These tests verify the core Poll event notification mechanism. + use corcovado::*; use std::time::Duration; +/// Tests that Poll correctly closes file descriptors. +/// +/// This test creates and drops Poll instances repeatedly to ensure +/// that file descriptors are properly released and no resource leaks occur. #[test] fn test_poll_closes_fd() { for _ in 0..2000 { @@ -18,3 +26,4 @@ fn test_poll_closes_fd() { drop(registration); } } + diff --git a/docs/docs/config.md b/docs/docs/config.md index 595dc4113b..d8b05c0a76 100644 --- a/docs/docs/config.md +++ b/docs/docs/config.md @@ -349,14 +349,6 @@ You can also set family on root to overwrite all fonts. fonts.family = "cascadiacode" ``` -## fonts.extras - -You can also specify extra fonts to load: - -```toml -fonts.extras = [{ family = "Microsoft JhengHei" }] -``` - ## fonts.features In case you want to specify any font feature: @@ -369,22 +361,18 @@ Note: Font features do not have support to live reload on configuration, so to r ## fonts.extras -Extra font families searched after the configured regular/italic/bold slots. Use this to override the bundled Twemoji with a system color-emoji font, or to bring in a Nerd Font for icon glyphs. Rio auto-detects color-emoji fonts from their SFNT color tables (`COLR`, `CBDT`, `CBLC`, `sbix`), so an emoji family dropped here is treated as wide-cell / color-atlas without needing a flag, while a Nerd Font family stays single-cell. +Extra font families searched after the configured regular/italic/bold slots. Use this to bring in a Nerd Font for icon glyphs, or to add a custom CJK / symbol family. Rio auto-detects color-emoji fonts from their SFNT color tables (`COLR`, `CBDT`, `CBLC`, `sbix`), so an emoji family dropped here is treated as wide-cell / color-atlas without needing a flag, while a Nerd Font family stays single-cell. ```toml -# Use Apple Color Emoji instead of the bundled Twemoji -fonts.extras = [{ family = "Apple Color Emoji" }] - -# Or a Nerd Font for icon glyphs +# A Nerd Font for icon glyphs fonts.extras = [{ family = "JetBrainsMono Nerd Font Mono" }] -# Both — order determines fallback priority -fonts.extras = [ - { family = "Apple Color Emoji" }, - { family = "JetBrainsMono Nerd Font Mono" }, -] +# Or a specific CJK / symbol family +fonts.extras = [{ family = "Microsoft JhengHei" }] ``` +**macOS note:** `fonts.extras` is ignored on macOS. Rio uses CoreText's `CTFontCopyDefaultCascadeListForLanguages` to pull the system-recommended fallback chain for the primary font — that already includes Apple Color Emoji, CJK fonts, symbols, and script-specific typefaces in the order the OS prefers. Adding `fonts.extras` entries on macOS would either duplicate what's already in the cascade or compete with CoreText's ordering, so it's skipped. + ## fonts.hinting Enable or disable font hinting. It is enabled by default. diff --git a/flake.lock b/flake.lock index 41d670cdd6..2da19a4fe1 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1772408722, - "narHash": "sha256-rHuJtdcOjK7rAHpHphUb1iCvgkU3GpfvicLMwwnfMT0=", + "lastModified": 1775087534, + "narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "f20dc5d9b8027381c474144ecabc9034d6a839a3", + "rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b", "type": "github" }, "original": { @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1773821835, - "narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=", + "lastModified": 1776169885, + "narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0", + "rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9", "type": "github" }, "original": { @@ -36,11 +36,11 @@ }, "nixpkgs-lib": { "locked": { - "lastModified": 1772328832, - "narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=", + "lastModified": 1774748309, + "narHash": "sha256-+U7gF3qxzwD5TZuANzZPeJTZRHS29OFQgkQ2kiTJBIQ=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742", + "rev": "333c4e0545a6da976206c74db8773a1645b5870a", "type": "github" }, "original": { @@ -64,11 +64,11 @@ ] }, "locked": { - "lastModified": 1774062094, - "narHash": "sha256-ba3c+hS7KzEiwtZRGHagIAYdcmdY3rCSWVCyn64rx7s=", + "lastModified": 1776481912, + "narHash": "sha256-Xq7p+Ex3YHFAd+fFFLOYw2Wv67582X7SAmrEDtIDZQ4=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "c807e83cc2e32adc35f51138b3bdef722c0812ab", + "rev": "e611106c527e8ab0adbb641183cda284411d575c", "type": "github" }, "original": { diff --git a/frontends/rioterm/src/layout/mod.rs b/frontends/rioterm/src/layout/mod.rs index 5c8f9e94b0..fa117902f7 100644 --- a/frontends/rioterm/src/layout/mod.rs +++ b/frontends/rioterm/src/layout/mod.rs @@ -908,7 +908,17 @@ impl ContextGrid { let is_multi_panel = self.inner.len() > 1; for item in self.inner.values_mut() { - let [abs_x, abs_y, width, height] = item.layout_rect; + // Single panel: ignore Taffy panel margin offset, use full available area + let [abs_x, abs_y, width, height] = if is_multi_panel { + item.layout_rect + } else { + [ + 0.0, + 0.0, + self.width - self.scaled_margin.left - self.scaled_margin.right, + self.height - self.scaled_margin.top - self.scaled_margin.bottom, + ] + }; let x = (abs_x + self.scaled_margin.left) / scale; let y = (abs_y + self.scaled_margin.top) / scale; diff --git a/frontends/rioterm/src/screen/mod.rs b/frontends/rioterm/src/screen/mod.rs index 4b75847220..1e3bb3a9ca 100644 --- a/frontends/rioterm/src/screen/mod.rs +++ b/frontends/rioterm/src/screen/mod.rs @@ -1414,9 +1414,9 @@ impl Screen<'_> { self.context_manager.contexts_mut()[old_index] .update_dimensions(&mut self.sugarloaf); - // Use the base scaled_margin for the new tab position, not the - // split-panel-aware margin, because the new tab is full-window. - let padding_x = self.context_manager.current_grid().scaled_margin.left; + // Use the logical (unscaled) margin for the new tab position so it + // matches the position set for the first tab in Screen::new. + let padding_x = self.renderer.margin.left; let padding_y_top = self.renderer.margin.top + self.renderer.island.as_ref().map_or(0.0, |i| i.height()); let rich_text_id = next_rich_text_id(); diff --git a/rio-backend/src/crosswords/mod.rs b/rio-backend/src/crosswords/mod.rs index 951eb52c5a..4f40c2e3e7 100644 --- a/rio-backend/src/crosswords/mod.rs +++ b/rio-backend/src/crosswords/mod.rs @@ -1507,15 +1507,12 @@ impl Crosswords { .. }) => { for line in (start.row.0..end.row.0).map(Line::from) { - res += self - .line_to_string(line, start.col..end.col, start.col.0 != 0) - .trim_end(); + res += + &self.line_to_string(line, start.col..end.col, start.col.0 != 0); res += "\n"; } - res += self - .line_to_string(end.row, start.col..end.col, true) - .trim_end(); + res += &self.line_to_string(end.row, start.col..end.col, true); } Some(Selection { ty: SelectionType::Lines, @@ -1532,7 +1529,10 @@ impl Crosswords { } pub fn bounds_to_string(&self, start: Pos, end: Pos) -> String { - let mut res = String::new(); + let mut text = String::new(); + let mut blank_rows: usize = 0; + let mut blank_cells: usize = 0; + let last_col = self.grid.last_column(); for line in (start.row.0..=end.row.0).map(Line::from) { let start_col = if line == start.row { @@ -1540,32 +1540,93 @@ impl Crosswords { } else { Column(0) }; - let end_col = if line == end.row { - end.col - } else { - self.grid.last_column() - }; + let end_col = if line == end.row { end.col } else { last_col }; + + // Carry buffered blank cells across wrap continuations only. + // Without this, `aaa \n aaa"` (where row N wraps into N+1) would + // collapse the cross-row gap from two spaces to one. + let is_wrap_continuation = + line.0 > start.row.0 && self.grid[line - 1i32][last_col].wrapline(); + if !is_wrap_continuation { + blank_cells = 0; + } + + let mut row_text = String::new(); + let had_content = self.append_cells( + &mut row_text, + line, + start_col..end_col, + line == end.row, + &mut blank_cells, + ); + + if !had_content { + // Defer entirely-blank rows; trailing blank rows get dropped. + blank_rows += 1; + continue; + } + + for _ in 0..blank_rows { + text.push('\n'); + } + blank_rows = 0; + + text.push_str(&row_text); - res += &self.line_to_string(line, start_col..end_col, line == end.row); + let cur_wraps = self.grid[line][last_col].wrapline(); + if end_col >= last_col && !cur_wraps { + text.push('\n'); + blank_cells = 0; + } } - res.strip_suffix('\n').map(str::to_owned).unwrap_or(res) + text.strip_suffix('\n').map(str::to_owned).unwrap_or(text) } - /// Convert a single line in the grid to a String. + /// Convert a single line in the grid to a String. Used by Block selection; + /// trailing blank cells are dropped. No trailing newline is appended — + /// the caller controls row separation. fn line_to_string( &self, line: Line, - mut cols: Range, + cols: Range, include_wrapped_wide: bool, ) -> String { let mut text = String::new(); + let mut blank_cells = 0; + self.append_cells( + &mut text, + line, + cols, + include_wrapped_wide, + &mut blank_cells, + ); + text + } + /// Append cells from a single line to `text`, buffering blank cells + /// (`\0` and trailing spaces) so that: + /// - `\0` cells inside a run of content become real spaces + /// - trailing blanks at end of the run are dropped (caller decides + /// whether to flush them via the `blank_cells` accumulator) + /// + /// Returns true if the line emitted any non-blank content. + fn append_cells( + &self, + text: &mut String, + line: Line, + mut cols: Range, + include_wrapped_wide: bool, + blank_cells: &mut usize, + ) -> bool { + let mut had_content = false; let grid_line = &self.grid[line]; let line_length = std::cmp::min(grid_line.line_length(), cols.end + 1); // Include wide char when trailing spacer is selected. - if matches!(grid_line[cols.start].wide(), Wide::Spacer) { + if cols.start < self.grid.columns() + && matches!(grid_line[cols.start].wide(), Wide::Spacer) + { cols.start -= 1; } @@ -1586,25 +1647,34 @@ impl Crosswords { tab_mode = true; } - if !matches!(cell.wide(), Wide::Spacer | Wide::LeadingSpacer) { - // Push cells primary character. - text.push(cell.c()); + if matches!(cell.wide(), Wide::Spacer | Wide::LeadingSpacer) { + continue; + } + + let c = cell.c(); + let has_extras = cell.extras_id().is_some(); - // Push zero-width characters. - if let Some(extras_id) = cell.extras_id() { - if let Some(extras) = self.grid.extras_table.get(extras_id) { - for c in &extras.zerowidth { - text.push(*c); - } + // Buffer blank cells. They only get emitted as real spaces if a + // non-blank cell follows (on this row or a wrap continuation). + if !has_extras && (c == '\0' || c == ' ') { + *blank_cells += 1; + continue; + } + + for _ in 0..*blank_cells { + text.push(' '); + } + *blank_cells = 0; + + text.push(c); + if let Some(extras_id) = cell.extras_id() { + if let Some(extras) = self.grid.extras_table.get(extras_id) { + for c in &extras.zerowidth { + text.push(*c); } } } - } - - if cols.end >= self.grid.columns() - 1 - && (line_length.0 == 0 || !self.grid[line][line_length - 1].wrapline()) - { - text.push('\n'); + had_content = true; } // If wide char is not part of the selection, but leading spacer is, include it. @@ -1613,10 +1683,15 @@ impl Crosswords { && matches!(grid_line[line_length - 1].wide(), Wide::LeadingSpacer) && include_wrapped_wide { + for _ in 0..*blank_cells { + text.push(' '); + } + *blank_cells = 0; text.push(self.grid[line - 1i32][Column(0)].c()); + had_content = true; } - text + had_content } #[inline] @@ -4695,9 +4770,12 @@ mod tests { Side::Right, ); } + // Trailing space on the wrapped row is preserved as a buffered blank + // and only flushed if a non-blank cell follows on the continuation + // row. Here the selection ends mid-wrap so the trailing space is dropped. assert_eq!( term.selection_to_string(), - Some(String::from("\"aaa\"\n\n aaa ")) + Some(String::from("\"aaa\"\n\n aaa")) ); // A wrapline. @@ -4836,6 +4914,133 @@ mod tests { ); } + fn make_term_for_selection(rows: usize, cols: usize) -> Crosswords { + let size = CrosswordsSize::new(cols, rows); + let window_id = crate::event::WindowId::from(0); + Crosswords::new( + size, + CursorShape::Block, + VoidListener {}, + window_id, + 0, + 10_000, + ) + } + + fn select_simple( + term: &mut Crosswords, + start: (i32, usize), + end: (i32, usize), + ) { + term.selection = Some(Selection::new( + SelectionType::Simple, + Pos { + row: Line(start.0), + col: Column(start.1), + }, + Side::Left, + )); + if let Some(s) = term.selection.as_mut() { + s.update( + Pos { + row: Line(end.0), + col: Column(end.1), + }, + Side::Right, + ); + } + } + + /// `\0` cells in the middle of a run of content must be emitted as ASCII + /// spaces, not raw NULs. This is the "TUI redrew its UI and left holes" + /// case (e.g. fullscreen apps that paint with cursor positioning). + #[test] + fn null_cells_inside_run_become_spaces() { + let mut term = make_term_for_selection(1, 7); + let grid = &mut term.grid; + // Row layout: a, a, \0, \0, \0, b, b + grid[Line(0)][Column(0)].set_c('a'); + grid[Line(0)][Column(1)].set_c('a'); + grid[Line(0)][Column(5)].set_c('b'); + grid[Line(0)][Column(6)].set_c('b'); + + select_simple(&mut term, (0, 0), (0, 6)); + let s = term.selection_to_string().unwrap(); + assert_eq!(s, "aa bb"); + assert!(!s.contains('\0'), "selection must not contain raw NULs"); + } + + /// Trailing `\0` and trailing spaces on a non-wrapped row must be dropped + /// rather than padding the copy out to column width. + #[test] + fn trailing_blanks_on_non_wrapped_row_are_dropped() { + let mut term = make_term_for_selection(1, 8); + let grid = &mut term.grid; + grid[Line(0)][Column(0)].set_c('h'); + grid[Line(0)][Column(1)].set_c('i'); + grid[Line(0)][Column(2)].set_c(' '); + grid[Line(0)][Column(3)].set_c(' '); + // cols 4..=7 stay as \0 + + select_simple(&mut term, (0, 0), (0, 7)); + assert_eq!(term.selection_to_string(), Some(String::from("hi"))); + } + + /// Trailing blank rows in a multi-row selection must be dropped, not + /// emitted as a run of `\n`s. + #[test] + fn trailing_blank_rows_are_dropped() { + let mut term = make_term_for_selection(5, 5); + let grid = &mut term.grid; + grid[Line(0)][Column(0)].set_c('x'); + grid[Line(0)][Column(1)].set_c('y'); + // Rows 1..=4 are entirely \0. + + select_simple(&mut term, (0, 0), (4, 4)); + assert_eq!(term.selection_to_string(), Some(String::from("xy"))); + } + + /// Blank rows between non-blank rows must still be emitted as `\n`s, so + /// real visual gaps in the selection are preserved. + #[test] + fn blank_rows_between_content_are_preserved() { + let mut term = make_term_for_selection(5, 5); + let grid = &mut term.grid; + grid[Line(0)][Column(0)].set_c('a'); + // Rows 1, 2 entirely \0. + grid[Line(3)][Column(0)].set_c('b'); + // Row 4 entirely \0. + + select_simple(&mut term, (0, 0), (4, 4)); + assert_eq!(term.selection_to_string(), Some(String::from("a\n\n\nb"))); + } + + /// When a row wraps into the next, the trailing-space buffer must carry + /// across so the visual gap survives the round-trip through the clipboard. + #[test] + fn trailing_space_carries_across_wrap_continuation() { + let mut term = make_term_for_selection(2, 5); + let grid = &mut term.grid; + // Row 0: "ab " with a wrap into row 1. + grid[Line(0)][Column(0)].set_c('a'); + grid[Line(0)][Column(1)].set_c('b'); + grid[Line(0)][Column(2)].set_c(' '); + grid[Line(0)][Column(3)].set_c(' '); + grid[Line(0)][Column(4)].set_c(' '); + grid[Line(0)][Column(4)].set_wrapline(true); + // Row 1: " cd " + grid[Line(1)][Column(0)].set_c(' '); + grid[Line(1)][Column(1)].set_c('c'); + grid[Line(1)][Column(2)].set_c('d'); + grid[Line(1)][Column(3)].set_c(' '); + grid[Line(1)][Column(4)].set_c(' '); + + // Trailing spaces on row 1 dropped (no further continuation), but the + // 4 spaces between `b` and `c` must survive across the wrap. + select_simple(&mut term, (0, 0), (1, 4)); + assert_eq!(term.selection_to_string(), Some(String::from("ab cd"))); + } + #[test] fn parse_cargo_version() { assert_eq!(version_number("0.0.1-nightly"), 1); diff --git a/rio-window/src/platform/windows.rs b/rio-window/src/platform/windows.rs index 704b069092..1c8a0ef06c 100644 --- a/rio-window/src/platform/windows.rs +++ b/rio-window/src/platform/windows.rs @@ -285,9 +285,15 @@ pub trait WindowExtWindows { /// Enabling the shadow causes a thin 1px line to appear on the top of the window. fn set_undecorated_shadow(&self, shadow: bool); - /// Sets system-drawn backdrop type. - /// - /// Requires Windows 11 build 22523+. + /// Sets the window backdrop effect. + /// + /// On the Win32 backend this maps to the legacy blur-behind composition + /// attribute (`ACCENT_ENABLE_BLURBEHIND`), which is available on + /// Windows 10 v1809+ and every Windows 11 build. All non-`None` + /// variants of [`BackdropType`] collapse to the same blur effect — the + /// distinctions between `MainWindow` / `TransientWindow` / `TabbedWindow` + /// only apply to the newer `DWMWA_SYSTEMBACKDROP_TYPE` path, which + /// requires Windows 11 build 22523+. fn set_system_backdrop(&self, backdrop_type: BackdropType); /// Sets the color of the window border. diff --git a/rio-window/src/platform_impl/windows/window.rs b/rio-window/src/platform_impl/windows/window.rs index a06e64a273..c60fd54033 100644 --- a/rio-window/src/platform_impl/windows/window.rs +++ b/rio-window/src/platform_impl/windows/window.rs @@ -8,15 +8,15 @@ use std::sync::mpsc::channel; use std::sync::{Arc, Mutex, MutexGuard}; use std::{io, panic, ptr}; +use std::sync::LazyLock; use windows_sys::Win32::Foundation::{ BOOL, FALSE, HWND, LPARAM, OLE_E_WRONGCOMPOBJ, POINT, POINTS, RECT, RPC_E_CHANGED_MODE, S_OK, TRUE, WPARAM, }; use windows_sys::Win32::Graphics::Dwm::{ DwmEnableBlurBehindWindow, DwmSetWindowAttribute, DWMWA_BORDER_COLOR, - DWMWA_CAPTION_COLOR, DWMWA_CLOAK, DWMWA_SYSTEMBACKDROP_TYPE, DWMWA_TEXT_COLOR, - DWMWA_WINDOW_CORNER_PREFERENCE, DWM_BB_BLURREGION, DWM_BB_ENABLE, DWM_BLURBEHIND, - DWM_SYSTEMBACKDROP_TYPE, DWM_WINDOW_CORNER_PREFERENCE, + DWMWA_CAPTION_COLOR, DWMWA_CLOAK, DWMWA_TEXT_COLOR, DWMWA_WINDOW_CORNER_PREFERENCE, + DWM_BB_BLURREGION, DWM_BB_ENABLE, DWM_BLURBEHIND, DWM_WINDOW_CORNER_PREFERENCE, }; use windows_sys::Win32::Graphics::Gdi::{ ChangeDisplaySettingsExW, ClientToScreen, CreateRectRgn, DeleteObject, InvalidateRgn, @@ -27,6 +27,9 @@ use windows_sys::Win32::System::Com::{ CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_APARTMENTTHREADED, }; +use windows_sys::Win32::System::LibraryLoader::{ + GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32, +}; use windows_sys::Win32::System::Ole::{OleInitialize, RegisterDragDrop}; use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ EnableWindow, GetActiveWindow, MapVirtualKeyW, ReleaseCapture, SendInput, ToUnicode, @@ -94,6 +97,101 @@ impl SyncWindowHandle { } } +// Undocumented user32 API for window composition attributes. +// +// We go through this (vs. the documented `DwmSetWindowAttribute(DWMWA_ +// SYSTEMBACKDROP_TYPE, …)`) because the DWM backdrop path requires +// Windows 11 build 22523+ and, where available, produces a visibly +// different composition than the legacy blur-behind. Shipping the legacy +// path across Windows 10 v1809+ and every Windows 11 build gives a +// consistent look across versions. Taur i's `window-vibrancy` crate uses +// the same fallback shape. +// +// Known caveats: +// - Symbol is not in the public SDK. Chromium / Electron / VSCode use it, +// but Microsoft gives no compatibility guarantees. +// - Windows 10 2004+ on multi-monitor setups: blur can disappear during +// window resize (`dotnet/wpf#3608`, unfixed at time of writing). +// - `ACCENT_ENABLE_ACRYLICBLURBEHIND` (value 4) survives resize but +// introduces laggy window dragging. +type SetWindowCompositionAttribute = + unsafe extern "system" fn(HWND, *mut WINDOWCOMPOSITIONATTRIBDATA) -> BOOL; + +#[allow(clippy::upper_case_acronyms)] +type WINDOWCOMPOSITIONATTRIB = u32; + +const WCA_ACCENT_POLICY: WINDOWCOMPOSITIONATTRIB = 19; +const ACCENT_DISABLED: u32 = 0; +const ACCENT_ENABLE_BLURBEHIND: u32 = 3; +// `AccentFlags = 2` is the undocumented "use the gradient color" flag. +// Without it the backdrop ignores the tint and picks whatever DWM +// defaults to, which tends to look washed out. Tauri's `window-vibrancy` +// uses the same value for non-acrylic blur. +const ACCENT_FLAG_USE_GRADIENT: u32 = 2; + +#[allow(non_snake_case)] +#[allow(clippy::upper_case_acronyms)] +#[repr(C)] +struct WINDOWCOMPOSITIONATTRIBDATA { + Attrib: WINDOWCOMPOSITIONATTRIB, + pvData: *mut c_void, + cbData: usize, +} + +#[allow(non_snake_case)] +#[allow(clippy::upper_case_acronyms)] +#[repr(C)] +struct ACCENT_POLICY { + AccentState: u32, + AccentFlags: u32, + GradientColor: u32, + AnimationId: u32, +} + +static SET_WINDOW_COMPOSITION_ATTRIBUTE: LazyLock> = + LazyLock::new(|| unsafe { get_window_composition_attribute() }); + +unsafe fn get_window_composition_attribute() -> Option { + // user32 is a Known DLL so `LoadLibraryA` would also find it in + // `System32`, but passing `LOAD_LIBRARY_SEARCH_SYSTEM32` explicitly + // rules out any DLL-planting attack by policy rather than by luck. + // UTF-16 literal for `LoadLibraryExW`: "user32.dll\0". + const USER32_DLL_W: &[u16] = &[ + b'u' as u16, + b's' as u16, + b'e' as u16, + b'r' as u16, + b'3' as u16, + b'2' as u16, + b'.' as u16, + b'd' as u16, + b'l' as u16, + b'l' as u16, + 0, + ]; + let module = unsafe { + LoadLibraryExW( + USER32_DLL_W.as_ptr(), + std::ptr::null_mut(), + LOAD_LIBRARY_SEARCH_SYSTEM32, + ) + }; + if module.is_null() { + return None; + } + + let handle = unsafe { + GetProcAddress(module, c"SetWindowCompositionAttribute".as_ptr().cast()) + }; + handle.map(|handle| unsafe { std::mem::transmute(handle) }) +} + +// Compile-time layout check — catches accidental field drift on +// `ACCENT_POLICY`. Microsoft can't change the shape without breaking +// Chromium, but the layout is unchecked by the compiler otherwise. +// Four `u32` fields => 16 bytes on every target. +const _: () = assert!(std::mem::size_of::() == 16); + /// The Win32 implementation of the main `Window` object. pub(crate) struct Window { /// Main handle for the window. @@ -164,11 +262,9 @@ impl Window { } pub fn set_blur(&self, blur: bool) { - // Maps the cross-platform `blur` flag to the Windows 11 - // Acrylic backdrop (`DWMSBT_TRANSIENTWINDOW`). On Windows 10 - // / pre-22H2 builds `DwmSetWindowAttribute` returns - // `E_INVALIDARG` and the backdrop silently does nothing — - // matches the no-op behaviour of the previous stub. + // Maps the cross-platform `blur` flag to the legacy Win32 + // blur-behind effect. This is available on older Windows + // versions than the system backdrop attribute path. self.set_system_backdrop(if blur { BackdropType::TransientWindow } else { @@ -1114,12 +1210,37 @@ impl Window { #[inline] pub fn set_system_backdrop(&self, backdrop_type: BackdropType) { unsafe { - DwmSetWindowAttribute( - self.hwnd(), - DWMWA_SYSTEMBACKDROP_TYPE as u32, - &(backdrop_type as i32) as *const _ as _, - mem::size_of::() as _, - ); + if let Some(set_window_composition_attribute) = + *SET_WINDOW_COMPOSITION_ATTRIBUTE + { + let is_enabled = backdrop_type != BackdropType::None; + let mut accent_policy = ACCENT_POLICY { + AccentState: if is_enabled { + ACCENT_ENABLE_BLURBEHIND + } else { + ACCENT_DISABLED + }, + // See `ACCENT_FLAG_USE_GRADIENT` — required for the + // tint to render. `GradientColor` stays 0 (fully + // transparent), so the "gradient" here is "no tint", + // matching the legacy blur-behind look. + AccentFlags: if is_enabled { + ACCENT_FLAG_USE_GRADIENT + } else { + 0 + }, + GradientColor: 0, + AnimationId: 0, + }; + + let mut data = WINDOWCOMPOSITIONATTRIBDATA { + Attrib: WCA_ACCENT_POLICY, + pvData: &mut accent_policy as *mut _ as _, + cbData: mem::size_of_val(&accent_policy) as _, + }; + + set_window_composition_attribute(self.hwnd(), &mut data); + } } } diff --git a/sugarloaf/Cargo.toml b/sugarloaf/Cargo.toml index 07c211abc3..cc7c0c2a75 100644 --- a/sugarloaf/Cargo.toml +++ b/sugarloaf/Cargo.toml @@ -73,6 +73,8 @@ metal = "0.32.0" objc-rs = "0.2.8" core-graphics-types = "0.2.0" core-graphics = "0.24.0" +core-text = "21.0.0" +core-foundation = "0.10.1" block = "0.1.6" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/sugarloaf/src/font/constants.rs b/sugarloaf/src/font/constants.rs index 6949acff22..39a61bad95 100644 --- a/sugarloaf/src/font/constants.rs +++ b/sugarloaf/src/font/constants.rs @@ -42,8 +42,8 @@ pub const FONT_CASCADIAMONO_LIGHT: &[u8] = pub const FONT_CASCADIAMONO_LIGHT_ITALIC: &[u8] = font!("./resources/CascadiaCode/CascadiaCode-LightItalic.otf"); -pub const FONT_CASCADIAMONO_REGULAR: &[u8] = - font!("./resources/CascadiaCode/CascadiaCode-Regular.otf"); +pub const FONT_CASCADIAMONO_NF_REGULAR: &[u8] = + font!("./resources/CascadiaCode/CascadiaCodeNF-Regular.otf"); pub const FONT_CASCADIAMONO_SEMI_BOLD: &[u8] = font!("./resources/CascadiaCode/CascadiaCode-SemiBold.otf"); @@ -57,7 +57,12 @@ pub const FONT_CASCADIAMONO_SEMI_LIGHT: &[u8] = pub const FONT_CASCADIAMONO_SEMI_LIGHT_ITALIC: &[u8] = font!("./resources/CascadiaCode/CascadiaCode-SemiLightItalic.otf"); -pub const FONT_SYMBOLS_NERD_FONT_MONO: &[u8] = - font!("./resources/SymbolsNerdFontMono/SymbolsNerdFontMono-Regular.ttf"); +// pub const FONT_SYMBOLS_NERD_FONT_MONO: &[u8] = +// font!("./resources/SymbolsNerdFontMono/SymbolsNerdFontMono-Regular.ttf"); +// macOS gets system Apple Color Emoji through the font fallback chain (see +// `fallbacks::external_fallbacks`) and doesn't need the bundled Twemoji — +// Rio's binary drops ~600 KB by not embedding it. Kept for test builds so +// the cross-platform COLR rasterization path stays covered. +#[cfg(any(test, not(target_os = "macos")))] pub const FONT_TWEMOJI_EMOJI: &[u8] = font!("./resources/Twemoji/Twemoji.Mozilla.ttf"); diff --git a/sugarloaf/src/font/fallbacks/mod.rs b/sugarloaf/src/font/fallbacks/mod.rs index f7539e77a7..bf7908ee3b 100644 --- a/sugarloaf/src/font/fallbacks/mod.rs +++ b/sugarloaf/src/font/fallbacks/mod.rs @@ -1,12 +1,12 @@ #[cfg(target_os = "macos")] pub fn external_fallbacks() -> Vec { - vec![ - String::from("Menlo"), - String::from("Geneva"), - String::from("Arial Unicode MS"), - // String::from("Noto Emoji"), - // String::from("Noto Color Emoji"), - ] + // Empty on macOS by design: CoreText's default cascade list + // (`CTFontCopyDefaultCascadeListForLanguages`, wired in + // `FontLibraryData::load`) already includes Menlo / Geneva / Arial + // Unicode MS / Apple Color Emoji and whatever else the system considers + // the right fallback chain for the primary font. Hardcoding family + // names here would duplicate that list and fight it. + Vec::new() } #[cfg(target_os = "windows")] diff --git a/sugarloaf/src/font/macos.rs b/sugarloaf/src/font/macos.rs new file mode 100644 index 0000000000..002ab85e73 --- /dev/null +++ b/sugarloaf/src/font/macos.rs @@ -0,0 +1,1463 @@ +//! macOS glyph rasterization via CoreText + CoreGraphics. +//! +//! Replaces the zeno path on macOS so text picks up the native anti-aliasing +//! style and Apple Color Emoji renders without bundled fallback fonts. +//! +//! Output: left/top bearings, width/height in device pixels, and either R8 +//! alpha-only bytes (mask) or premultiplied RGBA in Display-P3 (color). The +//! caller stitches this into the existing atlas pipeline unchanged. + +use std::path::PathBuf; + +use core_foundation::{ + attributed_string::CFMutableAttributedString, + base::{CFRange, CFType, TCFType}, + dictionary::CFDictionary, + number::CFNumber, + string::CFString, + url::{CFURLRef, CFURL}, +}; +use core_graphics::{ + base::{kCGBitmapByteOrder32Little, kCGImageAlphaPremultipliedFirst, CGFloat}, + color_space::{kCGColorSpaceDisplayP3, CGColorSpace}, + context::{CGContext, CGTextDrawingMode}, + font::CGGlyph, + geometry::{CGAffineTransform, CGPoint, CGRect, CGSize}, +}; +use core_text::{ + font as ct_font, + font::{CTFont, CTFontRef}, + font_collection, + font_descriptor::{ + self, kCTFontFamilyNameAttribute, kCTFontOrientationDefault, kCTFontSlantTrait, + kCTFontTraitsAttribute, kCTFontWeightTrait, kCTFontWidthTrait, CTFontDescriptor, + CTFontDescriptorCreateMatchingFontDescriptor, CTFontDescriptorRef, + }, + font_manager, + line::CTLine, + run::CTRun, + string_attributes::kCTFontAttributeName, +}; + +// core-graphics 0.24 doesn't export this one; `kCGImageAlphaOnly = 7` per Apple's +// CGImage.h. Used for 1-channel alpha-only bitmaps (monochrome glyph masks). +#[allow(non_upper_case_globals)] +const kCGImageAlphaOnly: u32 = 7; + +// core-text 21 leaves CTFontManagerRegisterFontsForURL commented out, so declare +// the FFI ourselves. Used to publish `additional_dirs` fonts to CoreText so +// descriptor matching (and the command-palette browser) finds them. +type CTFontManagerScope = u32; +#[allow(non_upper_case_globals)] +const kCTFontManagerScopeProcess: CTFontManagerScope = 1; + +// Raw FFI for `CFDataCreateWithBytesNoCopy` with `kCFAllocatorNull`. +// core-foundation's `CFData::from_buffer` goes through `CFDataCreate` which +// *copies* the buffer, and `CFData::from_arc` requires an Arc; neither hits +// the zero-copy path we want for bundled fonts whose bytes already live in +// `.rodata`. +#[link(name = "CoreFoundation", kind = "framework")] +extern "C" { + fn CFDataCreateWithBytesNoCopy( + allocator: core_foundation::base::CFAllocatorRef, + bytes: *const u8, + length: core_foundation::base::CFIndex, + bytes_deallocator: core_foundation::base::CFAllocatorRef, + ) -> core_foundation::data::CFDataRef; + + #[allow(non_upper_case_globals)] + static kCFAllocatorNull: core_foundation::base::CFAllocatorRef; +} + +#[allow(non_snake_case)] +#[link(name = "CoreText", kind = "framework")] +extern "C" { + fn CTFontManagerRegisterFontsForURL( + fontURL: CFURLRef, + scope: CTFontManagerScope, + error: *mut core_foundation::base::CFTypeRef, + ) -> bool; + + // core-text 21 wraps this as `clone_with_font_size` which hardcodes a + // null matrix. For synthetic italic we need to pass a non-null skew + // matrix, so declare the underlying symbol ourselves. + fn CTFontCreateCopyWithAttributes( + font: CTFontRef, + size: CGFloat, + matrix: *const CGAffineTransform, + attributes: CTFontDescriptorRef, + ) -> CTFontRef; + + // Plural variant used for TTC/OTC collections — returns an array of + // descriptors, one per sub-font. core-text 21 doesn't wrap this; only + // the singular `…FromData` is exposed (which picks the first font). + fn CTFontManagerCreateFontDescriptorsFromData( + data: core_foundation::data::CFDataRef, + ) -> core_foundation::array::CFArrayRef; + + // Returns the best CTFont for rendering `string` in `range`, using + // `current_font`'s cascade list. When the primary can render every + // codepoint in the range, CoreText returns the primary unchanged; + // otherwise it returns the cascade-picked fallback. Used by Rio's + // lazy font-discovery path to register an unknown cascade font on + // first encounter rather than pre-registering the full cascade at + // startup. + fn CTFontCreateForString( + current_font: CTFontRef, + string: core_foundation::string::CFStringRef, + range: CFRange, + ) -> CTFontRef; +} + +/// Shear matrix applied to `CTFont` for synthetic italic. `c = tan(15°)` +/// leans the glyphs 15° to the right. +const SYNTHETIC_ITALIC_SKEW: CGAffineTransform = CGAffineTransform { + a: 1.0, + b: 0.0, + c: 0.267_949, + d: 1.0, + tx: 0.0, + ty: 0.0, +}; + +/// Return a sheared `CTFont` for synthetic italic rendering. The base font +/// stays intact; the returned CTFont carries the skew in its transform +/// matrix so `draw_glyphs` produces slanted output. +fn ct_font_sheared(base: &CTFont, size: f64) -> CTFont { + use core_foundation::base::TCFType; + unsafe { + let raw = CTFontCreateCopyWithAttributes( + base.as_concrete_TypeRef(), + size as CGFloat, + &SYNTHETIC_ITALIC_SKEW, + std::ptr::null(), + ); + CTFont::wrap_under_create_rule(raw) + } +} + +/// Register every `.ttf`/`.otf`/`.ttc`/`.otc` under `dir` with CoreText so +/// `additional_dirs` fonts become discoverable by descriptor matching. +/// +/// Process-scoped: registrations only affect rio, not other apps on the +/// system, and they disappear when rio exits. Silently skips paths CoreText +/// rejects (duplicate registration, malformed files) — the rest of the dir +/// still loads. +pub fn register_fonts_in_dir(dir: &std::path::Path) { + let walker = walkdir::WalkDir::new(dir) + .into_iter() + .filter_map(|e| e.ok()); + for entry in walker { + let path = entry.path(); + if !path.is_file() { + continue; + } + let Some(ext) = path.extension().and_then(|e| e.to_str()) else { + continue; + }; + let ext = ext.to_ascii_lowercase(); + if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") { + continue; + } + let Some(url) = CFURL::from_path(path, false) else { + continue; + }; + let mut err: core_foundation::base::CFTypeRef = std::ptr::null(); + let ok = unsafe { + CTFontManagerRegisterFontsForURL( + url.as_concrete_TypeRef(), + kCTFontManagerScopeProcess, + &mut err, + ) + }; + if !ok { + tracing::debug!( + "CTFontManagerRegisterFontsForURL skipped {}", + path.display() + ); + } + } +} + +/// A parsed CoreText font. Construction goes through +/// `CTFontManagerCreateFontDescriptorFromData` + `CTFontCreateWithFontDescriptor`, +/// which preserves COLR, sbix, and other color font tables that the simpler +/// `CGFontCreateWithDataProvider` → `CTFontCreateWithGraphicsFont` path +/// silently drops. +/// +/// Stored at a reference 1.0pt size; per-call rasterization clones with the +/// target size (cheap CF refcount, not a parse). Clone is a CF retain. +/// +/// TTC caveat: CoreText's data-based descriptor reads only the first font in +/// a collection — use [`FontHandle::from_bytes_index`] for other indices. +#[derive(Clone)] +pub struct FontHandle { + base_font: CTFont, +} + +impl FontHandle { + /// Parse a font file's bytes into a `CTFont`. Returns `None` if CoreText + /// can't interpret the buffer (malformed, unsupported format). + /// + /// For TTC/OTC collections this picks the first contained font (index + /// 0) — matches Rio's cross-platform loader (`FontRef::from_index` with + /// index 0). Use [`FontHandle::from_bytes_index`] if a specific index + /// is needed. + pub fn from_bytes(font_bytes: &[u8]) -> Option { + let desc = font_manager::create_font_descriptor(font_bytes).ok()?; + let base_font = ct_font::new_from_descriptor(&desc, 1.0); + Some(Self { base_font }) + } + + /// Like [`from_bytes`] but picks a specific sub-font from a TTC/OTC + /// collection by index. For non-collection files pass `index = 0`; + /// results are equivalent to [`from_bytes`]. + /// + /// Returns `None` if the buffer isn't a valid (collection of) font(s) + /// or the index is out of range. + pub fn from_bytes_index(font_bytes: &[u8], index: usize) -> Option { + use core_foundation::array::CFArray; + use core_foundation::base::TCFType; + use core_foundation::data::CFData; + + let data = CFData::from_buffer(font_bytes); + let array_ref = unsafe { + CTFontManagerCreateFontDescriptorsFromData(data.as_concrete_TypeRef()) + }; + if array_ref.is_null() { + return None; + } + let descriptors: CFArray = + unsafe { CFArray::wrap_under_create_rule(array_ref) }; + let desc_ref = descriptors.get(index as isize)?; + let base_font = ct_font::new_from_descriptor(&desc_ref, 1.0); + Some(Self { base_font }) + } + + /// Zero-copy variant for bundled fonts whose bytes live in `.rodata`. + /// + /// `CFDataCreateWithBytesNoCopy(_, ptr, len, kCFAllocatorNull)` tells + /// CoreFoundation "I own these forever — never try to free them". The + /// bytes stay in the binary image; CoreText just holds a pointer. This + /// Saves the ~10 MB of duplication `CFDataCreate` would incur across + /// our bundled CascadiaMono / Nerd Font slices. + pub fn from_static_bytes(font_bytes: &'static [u8]) -> Option { + use core_foundation::base::{CFIndex, TCFType}; + use core_foundation::data::CFData; + + let data_ref = unsafe { + CFDataCreateWithBytesNoCopy( + std::ptr::null(), // default allocator for the CFData itself + font_bytes.as_ptr(), + font_bytes.len() as CFIndex, + kCFAllocatorNull, // never free the payload + ) + }; + if data_ref.is_null() { + return None; + } + let data = unsafe { CFData::wrap_under_create_rule(data_ref) }; + let desc = font_manager::create_font_descriptor_with_data(data).ok()?; + let base_font = ct_font::new_from_descriptor(&desc, 1.0); + Some(Self { base_font }) + } + + /// Load a font straight from a file path without reading the bytes + /// into Rio. + /// + /// Uses `CTFontManagerCreateFontDescriptorsFromURL` so CoreText reads the + /// file itself (backing it with an mmap or page cache as it sees fit). + /// This is the right path for the cascade-list emoji font (hundreds of + /// MB) and for any user font where we know the on-disk location. + /// + /// Returns `None` if CoreText can't open or parse the file. + pub fn from_path(path: &std::path::Path) -> Option { + use core_foundation::array::CFArray; + + let url = CFURL::from_path(path, false)?; + let array_ref = unsafe { + core_text::font_manager::CTFontManagerCreateFontDescriptorsFromURL( + url.as_concrete_TypeRef(), + ) + }; + if array_ref.is_null() { + return None; + } + let descriptors: CFArray = + unsafe { CFArray::wrap_under_create_rule(array_ref) }; + let desc_ref = descriptors.get(0)?; + let base_font = ct_font::new_from_descriptor(&desc_ref, 1.0); + Some(Self { base_font }) + } + + /// Unique-per-face PostScript name (e.g. "CascadiaMono-Regular", + /// "AppleColorEmoji"). Used to map a CTFont selected by CoreText's + /// cascade fallback back to Rio's `font_id` in [`shape_text`]. + pub fn postscript_name(&self) -> String { + self.base_font.postscript_name() + } +} + +/// Output of a single glyph rasterization. Mirrors the fields of +/// `font_introspector::scale::image::Image` the zeno path fills in. +#[derive(Debug)] +pub struct RasterizedGlyph { + /// Bitmap width in device pixels. `0` signals a zero-area glyph + /// (e.g. space, combining mark without ink). + pub width: u32, + /// Bitmap height in device pixels. `0` for zero-area glyphs. + pub height: u32, + /// Pen-relative x of the bitmap's left edge, in pixels. Positive = + /// right of the pen. + pub left: i32, + /// Baseline-relative y of the bitmap's top edge, in pixels. Positive + /// = above the baseline. + pub top: i32, + /// `true` when `bytes` is 4bpp premultiplied-alpha RGBA in Display-P3 + /// (color emoji); `false` when `bytes` is 1bpp alpha-only (monochrome + /// outline). + pub is_color: bool, + /// Row-major pixel bytes, no row padding. + pub bytes: Vec, +} + +/// Rasterize one glyph from a previously-parsed `FontHandle`. +/// +/// `glyph_id` is a TrueType glyph index; callers resolve it via shaping or a +/// charmap lookup before getting here. `size_px` is the target pixel size. +/// `is_color` picks the bitmap format — set it to the font's emoji-ness, not +/// per-glyph, since the atlas tile format is fixed up front. +/// +/// `synthetic_italic` applies a 15° right-lean via a sheared CTFont +/// transform. `synthetic_bold` draws with `CGTextFillStroke` and a stroke +/// width of `max(size/14, 1)`. Both are meant for when the font family +/// lacks the requested variant — normal bold/italic fonts are found by +/// `find_font_path` and should leave both flags `false`. +/// +/// Returns `None` only for zero-area glyphs with no placement (rare). Callers +/// should cache the `FontHandle` per font id so the font bytes are parsed +/// once, not once per glyph. +pub fn rasterize_glyph( + handle: &FontHandle, + glyph_id: u16, + size_px: f32, + is_color: bool, + synthetic_italic: bool, + synthetic_bold: bool, +) -> Option { + let ct_font = if synthetic_italic { + ct_font_sheared(&handle.base_font, size_px as f64) + } else { + handle.base_font.clone_with_font_size(size_px as f64) + }; + + let glyphs = [glyph_id as CGGlyph]; + let mut raw_bounds = + ct_font.get_bounding_rects_for_glyphs(kCTFontOrientationDefault, &glyphs); + + // Synthetic-bold rect expansion. The fill-stroke draw lays a stroke + // centered on the glyph outline, so it extends `line_width/2` outside + // the natural bounding rect — without expansion the stroke clips at the + // canvas edges. Not applied to color/sbix fonts; bitmap emoji aren't + // affected by synthetic bold. + if synthetic_bold && !is_color { + let line_width = (size_px as f64 / 14.0).max(1.0); + raw_bounds.size.width += line_width; + raw_bounds.size.height += line_width; + raw_bounds.origin.x -= line_width / 2.0; + raw_bounds.origin.y -= line_width / 2.0; + } + + // COLR color fonts routinely ship an empty outline for each glyph — the + // real rendering is layered color painting on top of an invisible base. + // The outline bbox is then 0×0, which `getBoundingRectsForGlyphs` dutifully + // reports. Fall back to a cell sized from the font's line metrics and the + // glyph's advance; CoreText paints the color layers into that box. sbix + // (bitmap) emoji reports a real bbox so they skip this branch. + let bounds = + if is_color && (raw_bounds.size.width <= 0.0 || raw_bounds.size.height <= 0.0) { + let ascent = ct_font.ascent(); + let descent = ct_font.descent(); + let mut advance = CGSize::new(0.0, 0.0); + unsafe { + ct_font.get_advances_for_glyphs( + kCTFontOrientationDefault, + glyphs.as_ptr(), + &mut advance, + 1, + ); + } + if advance.width <= 0.0 || ascent + descent <= 0.0 { + // No meaningful metrics — treat as truly empty. + return Some(RasterizedGlyph { + width: 0, + height: 0, + left: 0, + top: 0, + is_color, + bytes: Vec::new(), + }); + } + CGRect::new( + &CGPoint::new(0.0, -descent), + &CGSize::new(advance.width, ascent + descent), + ) + } else if raw_bounds.size.width <= 0.0 || raw_bounds.size.height <= 0.0 { + // Zero-area monochrome glyph (space, ZWJ, combining mark with no ink). + return Some(RasterizedGlyph { + width: 0, + height: 0, + left: 0, + top: 0, + is_color, + bytes: Vec::new(), + }); + } else { + raw_bounds + }; + + // 1px halo on each edge so anti-aliased outlines aren't clipped. + const PAD: i32 = 1; + let left = (bounds.origin.x.floor() as i32) - PAD; + let bottom = (bounds.origin.y.floor() as i32) - PAD; + let width = ((bounds.size.width.ceil() as i32) + 2 * PAD).max(1) as usize; + let height = ((bounds.size.height.ceil() as i32) + 2 * PAD).max(1) as usize; + // Top bearing in the terminal's y-down convention: baseline-to-top-edge, + // positive up. `bottom` is CoreGraphics' bottom-edge Y (positive up); the + // top edge sits `height` pixels above it. + let top = bottom + height as i32; + + let (mut bytes, cx) = if is_color { + let mut bytes = vec![0u8; width * height * 4]; + // Display-P3 color space (wider gamut than device RGB, which is + // what Apple Color Emoji assets are authored in) + premultiplied- + // first alpha + 32-bit little-endian byte order. Combined, this + // writes BGRA premultiplied bytes into `bytes` — we swap to RGBA + // below for atlas compatibility, but keep the alpha premultiplied. + let colorspace = + CGColorSpace::create_with_name(unsafe { kCGColorSpaceDisplayP3 }) + .unwrap_or_else(CGColorSpace::create_device_rgb); + let cx = CGContext::create_bitmap_context( + Some(bytes.as_mut_ptr() as *mut _), + width, + height, + 8, + width * 4, + &colorspace, + kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Little, + ); + (bytes, cx) + } else { + let mut bytes = vec![0u8; width * height]; + let cx = CGContext::create_bitmap_context( + Some(bytes.as_mut_ptr() as *mut _), + width, + height, + 8, + width, + &CGColorSpace::create_device_gray(), + kCGImageAlphaOnly, + ); + (bytes, cx) + }; + + cx.set_should_antialias(true); + cx.set_allows_antialiasing(true); + cx.set_should_smooth_fonts(true); + cx.set_gray_fill_color(0.0, 1.0); + // Synthetic bold via fill+stroke. Line width scales with size + // (1/14 of points, floored at 1 device pixel) so bold weight looks + // consistent across font sizes. + if synthetic_bold { + cx.set_text_drawing_mode(CGTextDrawingMode::CGTextFillStroke); + let line_width = (size_px as CGFloat / 14.0).max(1.0); + cx.set_line_width(line_width); + } else { + cx.set_text_drawing_mode(CGTextDrawingMode::CGTextFill); + } + // Rio snaps text to the cell grid — no subpixel positioning. + cx.set_allows_font_subpixel_positioning(false); + cx.set_should_subpixel_position_fonts(false); + cx.set_allows_font_subpixel_quantization(false); + cx.set_should_subpixel_quantize_fonts(false); + + // Shift the pen so the glyph's bounding rect lands at (0, 0)..(width, height) + // in the bitmap. CoreGraphics' origin is bottom-left; `left`/`bottom` are + // already the bitmap-space offsets of that origin. + let origin = CGPoint::new(-left as CGFloat, -bottom as CGFloat); + ct_font.draw_glyphs(&glyphs, &[origin], cx); + + if is_color { + // CoreGraphics wrote BGRA premultiplied (due to + // `byte_order_32_little | premul_first`). Rio's atlas is RGBA8Unorm + // with premultiplied-alpha shader blending, so swap B and R to get + // RGBA premultiplied. The shader converts P3 → sRGB at sample time. + bgra_to_rgba_in_place(&mut bytes); + } + + Some(RasterizedGlyph { + width: width as u32, + height: height as u32, + left, + top, + is_color, + bytes, + }) +} + +/// Stretch axis, used to build a CoreText width trait when resolving a font. +/// Mirrors CSS `font-stretch` values. `Normal` is the no-op default. +#[derive(Clone, Copy, Debug, Default)] +pub enum Stretch { + UltraCondensed, + ExtraCondensed, + Condensed, + SemiCondensed, + #[default] + Normal, + SemiExpanded, + Expanded, + ExtraExpanded, + UltraExpanded, +} + +impl Stretch { + /// Map to CoreText's normalized width trait (-1.0 = narrowest, 1.0 = widest). + fn as_ct_width(self) -> f64 { + match self { + Self::UltraCondensed => -1.0, + Self::ExtraCondensed => -0.75, + Self::Condensed => -0.5, + Self::SemiCondensed => -0.25, + Self::Normal => 0.0, + Self::SemiExpanded => 0.25, + Self::Expanded => 0.5, + Self::ExtraExpanded => 0.75, + Self::UltraExpanded => 1.0, + } + } +} + +/// Map CSS-style font weight (100–900) to CoreText's normalized weight trait +/// (-1.0 thin .. 0.0 regular .. 1.0 black). Values picked from the mapping +/// CoreText itself uses internally, rounded to the nearest standard step. +fn css_weight_to_ct(weight: u16) -> f64 { + match weight { + 0..=149 => -0.8, + 150..=249 => -0.6, + 250..=349 => -0.4, + 350..=449 => 0.0, + 450..=549 => 0.23, + 550..=649 => 0.3, + 650..=749 => 0.4, + 750..=849 => 0.56, + _ => 0.62, + } +} + +/// Resolve a font spec to a file path via CoreText descriptor matching. +/// +/// Build a descriptor with family + weight + slant + width, let CoreText do +/// the match, extract the URL. No CSS-spec matching code on our side — +/// CoreText handles proximity scoring and "closest match" rules natively. +/// Returns `None` if CoreText can't find anything, or the resolved +/// descriptor has no URL (e.g. system-supplied font without a backing file, +/// which shouldn't happen for user-installable fonts). +pub fn find_font_path( + family: &str, + weight: u16, + italic: bool, + stretch: Stretch, +) -> Option { + let family_cf = CFString::new(family); + let ct_weight = css_weight_to_ct(weight); + let ct_slant: f64 = if italic { 1.0 } else { 0.0 }; + let ct_width = stretch.as_ct_width(); + + let weight_key = unsafe { CFString::wrap_under_get_rule(kCTFontWeightTrait) }; + let slant_key = unsafe { CFString::wrap_under_get_rule(kCTFontSlantTrait) }; + let width_key = unsafe { CFString::wrap_under_get_rule(kCTFontWidthTrait) }; + let traits: CFDictionary = CFDictionary::from_CFType_pairs(&[ + (weight_key, CFNumber::from(ct_weight).as_CFType()), + (slant_key, CFNumber::from(ct_slant).as_CFType()), + (width_key, CFNumber::from(ct_width).as_CFType()), + ]); + + let family_key = unsafe { CFString::wrap_under_get_rule(kCTFontFamilyNameAttribute) }; + let traits_attr_key = + unsafe { CFString::wrap_under_get_rule(kCTFontTraitsAttribute) }; + let attrs: CFDictionary = CFDictionary::from_CFType_pairs(&[ + (family_key, family_cf.as_CFType()), + (traits_attr_key, traits.as_CFType()), + ]); + + let desc = font_descriptor::new_from_attributes(&attrs); + + let matched = unsafe { + let raw = CTFontDescriptorCreateMatchingFontDescriptor( + desc.as_concrete_TypeRef(), + std::ptr::null(), + ); + if raw.is_null() { + return None; + } + CTFontDescriptor::wrap_under_create_rule(raw) + }; + + matched.font_path() +} + +/// System default cascade (fallback) font file paths for `handle`'s font. +/// +/// This is CoreText's own recommended fallback order — the same chain it uses +/// for automatic font substitution when a string contains glyphs missing from +/// the requested font. Typically includes: the primary font's designer-chosen +/// fallbacks, system CJK fonts, Apple Color Emoji, and symbol fonts. +/// +/// Dynamic fallback: instead of hardcoding family names like +/// `"Apple Color Emoji"`, rely on CoreText to pick the right fonts for this +/// system. Paths that CoreText doesn't expose (some system fonts ship without +/// a file URL) are silently skipped. +pub fn default_cascade_list(handle: &FontHandle) -> Vec { + use core_foundation::array::CFArray; + let languages: CFArray = CFArray::from_CFTypes(&[]); + let cascade = + core_text::font::cascade_list_for_languages(&handle.base_font, &languages); + cascade.iter().filter_map(|desc| desc.font_path()).collect() +} + +/// Sorted, deduplicated list of every installed font family, straight from +/// CoreText. Used by the command-palette font browser. +/// +/// Replaces the `font-kit::SystemSource::all_families` call on macOS — font-kit +/// on macOS is itself a CoreText wrapper, so skipping the layer cuts one +/// dependency out of the hot path and sidesteps a known leak in its +/// enumeration API. +pub fn all_families() -> Vec { + let collection = font_collection::create_for_all_families(); + let Some(descriptors) = collection.get_descriptors() else { + return Vec::new(); + }; + let mut families: Vec = + descriptors.iter().map(|desc| desc.family_name()).collect(); + families.sort_unstable(); + families.dedup(); + families +} + +/// Swap B and R in each 4-byte pixel. CoreGraphics' `premul_first + +/// byte_order_32_little` writes BGRA; Rio's atlas is RGBA. Alpha stays put. +fn bgra_to_rgba_in_place(bytes: &mut [u8]) { + for px in bytes.chunks_exact_mut(4) { + px.swap(0, 2); + } +} + +/// Build a `font_introspector::Metrics` populated from CoreText, in font +/// design units. Used by `FontData::get_metrics` on macOS so the metrics +/// path works without raw font bytes. +/// +/// CTFont exposes everything we need directly (ascent, descent, leading, +/// underline, x-height, cap-height, units_per_em). Strikeout has no CT +/// API — we derive it like `font::macos::font_metrics` does, from the +/// OS/2 table if available or x-height/2 as a fallback. +pub fn design_unit_metrics(handle: &FontHandle) -> crate::font_introspector::Metrics { + let ct = &handle.base_font; + let upem = ct.units_per_em() as f32; + + // Base CTFont is at 1pt, so these are in points-per-unit; multiply by + // units_per_em for design units. + let ascent = ct.ascent() as f32 * upem; + let descent = ct.descent() as f32 * upem; + let leading = ct.leading() as f32 * upem; + let underline_offset = ct.underline_position() as f32 * upem; + let stroke_size = ct.underline_thickness() as f32 * upem; + let x_height = ct.x_height() as f32 * upem; + let cap_height = ct.cap_height() as f32 * upem; + + let (strikeout_offset, strikeout_stroke) = read_os2_strikeout(ct, 1.0) + .map(|(off, thick)| (off * upem, thick * upem)) + .unwrap_or((x_height * 0.5, stroke_size)); + + // `SymbolicTraitAccessors` is private in core-text; bit-mask the raw + // u32 traits instead. 1 << 10 is `kCTFontTraitMonoSpace`. + let is_monospace = (ct.symbolic_traits() & (1 << 10)) != 0; + + crate::font_introspector::Metrics { + units_per_em: upem as u16, + glyph_count: ct.glyph_count() as u16, + is_monospace, + has_vertical_metrics: false, + ascent, + descent, + leading, + vertical_ascent: 0.0, + vertical_descent: 0.0, + vertical_leading: 0.0, + cap_height, + x_height, + average_width: 0.0, + max_width: 0.0, + underline_offset, + strikeout_offset, + stroke_size: strikeout_stroke.max(stroke_size), + } +} + +/// Measure the CJK water ideograph "水" at design-unit width. Mirrors +/// `FaceMetrics::measure_cjk_character_width` for non-macOS. Used so the +/// macOS `get_metrics` path can still feed a correct `ic_width` into +/// FaceMetrics without needing the font's bytes. +pub fn cjk_ic_width(handle: &FontHandle) -> Option { + const WATER: char = '\u{6C34}'; + advance_units_for_char(handle, WATER).and_then(|(units, _upem)| { + if units > 0.0 { + Some(units as f64) + } else { + None + } + }) +} + +/// Return `(advance_in_design_units, units_per_em)` for `ch`, or `None` +/// if the font doesn't carry a glyph for it. +/// +/// Matches the old swash-based `compute_advance` return shape so the +/// caller (`font_cache.rs`) can scale to pixels the same way on both +/// platforms. All data comes from the CTFont — no raw bytes needed. +pub fn advance_units_for_char(handle: &FontHandle, ch: char) -> Option<(f32, u16)> { + use core_foundation::base::CFIndex; + use core_graphics::geometry::CGSize; + + let mut utf16 = [0u16; 2]; + let encoded = ch.encode_utf16(&mut utf16); + let count = encoded.len(); + let mut glyphs = [0 as CGGlyph; 2]; + let ok = unsafe { + handle.base_font.get_glyphs_for_characters( + utf16.as_ptr(), + glyphs.as_mut_ptr(), + count as CFIndex, + ) + }; + if !ok || glyphs[0] == 0 { + return None; + } + + // Base CTFont is at 1pt, so advance.width is in points-per-unit. Scale + // by units_per_em to get design-unit advance. + let mut advance = CGSize::new(0.0, 0.0); + unsafe { + handle.base_font.get_advances_for_glyphs( + kCTFontOrientationDefault, + glyphs.as_ptr(), + &mut advance, + 1, + ); + } + let units_per_em = handle.base_font.units_per_em() as u16; + Some((advance.width as f32 * units_per_em as f32, units_per_em)) +} + +/// Font-level attributes read straight from a `CTFont`. Mirrors the subset +/// of `font_introspector::Attributes` that Rio stores on `FontData` — used +/// to build a `FontData` from a path (or static bytes) without parsing the +/// font file ourselves. +#[derive(Debug, Clone, Copy)] +pub struct FontAttributes { + pub weight: u16, + pub is_italic: bool, + pub is_monospace: bool, + pub is_color: bool, +} + +/// Read `(weight, italic, monospace, color)` traits from a CTFont. +/// +/// `core-text`'s `TraitAccessors` / `SymbolicTraitAccessors` traits are +/// private to the crate, so rather than fight the SDK we read symbolic +/// traits as a raw `u32` bitfield (constants from Apple's CoreText.h). +/// Weight is left at the CSS default (`400`) — Rio only uses the weight +/// on fallback fonts to decide whether to synthesize bold, and cascade / +/// discovered fonts are overwhelmingly weight-neutral regulars anyway. +pub fn font_attributes(handle: &FontHandle) -> FontAttributes { + const K_CTFONT_TRAIT_ITALIC: u32 = 1 << 0; + const K_CTFONT_TRAIT_MONOSPACE: u32 = 1 << 10; + const K_CTFONT_TRAIT_COLOR_GLYPHS: u32 = 1 << 13; + + let traits: u32 = handle.base_font.symbolic_traits(); + FontAttributes { + weight: 400, + is_italic: (traits & K_CTFONT_TRAIT_ITALIC) != 0, + is_monospace: (traits & K_CTFONT_TRAIT_MONOSPACE) != 0, + is_color: (traits & K_CTFONT_TRAIT_COLOR_GLYPHS) != 0, + } +} + +/// Ask CoreText which font should render `ch` when `primary` can't. +/// +/// Wraps `CTFontCreateForString(primary, string, range)`. When the +/// primary font carries a glyph for the codepoint, CoreText returns +/// the primary itself; when it doesn't, CoreText walks its cascade +/// list and returns the best available fallback (emoji, CJK, symbol, +/// etc.). +/// +/// The returned handle is normalized to 1pt so it matches the +/// convention for stored [`FontHandle`]s — per-render sizing goes +/// through `clone_with_font_size` at shape/raster time. +/// +/// Lets Rio register an unknown cascade font on first encounter rather +/// than pre-registering every path-backed fallback at startup. Returns +/// `None` only when CoreText itself refuses (exceptionally rare). +pub fn discover_fallback(primary: &FontHandle, ch: char) -> Option { + use core_foundation::base::CFIndex; + + let ch_str = ch.to_string(); + let cf_string = CFString::new(&ch_str); + let range = CFRange::init(0, ch.len_utf16() as CFIndex); + let ctfont_ref = unsafe { + CTFontCreateForString( + primary.base_font.as_concrete_TypeRef(), + cf_string.as_concrete_TypeRef(), + range, + ) + }; + if ctfont_ref.is_null() { + return None; + } + let ct = unsafe { CTFont::wrap_under_create_rule(ctfont_ref) }; + Some(FontHandle { + base_font: ct.clone_with_font_size(1.0), + }) +} + +/// Check whether `handle`'s font has a real glyph for `ch`. +/// +/// Replaces the `font_introspector::FontRef::charmap().map(ch)` path on +/// macOS so the fallback walk in `lookup_for_font_match` doesn't need the +/// font's raw bytes — only the CTFont. Combined with path-based FontHandle +/// construction, this lets us drop `FONT_DATA_CACHE` entirely. +/// +/// Astral codepoints (`ch as u32 > 0xFFFF`) encode as a UTF-16 surrogate +/// pair; CoreText maps both units to one glyph (first index holds it, +/// second is `0xFFFF`). We want the first index. +pub fn font_has_char(handle: &FontHandle, ch: char) -> bool { + use core_foundation::base::CFIndex; + let mut utf16 = [0u16; 2]; + let encoded = ch.encode_utf16(&mut utf16); + let count = encoded.len(); + let mut glyphs = [0 as CGGlyph; 2]; + let ok = unsafe { + handle.base_font.get_glyphs_for_characters( + utf16.as_ptr(), + glyphs.as_mut_ptr(), + count as CFIndex, + ) + }; + ok && glyphs[0] != 0 +} + +/// Scaled line-level metrics for a font at a specific pixel size. Mirrors +/// the subset of swash's `Metrics` struct that Rio's render path reads. +/// +/// CoreText exposes ascent/descent/leading/underline/x_height natively; +/// strikeout has no dedicated API, so we derive it from x_height and +/// underline thickness the way most OpenType shapers do. Cap height isn't +/// exposed here yet — Rio's renderer doesn't read it. +#[derive(Debug, Clone, Copy)] +pub struct FontMetrics { + pub ascent: f32, + pub descent: f32, + pub leading: f32, + pub underline_offset: f32, + pub underline_thickness: f32, + pub strikeout_offset: f32, + pub strikeout_thickness: f32, + pub x_height: f32, +} + +/// Read CoreText's line metrics for `handle` at `size_px`. Cheap: clones the +/// base CTFont to the target size (CF refcount + trivial size field). +pub fn font_metrics(handle: &FontHandle, size_px: f32) -> FontMetrics { + let ct_font = handle.base_font.clone_with_font_size(size_px as f64); + let ascent = ct_font.ascent() as f32; + let descent = ct_font.descent() as f32; + let leading = ct_font.leading() as f32; + let underline_offset = ct_font.underline_position() as f32; + let underline_thickness = ct_font.underline_thickness() as f32; + let x_height = ct_font.x_height() as f32; + + // Prefer the designer's explicit strikeout values from the OS/2 table. + // If the font doesn't ship OS/2 or has it zeroed, fall back to the + // x-height heuristic — strike through the middle of the x-height band + // at underline thickness. + let (strikeout_offset, strikeout_thickness) = read_os2_strikeout(&ct_font, size_px) + .unwrap_or((x_height * 0.5, underline_thickness)); + + FontMetrics { + ascent, + descent, + leading, + underline_offset, + underline_thickness, + strikeout_offset, + strikeout_thickness, + x_height, + } +} + +/// Read `yStrikeoutPosition` and `yStrikeoutSize` from the font's OS/2 table, +/// scaled to pixels. Returns `None` when: +/// +/// - the font doesn't carry an OS/2 table (rare for any modern font), +/// - the table is truncated (malformed), +/// - `units_per_em` is 0 (shouldn't happen), or +/// - both fields are 0 (OS/2 present but strikeout unset — treat as missing +/// so the caller can fall back). +fn read_os2_strikeout(ct_font: &CTFont, size_px: f32) -> Option<(f32, f32)> { + const OS2_TAG: u32 = u32::from_be_bytes(*b"OS/2"); + + let cg_font = ct_font.copy_to_CGFont(); + let table = cg_font.copy_table_for_tag(OS2_TAG)?; + let bytes = table.bytes(); + // yStrikeoutSize: i16 big-endian at offset 26. + // yStrikeoutPosition: i16 big-endian at offset 28. + if bytes.len() < 30 { + return None; + } + let size_units = i16::from_be_bytes([bytes[26], bytes[27]]); + let pos_units = i16::from_be_bytes([bytes[28], bytes[29]]); + if size_units == 0 && pos_units == 0 { + return None; + } + let units_per_em = ct_font.units_per_em() as f32; + if units_per_em <= 0.0 { + return None; + } + let scale = size_px / units_per_em; + Some((pos_units as f32 * scale, size_units as f32 * scale)) +} + +/// One glyph in a shaped text run. +/// +/// The CoreText shaping output, flattened. `cluster` is a UTF-8 byte offset +/// into the original input text — not a UTF-16 index — so callers doing +/// `&text[cluster..]` get the source codepoint directly. +#[derive(Debug, Clone, Copy)] +pub struct ShapedGlyph { + pub id: u16, + /// Pen-relative x in device pixels; offset from the expected pen + /// position if every prior glyph had advanced by its own `advance`. + /// Zero for LTR Latin text without kerning/marks, which is the + /// simple-glyph fast path in `push_run_macos`. + pub x: f32, + /// Pen-relative y in device pixels; CoreText coordinate system (positive + /// = above baseline). + pub y: f32, + /// Distance to the next glyph's pen position, in device pixels. + pub advance: f32, + /// UTF-8 byte offset of the source codepoint in the original input. + pub cluster: u32, +} + +/// Shape a text run via CoreText's `CTLine`. +/// +/// Picks up OpenType GSUB/GPOS, AAT, kerning and ligatures — whatever the +/// font ships. +/// +/// Positions are emitted as pen-relative deltas (`ShapedGlyph::x`, +/// `::y`): offsets from the expected pen position if each prior glyph +/// had advanced by its own width. The pen accumulates across every +/// CTRun on the line — `CTRunGetPositions` is documented as +/// line-relative, never run-relative, so the last glyph of a non-first +/// run uses the next run's first position (or the line's typographic +/// width) as its advance sentinel. Never mix `CTRunGetTypographicBounds` +/// into that math — it's run-local and produces negative advances for +/// any run that doesn't start at x=0. +/// +/// If the primary font can't render every codepoint, CoreText may split +/// the line into multiple CTRuns and substitute from the cascade list. +/// Rio handles cascade substitution *before* shaping (via +/// `CodepointResolver`-style per-char font resolution with lazy +/// discovery), so shape calls here normally see a single font. When a +/// shape-time substitution does slip through, every run's glyphs are +/// rasterized against `handle` — producing .notdef / tofu for the +/// substituted glyphs, which is the signal that pre-resolution missed +/// something and needs widening. +pub fn shape_text(handle: &FontHandle, text: &str, size_px: f32) -> Vec { + if text.is_empty() { + return Vec::new(); + } + + let primary_ct_font = handle.base_font.clone_with_font_size(size_px as f64); + + let mut attr = CFMutableAttributedString::new(); + attr.replace_str(&CFString::new(text), CFRange::init(0, 0)); + let utf16_len = attr.char_len(); + unsafe { + attr.set_attribute( + CFRange::init(0, utf16_len), + kCTFontAttributeName, + &primary_ct_font, + ); + } + + let line = CTLine::new_with_attributed_string(attr.as_concrete_TypeRef()); + + // CoreText returns string indices as UTF-16 code-unit offsets. Callers + // expect UTF-8 byte offsets (that's how Rio slices text), so we need a + // mapping. For pure-ASCII input (vast majority of terminal output) every + // char is 1 byte in UTF-8 and 1 code unit in UTF-16, so the mapping is + // the identity — skip building the table entirely. + let utf16_to_utf8 = if text.is_ascii() { + None + } else { + Some(build_utf16_to_utf8_map(text)) + }; + + // End-of-line sentinel for the last glyph's advance. `CTLineGetTypographicBounds` + // returns the width of the full line, which is line-relative and therefore + // comparable to positions read from `CTRunGetPositions`. + let line_width = line.get_typographic_bounds().width as f32; + + // Retain every CTRun for the lifetime of the function so the + // `Cow<[T]>` slices returned by `run.glyphs()/positions()/string_indices()` + // stay valid. Cloning a CTRun is a CF retain, not a copy of the + // underlying data — cheap. This keeps the fast-path pointer read + // from core-text 21's accessors instead of forcing an owned Vec + // copy per run. + let glyph_runs = line.glyph_runs(); + let runs: Vec = glyph_runs.iter().map(|r| (*r).clone()).collect(); + if runs.is_empty() { + return Vec::new(); + } + + let mut shaped = Vec::new(); + let mut pen_x = 0.0f32; + + for run_idx in 0..runs.len() { + let run = &runs[run_idx]; + let glyphs = run.glyphs(); + if glyphs.is_empty() { + continue; + } + let positions = run.positions(); + let indices = run.string_indices(); + let n = glyphs.len(); + + // X-position just past this run's last glyph, in line + // coordinates. Scan forward for the first subsequent non-empty + // run's starting position; fall back to the line's typographic + // width when every following run is empty. Skipping empties + // matters on the rare line where CoreText splits an empty run + // in — without the skip, the preceding run's last glyph would + // advance all the way to `line_width` instead of to the next + // non-empty run's start. + let after_run_x = runs[run_idx + 1..] + .iter() + .find_map(|r| r.positions().first().map(|p| p.x as f32)) + .unwrap_or(line_width); + + for i in 0..n { + let pos_x = positions[i].x as f32; + let next_x = if i + 1 < n { + positions[i + 1].x as f32 + } else { + after_run_x + }; + let advance = next_x - pos_x; + let offset_x = pos_x - pen_x; + let offset_y = positions[i].y as f32; + + let utf16_idx = indices[i] as usize; + let cluster = match &utf16_to_utf8 { + Some(map) => map.get(utf16_idx).copied().unwrap_or(0) as u32, + // ASCII fast path: UTF-16 unit index == UTF-8 byte offset. + None => utf16_idx as u32, + }; + + shaped.push(ShapedGlyph { + id: glyphs[i], + x: offset_x, + y: offset_y, + advance, + cluster, + }); + + pen_x = next_x; + } + } + shaped +} + +/// Build a lookup from UTF-16 code-unit index → UTF-8 byte offset for the +/// start of the character that code unit is part of. `text.len()` sentinel +/// appended so out-of-range queries map to end-of-string cleanly. +fn build_utf16_to_utf8_map(text: &str) -> Vec { + let mut map = Vec::with_capacity(text.len()); + for (byte_idx, ch) in text.char_indices() { + for _ in 0..ch.len_utf16() { + map.push(byte_idx); + } + } + map.push(text.len()); + map +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::font::constants::FONT_CASCADIAMONO_NF_REGULAR; + use core_foundation::base::CFIndex; + + fn glyph_id_for_char(handle: &FontHandle, size: f64, ch: char) -> u16 { + let ct_font = handle.base_font.clone_with_font_size(size); + // Astral codepoints encode as a UTF-16 surrogate pair; CoreText maps + // both units to a single glyph (first slot holds the gid, second is + // 0xFFFF). We always want the first slot. + let mut utf16 = [0u16; 2]; + let encoded = ch.encode_utf16(&mut utf16); + let count = encoded.len(); + let mut glyphs = [0 as CGGlyph; 2]; + let ok = unsafe { + ct_font.get_glyphs_for_characters( + utf16.as_ptr(), + glyphs.as_mut_ptr(), + count as CFIndex, + ) + }; + assert!(ok, "{ch:?} not in font"); + glyphs[0] + } + + #[test] + fn shapes_ascii_monospace() { + let handle = + FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + let glyphs = shape_text(&handle, "Hello", 18.0); + + assert_eq!(glyphs.len(), 5, "one glyph per ASCII char"); + // Monospace: advances are all equal. + let first_advance = glyphs[0].advance; + for g in &glyphs { + assert!( + (g.advance - first_advance).abs() < 0.01, + "expected constant advance in monospace, got {:?}", + glyphs + ); + } + // LTR Latin without special positioning: x/y offsets from expected + // pen are all zero. If this regresses, the renderer falls off the + // simple-glyph fast path and double-accumulates positions. + for g in &glyphs { + assert!( + g.x.abs() < 0.001, + "x offset should be zero for LTR Latin, got {}", + g.x + ); + assert!(g.y.abs() < 0.001, "y offset should be zero, got {}", g.y); + } + // Clusters advance monotonically by 1 byte (ASCII). + for (i, g) in glyphs.iter().enumerate() { + assert_eq!(g.cluster, i as u32); + } + } + + #[test] + fn reads_strikeout_from_os2_table() { + // CascadiaMono carries a well-formed OS/2 table, so we should get + // real values — not the x-height/2 fallback. + let handle = + FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + let m = font_metrics(&handle, 24.0); + + assert!(m.strikeout_thickness > 0.0, "thickness should be positive"); + // Strikeout should sit somewhere in the x-height band — sanity check. + assert!(m.strikeout_offset > 0.0); + assert!( + m.strikeout_offset < m.ascent, + "strikeout offset should be below ascent" + ); + // A pure x_height/2 fallback would give exactly x_height/2. OS/2 + // values rarely coincide exactly with that — if they do, the test + // is weaker but still passes (since both yield sensible output). + eprintln!( + "CascadiaMono @24px: strikeout offset={} size={} x_height={}", + m.strikeout_offset, m.strikeout_thickness, m.x_height + ); + } + + #[test] + fn shape_ascii_skips_utf16_map() { + // Smoke test the fast path — mostly just confirm clusters are + // correctly identified as byte offsets for ASCII text. If the fast + // path swapped indices for the slow-path output it'd still compile, + // but the cluster values would be wrong for multi-byte text. + let handle = + FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + let glyphs = shape_text(&handle, "abcde", 18.0); + for (i, g) in glyphs.iter().enumerate() { + assert_eq!(g.cluster, i as u32); + } + } + + #[test] + fn shape_non_ascii_keeps_correct_clusters() { + // Mixed BMP non-ASCII: 'é' is 2 bytes in UTF-8, 1 code unit in UTF-16. + // Byte offsets should jump accordingly. + let handle = + FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + let glyphs = shape_text(&handle, "aébc", 18.0); + // Expected clusters: a=0, é=1, b=3 (after 2-byte é), c=4. + assert_eq!(glyphs.len(), 4); + assert_eq!(glyphs[0].cluster, 0); + assert_eq!(glyphs[1].cluster, 1); + assert_eq!(glyphs[2].cluster, 3); + assert_eq!(glyphs[3].cluster, 4); + } + + #[test] + fn shapes_empty_input() { + let handle = + FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + assert!(shape_text(&handle, "", 18.0).is_empty()); + } + + #[test] + fn discover_fallback_handles_covered_char() { + // For a codepoint the primary font already has, `CTFontCreateForString` + // returns a font (usually the primary itself). The result must not be + // null — Rio's lazy-discovery path relies on that. + let primary = + FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load primary"); + let result = discover_fallback(&primary, 'A'); + assert!( + result.is_some(), + "discover_fallback should return a font for a covered char" + ); + } + + #[test] + fn discover_fallback_returns_a_font_for_cjk() { + // CascadiaMono doesn't have U+6C34 ('水'). CoreText's cascade + // must produce *some* font — Rio registers whichever one comes + // back. The test asserts non-null and, to avoid being flaky on + // different macOS versions, does not hardcode the PS name. + let primary = + FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load primary"); + let fallback = discover_fallback(&primary, '\u{6C34}') + .expect("CoreText should cascade to a CJK font for 水"); + // The discovered font must cover the codepoint — the whole + // point of the cascade is that it can render what primary + // couldn't. + assert!( + font_has_char(&fallback, '\u{6C34}'), + "CTFontCreateForString returned a font that doesn't cover U+6C34" + ); + } + + #[test] + fn shape_cascades_cjk_and_keeps_advances_positive() { + // Regression test for the "title moves to the left" bug: when the + // primary font (CascadiaMono) can't render a codepoint and + // CoreText substitutes from its internal cascade list, the + // resulting multi-CTRun output must still produce monotonically + // increasing pen positions and no negative advances. The old + // code read run-local `CTRunGetTypographicBounds` width against + // line-relative positions from `CTRunGetPositions`, which made + // the last glyph of every non-first run advance negatively, + // pulling later text to the left. + // + // Rio's production path avoids shape-time substitution by + // pre-resolving per-character font_ids (so `shape_text` sees a + // single-font fragment), but we still exercise the multi-run + // path here because it's the cheapest invariant to regress on. + let handle = + FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + // "A水B" — the CJK "water" ideograph is not in CascadiaMono, so + // CoreText will cascade into a system CJK font for the middle + // glyph, splitting the line across 3 CTRuns. + let glyphs = shape_text(&handle, "A水B", 18.0); + assert!(!glyphs.is_empty(), "expected at least one glyph, got none"); + + // Invariant 1: every glyph's advance is positive. The old bug + // made the last glyph of each non-first CTRun have a negative + // advance (`bounds.width - positions[last].x`). + for g in &glyphs { + assert!( + g.advance > 0.0, + "non-positive advance {} at cluster {}", + g.advance, + g.cluster + ); + } + + // Invariant 2: reconstructing the pen by summing advances and + // applying per-glyph `x` deltas is monotonically non-decreasing. + // Any mix-up between line-relative and run-local coordinates + // would break this. + let mut cursor = 0.0f32; + for g in &glyphs { + let glyph_x = cursor + g.x; + assert!( + glyph_x + 0.001 >= cursor - g.advance, + "pen moved backwards at cluster {}: cursor={}, glyph_x={}", + g.cluster, + cursor, + glyph_x + ); + cursor += g.advance; + } + let total: f32 = glyphs.iter().map(|g| g.advance).sum(); + assert!(total > 0.0, "expected positive total advance, got {total}"); + } + + #[test] + fn static_bytes_path_rasterizes() { + // Full no-copy path: .rodata bytes → CFDataCreateWithBytesNoCopy → + // CTFontDescriptor → CTFont → rasterize. Verifies the FFI is wired + // correctly and the ref-don't-copy CFData is accepted by + // CTFontManagerCreateFontDescriptorFromData. + let handle = FontHandle::from_static_bytes(FONT_CASCADIAMONO_NF_REGULAR) + .expect("static bytes should parse"); + let size = 18.0; + let gid = glyph_id_for_char(&handle, size as f64, 'M'); + let g = rasterize_glyph(&handle, gid, size, false, false, false) + .expect("rasterize returned None"); + assert!(g.width > 0 && g.height > 0); + // Inked: at least one non-zero alpha pixel. + assert!(g.bytes.iter().any(|&b| b > 0)); + } + + #[test] + fn rasterizes_an_inked_glyph() { + let handle = + FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + let size = 24.0; + let gid = glyph_id_for_char(&handle, size as f64, 'A'); + let g = rasterize_glyph(&handle, gid, size, false, false, false) + .expect("rasterize returned None"); + + assert!(g.width > 0, "A should have non-zero width"); + assert!(g.height > 0, "A should have non-zero height"); + assert!(!g.is_color); + assert_eq!(g.bytes.len(), (g.width * g.height) as usize); + + let total: u64 = g.bytes.iter().map(|&b| b as u64).sum(); + assert!(total > 0, "A should have some inked pixels"); + } + + #[test] + fn find_font_path_resolves_system_family() { + // Menlo ships on every macOS install since 10.6. + let path = find_font_path("Menlo", 400, false, Stretch::Normal) + .expect("Menlo should resolve"); + assert!(path.exists(), "resolved path should exist: {path:?}"); + assert!( + path.extension() + .is_some_and(|e| e == "ttf" || e == "ttc" || e == "otf"), + "unexpected font extension: {path:?}" + ); + } + + #[test] + fn default_cascade_list_is_nonempty() { + // Every macOS install has a system cascade list for any loaded font. + // This test is a regression guard — if `cascade_list_for_languages` + // ever returns empty for a legit font, dynamic fallback stops working. + let handle = + FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + let paths = default_cascade_list(&handle); + assert!( + !paths.is_empty(), + "CoreText should surface a non-empty cascade" + ); + // Every returned path should be a real file on disk. System fonts + // that don't ship a file URL are filtered out by `font_path()`. + for p in &paths { + assert!(p.exists(), "cascade path should exist: {p:?}"); + } + } + + #[test] + fn from_bytes_index_zero_matches_from_bytes() { + // For a plain TTF the single font is at index 0; both loaders + // should land on equivalent CTFonts. + let a = FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("a"); + let b = FontHandle::from_bytes_index(FONT_CASCADIAMONO_NF_REGULAR, 0).expect("b"); + // Compare via a shape probe — identical glyph ids means same face. + let gid_a = glyph_id_for_char(&a, 18.0, 'A'); + let gid_b = glyph_id_for_char(&b, 18.0, 'A'); + assert_eq!(gid_a, gid_b); + } + + #[test] + fn from_bytes_index_out_of_range_returns_none() { + let h = FontHandle::from_bytes_index(FONT_CASCADIAMONO_NF_REGULAR, 99); + assert!(h.is_none(), "index 99 on a single-font TTF should fail"); + } + + #[test] + fn all_families_returns_sorted_nonempty_list() { + let families = all_families(); + assert!( + !families.is_empty(), + "system should expose some font families" + ); + // Collection is deduped + sorted. + let mut sorted = families.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(families, sorted); + } + + #[test] + fn rasterizes_a_color_emoji_glyph() { + use crate::font::constants::FONT_TWEMOJI_EMOJI; + + // COLR fonts report a 0×0 outline bbox because the real drawing + // happens on color layers. This test asserts our fallback path + // (derive bbox from ascent/descent + advance) still produces an + // inked bitmap with real color content. + let handle = FontHandle::from_bytes(FONT_TWEMOJI_EMOJI).expect("load twemoji"); + let size = 24.0; + // U+1F600 grinning face — the canonical "is color emoji working" check. + let gid = glyph_id_for_char(&handle, size as f64, '\u{1F600}'); + + let g = rasterize_glyph(&handle, gid, size, true, false, false) + .expect("emoji rasterize returned None"); + + assert!( + g.width > 0 && g.height > 0, + "expected inked emoji, got {g:?}" + ); + assert!(g.is_color); + assert_eq!(g.bytes.len(), (g.width * g.height * 4) as usize); + // If CoreText dropped COLR tables we'd get a black silhouette — + // alpha non-zero but all RGB zero. Assert some color is present. + let rgb_sum: u64 = g + .bytes + .chunks_exact(4) + .map(|px| px[0] as u64 + px[1] as u64 + px[2] as u64) + .sum(); + assert!( + rgb_sum > 0, + "COLR tables appear not to have been preserved — emoji drew as \ + a monochrome silhouette" + ); + } + + #[test] + fn zero_ink_glyph_yields_empty_bitmap() { + let handle = + FontHandle::from_bytes(FONT_CASCADIAMONO_NF_REGULAR).expect("load font"); + let size = 24.0; + let gid = glyph_id_for_char(&handle, size as f64, ' '); + let g = rasterize_glyph(&handle, gid, size, false, false, false) + .expect("rasterize returned None"); + + // Space carries advance but no ink; rasterizer should short-circuit. + assert_eq!(g.width, 0); + assert_eq!(g.height, 0); + assert!(g.bytes.is_empty()); + } +} diff --git a/sugarloaf/src/font/mod.rs b/sugarloaf/src/font/mod.rs index ed4740690e..4400df2c26 100644 --- a/sugarloaf/src/font/mod.rs +++ b/sugarloaf/src/font/mod.rs @@ -3,6 +3,8 @@ mod fallbacks; pub mod fonts; #[cfg(not(target_arch = "wasm32"))] pub mod loader; +#[cfg(target_os = "macos")] +pub mod macos; pub mod metrics; pub mod nerd_font_attributes; pub mod text_run_cache; @@ -32,6 +34,25 @@ use std::sync::{Arc, OnceLock}; pub use crate::font_introspector::{Style, Weight}; +/// Cross-platform shim: non-macOS threads `&loader::Database` through to +/// `find_font`; macOS drops it since CoreText handles matching directly and +/// we never build a Database there. The macro lets call sites stay uniform +/// (`try_find_font!(&db, spec, evict)`) even though `db` doesn't exist on +/// macOS — macOS expansion simply discards that token. +#[cfg(target_os = "macos")] +macro_rules! try_find_font { + ($_db:expr, $spec:expr, $evictable:expr) => {{ + find_font($spec, $evictable) + }}; +} + +#[cfg(not(target_os = "macos"))] +macro_rules! try_find_font { + ($db:expr, $spec:expr, $evictable:expr) => {{ + find_font($db, $spec, $evictable) + }}; +} + // Type alias for the font data cache to improve readability type FontDataCache = Arc>; @@ -89,19 +110,57 @@ pub fn lookup_for_font_match( } } - if let Some((shared_data, offset, key)) = library.get_data(&font_id) { - let font_ref = FontRef { - data: shared_data.as_ref(), - offset, - key, - }; - let charmap = font_ref.charmap(); - let status = cluster.map(|ch| charmap.map(ch)); - if status != Status::Discard { - *synth = font_synth; - search_result = Some((font_id, is_emoji)); - break; + #[cfg(target_os = "macos")] + let matched = { + // Ask the CTFont directly whether it carries a glyph for each + // codepoint. Avoids the `get_data` byte load — the fallback + // walk no longer touches the font file(s) at all. + let handle_opt = library.inner.get(&font_id).and_then(|font| { + if let Some(path) = &font.path { + crate::font::macos::FontHandle::from_path(path) + } else if let Some(bytes) = &font.data { + crate::font::macos::FontHandle::from_bytes(bytes.as_ref()) + } else { + None + } + }); + if let Some(handle) = handle_opt { + let status = cluster.map(|ch| { + // Non-zero u16 == "has glyph"; swash's cluster.map only + // distinguishes zero vs non-zero, so `1` is fine as a + // placeholder when CTFont carries the codepoint. + if crate::font::macos::font_has_char(&handle, ch) { + 1 + } else { + 0 + } + }); + status != Status::Discard + } else { + false + } + }; + + #[cfg(not(target_os = "macos"))] + let matched = { + if let Some((shared_data, offset, key)) = library.get_data(&font_id) { + let font_ref = FontRef { + data: shared_data.as_ref(), + offset, + key, + }; + let charmap = font_ref.charmap(); + let status = cluster.map(|ch| charmap.map(ch)); + status != Status::Discard + } else { + false } + }; + + if matched { + *synth = font_synth; + search_result = Some((font_id, is_emoji)); + break; } } @@ -138,9 +197,119 @@ impl FontLibrary { ) } + /// Parsed CoreText font for `font_id` — a direct read of the handle + /// stored on the corresponding `FontData` (per-font pointer rather + /// than a library-global cache). + /// + /// Clone is a cheap CF retain; callers clone out to escape the read + /// lock scope. Returns `None` for unknown font ids or for fonts that + /// weren't eagerly given a handle at construction (non-macOS test + /// fonts loaded via `from_slice`). + /// + /// parking_lot's `RwLock` supports recursive reads, so calling this + /// from code that already holds a read lock on `inner` is safe. + #[cfg(target_os = "macos")] + pub fn ct_font(&self, font_id: usize) -> Option { + self.inner + .read() + .inner + .get(&font_id) + .and_then(|f| f.handle().cloned()) + } + + /// Resolve a PostScript name back to Rio's `font_id`. Returns + /// `None` when the library doesn't hold a font with that name. + #[cfg(target_os = "macos")] + pub fn font_id_for_postscript_name(&self, name: &str) -> Option { + self.inner.read().font_id_for_postscript_name(name) + } + + /// Resolve `ch` to a Rio `(font_id, is_emoji)`, walking the + /// registered fonts first and falling back to CoreText's cascade + /// via `CTFontCreateForString` when no registered font carries + /// the glyph. Discovered fonts are registered in-place so + /// subsequent queries for the same codepoint (or any codepoint + /// the discovered font covers) hit the registered-font walk + /// without re-invoking CoreText. + /// + /// Pre-shaping resolution with lazy discovery: the shaper + /// operates on a single font per call, and this method + /// guarantees that font covers the codepoint, so `CTLine` never + /// has to cascade-substitute at shape time. + /// + /// Returns `(0, false)` only when CoreText itself can't find + /// any font for the codepoint (truly unsupported by the system) + /// or when the library is empty. Both cases render as tofu. + #[cfg(target_os = "macos")] + pub fn resolve_font_for_char( + &self, + ch: char, + fragment_style: &SpanStyle, + ) -> (usize, bool) { + // Fast path: codepoint is covered by an already-registered + // font. No locks upgraded, no CoreText call. + if let Some(found) = self + .inner + .read() + .find_best_font_match_strict(ch, fragment_style) + { + return found; + } + + // Slow path: ask CoreText which font it would cascade to, + // then register that font so future lookups hit the fast + // path. The read lock is released between phases so a + // concurrent resolver can't block on our registration work. + let Some(primary) = self.ct_font(FONT_ID_REGULAR) else { + return (0, false); + }; + let Some(discovered) = crate::font::macos::discover_fallback(&primary, ch) else { + return (0, false); + }; + let ps_name = discovered.postscript_name(); + + // Has it already been registered by a concurrent resolver? + { + let lib = self.inner.read(); + if let Some(existing) = lib.font_id_for_postscript_name(&ps_name) { + let is_emoji = lib + .inner + .get(&existing) + .map(|fd| fd.is_emoji) + .unwrap_or(false); + return (existing, is_emoji); + } + } + + // Register under write lock. Re-check inside the lock so a + // race doesn't insert the same PS name twice. + let mut lib = self.inner.write(); + if let Some(existing) = lib.font_id_for_postscript_name(&ps_name) { + let is_emoji = lib + .inner + .get(&existing) + .map(|fd| fd.is_emoji) + .unwrap_or(false); + return (existing, is_emoji); + } + let font_data = FontData::from_ctfont_macos(discovered); + let is_emoji = font_data.is_emoji; + let new_id = lib.inner.len(); + lib.insert(font_data); + tracing::debug!( + "CoreText cascade discovered {} for U+{:04X}, registered as font_id {}", + ps_name, + ch as u32, + new_id + ); + (new_id, is_emoji) + } + /// Sorted, deduplicated list of every font family name the host - /// system exposes via `font-kit`'s `SystemSource` (CoreText on - /// macOS, DirectWrite on Windows, fontconfig on Linux). + /// system exposes. On macOS this goes straight through CoreText; on + /// Linux and Windows it uses `font-kit`'s `SystemSource` (fontconfig + /// on Linux, DirectWrite on Windows). `wasm32` has no system font + /// enumeration. /// /// Intended for the command-palette "List Fonts" browser, so users /// can see what's installed without leaving the terminal. Does NOT @@ -149,7 +318,12 @@ impl FontLibrary { /// `FontLibrary` past load, and walking the dirs again would /// duplicate I/O. A follow-up can widen this once `FontLibrary` /// keeps a `Database` alive. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(target_os = "macos")] + pub fn family_names(&self) -> Vec { + crate::font::macos::all_families() + } + + #[cfg(all(not(target_os = "macos"), not(target_arch = "wasm32")))] pub fn family_names(&self) -> Vec { let source = font_kit::source::SystemSource::new(); let mut families = source.all_families().unwrap_or_default(); @@ -187,6 +361,13 @@ pub struct FontLibraryData { pub hinting: bool, // Cache primary font metrics for consistent cell dimensions (consistent metrics approach) primary_metrics_cache: FxHashMap, + /// PostScript-name → `font_id` lookup, populated on `insert`. + /// Used by the macOS shaper to map CoreText's per-CTRun font back to + /// a Rio `font_id` when cascade-list substitution kicks in. Kept + /// behind `cfg(target_os = "macos")` so non-mac builds don't pay + /// the storage cost. + #[cfg(target_os = "macos")] + postscript_to_id: FxHashMap, } impl Default for FontLibraryData { @@ -196,6 +377,8 @@ impl Default for FontLibraryData { hinting: true, symbol_maps: None, primary_metrics_cache: FxHashMap::default(), + #[cfg(target_os = "macos")] + postscript_to_id: FxHashMap::default(), } } } @@ -257,9 +440,89 @@ impl FontLibraryData { Some((0, false)) } + /// Like [`find_best_font_match`](Self::find_best_font_match) but + /// returns `None` on a true miss instead of the `(0, false)` + /// last-resort fallback. Enables callers (currently the macOS + /// lazy-discovery path on [`FontLibrary`]) to distinguish + /// "primary font is the answer" from "nothing in the library + /// covers this codepoint" so discovery can fire on the latter. + #[cfg(target_os = "macos")] + #[inline] + pub fn find_best_font_match_strict( + &self, + ch: char, + fragment_style: &SpanStyle, + ) -> Option<(usize, bool)> { + let mut synth = Synthesis::default(); + let mut char_cluster = CharCluster::new(); + let mut parser = Parser::new( + Script::Latin, + std::iter::once(Token { + ch, + offset: 0, + len: ch.len_utf8() as u8, + info: ch.properties().into(), + data: 0, + }), + ); + if !parser.next(&mut char_cluster) { + return None; + } + + if let Some(symbol_maps) = &self.symbol_maps { + for symbol_map in symbol_maps { + if symbol_map.range.contains(&ch) { + return Some((symbol_map.font_index, false)); + } + } + } + + let is_italic = fragment_style.font_attrs.style() == Style::Italic; + let is_bold = fragment_style.font_attrs.weight() == Weight::BOLD; + + let spec_font_attr = if is_bold && is_italic { + Some((Style::Italic, true)) + } else if is_bold { + Some((Style::Normal, true)) + } else if is_italic { + Some((Style::Italic, false)) + } else { + None + }; + + lookup_for_font_match( + &mut char_cluster, + &mut synth, + self, + spec_font_attr.as_ref(), + ) + } + #[inline] pub fn insert(&mut self, font_data: FontData) { - self.inner.insert(self.inner.len(), font_data); + let id = self.inner.len(); + // Pull the PS name off the handle here so the shaper's cascade- + // run resolver can map a CoreText `CTFont` back to a Rio + // `font_id`. Only paid at load time. Duplicate names (same face + // loaded twice) resolve to the first-inserted id, which is the + // entry the rest of the library already points at — good enough + // for cascade mapping. + #[cfg(target_os = "macos")] + if let Some(handle) = font_data.handle() { + self.postscript_to_id + .entry(handle.postscript_name()) + .or_insert(id); + } + self.inner.insert(id, font_data); + } + + /// Rio `font_id` registered for the given PostScript name, or + /// `None` when no loaded font reports that name. Used by the macOS + /// shaper to map a CTFont pulled from a `CTRun`'s attributes back + /// onto Rio's font registry. + #[cfg(target_os = "macos")] + pub fn font_id_for_postscript_name(&self, name: &str) -> Option { + self.postscript_to_id.get(name).copied() } #[inline] @@ -360,14 +623,22 @@ impl FontLibraryData { font_family_overwrite.clone_into(&mut spec.italic.family); } + // On macOS we resolve fonts through CoreText (see `find_font` below) + // and never touch `loader::Database`, so skip its construction entirely + // — `SystemSource::new` walks the full CoreText font list on init, which + // is wasted work when we're about to do the same thing ourselves. + #[cfg(not(target_os = "macos"))] let mut db = loader::Database::new(); - spec.additional_dirs - .unwrap_or_default() - .into_iter() - .map(PathBuf::from) - .for_each(|p| db.load_fonts_dir(p)); - match find_font(&db, spec.regular, false) { + let additional_dirs = spec.additional_dirs.unwrap_or_default(); + for dir in additional_dirs.into_iter().map(PathBuf::from) { + #[cfg(target_os = "macos")] + crate::font::macos::register_fonts_in_dir(&dir); + #[cfg(not(target_os = "macos"))] + db.load_fonts_dir(dir); + } + + match try_find_font!(&db, spec.regular, false) { FindResult::Found(data) => { self.insert(data); } @@ -381,7 +652,7 @@ impl FontLibraryData { } } - match find_font(&db, spec.italic, false) { + match try_find_font!(&db, spec.italic, false) { FindResult::Found(data) => { self.insert(data); } @@ -394,7 +665,7 @@ impl FontLibraryData { } } - match find_font(&db, spec.bold, false) { + match try_find_font!(&db, spec.bold, false) { FindResult::Found(data) => { self.insert(data); } @@ -407,7 +678,7 @@ impl FontLibraryData { } } - match find_font(&db, spec.bold_italic, true) { + match try_find_font!(&db, spec.bold_italic, true) { FindResult::Found(data) => { self.insert(data); } @@ -421,13 +692,13 @@ impl FontLibraryData { } for fallback in fallbacks::external_fallbacks() { - match find_font( + match try_find_font!( &db, SugarloafFont { family: fallback, ..SugarloafFont::default() }, - true, + true ) { FindResult::Found(data) => { self.insert(data); @@ -439,6 +710,37 @@ impl FontLibraryData { } } + // On macOS, append CoreText's default cascade list for the primary + // font. Dynamic fallback: we let CoreText name every font it would + // normally fall back to (emoji, CJK, symbols, script typefaces) so + // users get the same coverage as any other macOS app. + // + // Critically, each cascade entry is constructed via `from_path_macos` + // — CoreText opens the file on demand, Rio never reads the bytes. + // This keeps us from pulling the 200 MB Apple Color Emoji file into + // `FONT_DATA_CACHE`. + #[cfg(target_os = "macos")] + { + let primary_handle = self.inner.get(&FONT_ID_REGULAR).and_then(|f| { + if let Some(path) = &f.path { + crate::font::macos::FontHandle::from_path(path) + } else if let Some(bytes) = &f.data { + crate::font::macos::FontHandle::from_bytes(bytes.as_ref()) + } else { + None + } + }); + if let Some(primary_handle) = primary_handle { + let default_spec = SugarloafFont::default(); + for path in crate::font::macos::default_cascade_list(&primary_handle) { + if let Ok(font_data) = FontData::from_path_macos(path, &default_spec) + { + self.insert(font_data); + } + } + } + } + // User-configured fallbacks run before the bundled emoji / Nerd Font // slices so a color emoji family dropped into `extras` (e.g. // `extras = [{family = "Apple Color Emoji"}]`) takes priority over @@ -446,8 +748,16 @@ impl FontLibraryData { // from_data` via `has_color_tables` (COLR/CBDT/CBLC/sbix), so real // emoji families get the wide-cell / color-atlas treatment while // Nerd Font families stay single-cell. + // + // On macOS the CoreText cascade list inserted above already includes + // emoji, CJK, symbols, and every other system-suggested fallback — + // `font.extras` is redundant there and would only duplicate or + // compete with the cascade order. Skipped entirely. + #[cfg(target_os = "macos")] + let _ = spec.extras; + #[cfg(not(target_os = "macos"))] for extra_font in spec.extras { - match find_font( + match try_find_font!( &db, SugarloafFont { family: extra_font.family, @@ -455,7 +765,7 @@ impl FontLibraryData { weight: extra_font.weight, width: extra_font.width, }, - true, + true ) { FindResult::Found(data) => { self.insert(data); @@ -466,8 +776,10 @@ impl FontLibraryData { } } - self.insert(FontData::from_slice(FONT_TWEMOJI_EMOJI).unwrap()); - self.insert(FontData::from_slice(FONT_SYMBOLS_NERD_FONT_MONO).unwrap()); + // macOS finds Apple Color Emoji through `fallbacks::external_fallbacks` + // above, so skip embedding Twemoji there. + #[cfg(not(target_os = "macos"))] + self.insert(FontData::from_static_slice(FONT_TWEMOJI_EMOJI).unwrap()); // TODO: Currently, it will naively just extend fonts from symbol_map // without even look if the font has been loaded before. @@ -487,13 +799,13 @@ impl FontLibraryData { if let Some(symbol_map) = spec.symbol_map { let mut symbol_maps = Vec::default(); for extra_font_from_symbol_map in symbol_map { - match find_font( + match try_find_font!( &db, SugarloafFont { family: extra_font_from_symbol_map.font_family, ..SugarloafFont::default() }, - true, + true ) { FindResult::Found(data) => { if let Some(start) = @@ -533,24 +845,61 @@ impl FontLibraryData { #[cfg(target_arch = "wasm32")] pub fn load(&mut self, _font_spec: SugarloafFonts) -> Vec { - self.insert(FontData::from_slice(FONT_CASCADIAMONO_REGULAR).unwrap()); + self.insert(FontData::from_static_slice(FONT_CASCADIAMONO_NF_REGULAR).unwrap()); vec![] } } -/// Atomically reference counted, heap allocated or memory mapped buffer. +/// Font byte storage. Three variants so each load path pays the smallest +/// cost it can: +/// +/// - [`Heap`](Self::Heap): Arc-shared `[u8]` on the heap. Fallback path +/// for bytes we genuinely own (tests, `from_slice`). +/// - [`Static`](Self::Static): a reference into `'static` data. Bundled +/// fonts use this so their bytes stay in the binary's `.rodata` instead +/// of being copied. +/// - [`Mmap`](Self::Mmap): memory-mapped file. Non-mac file reads use this +/// so the kernel backs the bytes with the font file and only pages in +/// what's actually touched. A 100 MB emoji font costs maybe 1 MB of +/// resident RAM instead of 100. +/// +/// Clone is atomic-refcount on [`Heap`]/[`Mmap`] and a pointer copy on +/// [`Static`]; all three are effectively free. #[derive(Clone, Debug)] -pub struct SharedData { - inner: Arc<[u8]>, +pub enum SharedData { + Heap(Arc<[u8]>), + Static(&'static [u8]), + #[cfg(not(target_arch = "wasm32"))] + Mmap(Arc), } impl SharedData { - /// Creates shared data from the specified bytes. + /// Wrap an owned byte buffer. Used for ad-hoc / test loads; production + /// font paths prefer [`from_static`](Self::from_static) or + /// [`from_mmap`](Self::from_mmap). pub fn new(data: Vec) -> Self { - Self { - inner: Arc::from(data), - } + Self::Heap(Arc::from(data)) + } + + /// Reference `'static` bytes. Zero-copy — bytes stay wherever they are + /// (typically the binary's `.rodata` for bundled fonts). + pub const fn from_static(data: &'static [u8]) -> Self { + Self::Static(data) + } + + /// Wrap a memory-mapped file. The `Arc` keeps the mapping alive + /// until every `SharedData` referencing it drops. + #[cfg(not(target_arch = "wasm32"))] + pub fn from_mmap(mmap: memmap2::Mmap) -> Self { + Self::Mmap(Arc::new(mmap)) + } + + /// `true` when this `SharedData` references the binary's `.rodata`. + /// Callers (the CoreText path) use this to pick a no-copy + /// `CFDataCreateWithBytesNoCopy` when true. + pub const fn is_static(&self) -> bool { + matches!(self, Self::Static(_)) } } @@ -558,13 +907,23 @@ impl std::ops::Deref for SharedData { type Target = [u8]; fn deref(&self) -> &Self::Target { - (*self.inner).as_ref() + match self { + Self::Heap(a) => a, + Self::Static(s) => s, + #[cfg(not(target_arch = "wasm32"))] + Self::Mmap(m) => m.as_ref(), + } } } impl AsRef<[u8]> for SharedData { fn as_ref(&self) -> &[u8] { - (*self.inner).as_ref() + match self { + Self::Heap(a) => a, + Self::Static(s) => s, + #[cfg(not(target_arch = "wasm32"))] + Self::Mmap(m) => m.as_ref(), + } } } @@ -586,6 +945,13 @@ pub struct FontData { pub is_emoji: bool, // Cached metrics per font size (per-font caching) metrics_cache: FxHashMap, + /// Parsed CoreText handle, constructed once at `FontData` creation + /// and cloned out via CF refcount on every access. Per-font pointer + /// rather than a library-global cache. `Clone` of `FontHandle` is an + /// atomic retain, so handing it out to the shape/raster/charmap paths + /// is effectively free. + #[cfg(target_os = "macos")] + handle: Option, } impl PartialEq for FontData { @@ -602,6 +968,21 @@ impl FontData { &self.data } + /// On-disk path the font was loaded from, if any. Embedded fonts + /// (bundled `&[u8]` constants) have no path. + pub fn path(&self) -> Option<&PathBuf> { + self.path.as_ref() + } + + /// The parsed CoreText handle, or `None` if this font was constructed + /// via a path that doesn't run on macOS. Access is a direct field read + /// (no map lookup); callers clone the handle (cheap CF retain) to + /// escape the lock scope. + #[cfg(target_os = "macos")] + pub fn handle(&self) -> Option<&crate::font::macos::FontHandle> { + self.handle.as_ref() + } + /// Get font offset pub fn offset(&self) -> u32 { self.offset @@ -621,6 +1002,49 @@ impl FontData { return Some(*cached); } + // macOS path-only (or handle-only) fonts: metrics come straight + // from CoreText. This fires for every cascade-list fallback, any + // user font discovered through `find_font_path`, AND any font + // registered at runtime via `from_ctfont_macos` (lazy cascade + // discovery) — none of which have `data` set. Prefer the stored + // CTFont handle when present (cheap CF retain); otherwise + // rebuild it from the path. + #[cfg(target_os = "macos")] + if self.data.is_none() { + let handle = if let Some(h) = self.handle.as_ref() { + h.clone() + } else { + self.path + .as_ref() + .and_then(|p| crate::font::macos::FontHandle::from_path(p))? + }; + let font_metrics = crate::font::macos::design_unit_metrics(&handle); + let scaled_metrics = font_metrics.scale(font_size); + let face_metrics = FaceMetrics { + cell_width: scaled_metrics.max_width as f64, + ascent: scaled_metrics.ascent as f64, + descent: scaled_metrics.descent as f64, + line_gap: scaled_metrics.leading as f64, + underline_position: Some(scaled_metrics.underline_offset as f64), + underline_thickness: Some(scaled_metrics.stroke_size as f64), + strikethrough_position: Some(scaled_metrics.strikeout_offset as f64), + strikethrough_thickness: Some(scaled_metrics.stroke_size as f64), + cap_height: Some(scaled_metrics.cap_height as f64), + ex_height: Some(scaled_metrics.x_height as f64), + ic_width: crate::font::macos::cjk_ic_width(&handle).map(|u| { + // design units → pixels at font_size + u * font_size as f64 / scaled_metrics.units_per_em as f64 + }), + }; + let metrics = if let Some(primary) = primary_metrics { + Metrics::calc_with_primary_cell_dimensions(face_metrics, primary) + } else { + Metrics::calc(face_metrics) + }; + self.metrics_cache.insert(size_key, metrics); + return Some(metrics); + } + // Calculate metrics if not cached if let Some(ref data) = self.data { let font_ref = crate::font_introspector::FontRef { @@ -704,15 +1128,146 @@ impl FontData { path: Some(path), is_emoji, metrics_cache: FxHashMap::default(), + // `from_data` is the non-macOS code path — macOS goes through + // `from_path_macos` or `from_static_slice`, both of which + // populate `handle` themselves. Leave it unset here; if + // anything on mac does route through here, the `ct_font()` + // fallback rebuilds from bytes/path on demand. + #[cfg(target_os = "macos")] + handle: None, + }) + } + + /// macOS-only: construct a `FontData` straight from a file path, with + /// attributes read through CoreText. Never loads the font bytes. + /// + /// CoreText reads the file itself, so Rio's `FONT_DATA_CACHE` never + /// ends up holding hundreds of MB of Apple Color Emoji / CJK font + /// bytes. + /// macOS-only: wrap a CTFont discovered at runtime (e.g. via + /// `CTFontCreateForString` lazy cascade) into a `FontData` with no + /// backing path or bytes. Metrics, rasterization and PS-name + /// lookups all go through the handle directly — there's nothing + /// for `get_data` / `get_metrics` to fall back to besides the + /// stored CTFont. + /// + /// Weight/italic/stretch are left at defaults because lazy-cascade + /// fonts are picked by CoreText based on script coverage rather + /// than style matching; the primary font's style already dictated + /// what was searched. Callers should not treat these fields as + /// authoritative. + #[cfg(target_os = "macos")] + pub fn from_ctfont_macos(handle: crate::font::macos::FontHandle) -> Self { + let attrs = crate::font::macos::font_attributes(&handle); + let style = if attrs.is_italic { + crate::font_introspector::Style::Italic + } else { + crate::font_introspector::Style::Normal + }; + let weight = crate::font_introspector::Weight(attrs.weight); + Self { + data: None, + path: None, + offset: 0, + key: CacheKey::new(), + weight, + style, + stretch: crate::font_introspector::Stretch::NORMAL, + synth: Synthesis::default(), + should_embolden: false, + should_italicize: false, + is_emoji: attrs.is_color, + metrics_cache: FxHashMap::default(), + handle: Some(handle), + } + } + + #[cfg(target_os = "macos")] + pub fn from_path_macos( + path: PathBuf, + font_spec: &SugarloafFont, + ) -> Result> { + let handle = crate::font::macos::FontHandle::from_path(&path) + .ok_or_else(|| format!("CoreText refused {}", path.display()))?; + let attrs = crate::font::macos::font_attributes(&handle); + + let style = if attrs.is_italic { + crate::font_introspector::Style::Italic + } else { + crate::font_introspector::Style::Normal + }; + let weight = crate::font_introspector::Weight(attrs.weight); + + let should_italicize = + font_spec.style == SugarloafFontStyle::Italic && !attrs.is_italic; + let should_embolden = font_spec.weight >= Some(700) && attrs.weight < 700; + + Ok(Self { + data: None, + path: Some(path), + offset: 0, + key: CacheKey::new(), + weight, + style, + stretch: crate::font_introspector::Stretch::NORMAL, + synth: Synthesis::default(), + should_embolden, + should_italicize, + is_emoji: attrs.is_color, + metrics_cache: FxHashMap::default(), + handle: Some(handle), + }) + } + + /// Load a bundled font whose bytes live in `.rodata` (anything from + /// `include_bytes!` / `font!`). + /// + /// The bytes stay where they already are — no `.to_vec()` copy onto + /// the heap, no second copy into a CoreFoundation buffer. On macOS we + /// also eagerly construct the CTFont via `CFDataCreateWithBytesNoCopy` + /// + `kCFAllocatorNull` and cache it on `FontData.handle`. + #[inline] + pub fn from_static_slice( + data: &'static [u8], + ) -> Result> { + let font = FontRef::from_index(data, 0).unwrap(); + let (offset, key) = (font.offset, font.key); + let attributes = font.attributes(); + let style = attributes.style(); + let weight = attributes.weight(); + let stretch = attributes.stretch(); + let synth = attributes.synthesize(attributes); + let is_emoji = has_color_tables(&font); + + #[cfg(target_os = "macos")] + let handle = crate::font::macos::FontHandle::from_static_bytes(data); + + Ok(Self { + data: Some(SharedData::from_static(data)), + offset, + key, + synth, + style, + should_embolden: false, + should_italicize: false, + weight, + stretch, + path: None, + is_emoji, + metrics_cache: FxHashMap::default(), + #[cfg(target_os = "macos")] + handle, }) } + /// Legacy constructor kept for tests and any caller that only has a + /// non-static slice — copies the bytes into an owned `Vec`. + /// Production code should use [`from_static_slice`] for bundled fonts + /// and [`from_data`] for path-loaded ones. #[inline] pub fn from_slice(data: &[u8]) -> Result> { let font = FontRef::from_index(data, 0).unwrap(); let (offset, key) = (font.offset, font.key); - // Return our struct with the original file data and copies of the - // offset and key from the font reference let attributes = font.attributes(); let style = attributes.style(); let weight = attributes.weight(); @@ -733,12 +1288,14 @@ impl FontData { path: None, is_emoji, metrics_cache: FxHashMap::default(), + #[cfg(target_os = "macos")] + handle: None, }) } } /// Auto-detect emoji-ness from SFNT color tables (COLR, CBDT, CBLC, SBIX). -/// Matches ghostty's `FT_HAS_COLOR()` / CoreText SBIX checks. Used to guard +/// Used to guard /// against Nerd Font families being mis-flagged as emoji when loaded via the /// `extras` config slot (`is_emoji` is wired per-load-site, but a real emoji /// font in that slot would still need the wide-cell/color-atlas treatment). @@ -760,7 +1317,59 @@ enum FindResult { NotFound(SugarloafFont), } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(target_os = "macos")] +#[inline] +fn find_font(font_spec: SugarloafFont, evictable: bool) -> FindResult { + if font_spec.is_default_family() { + return FindResult::NotFound(font_spec); + } + + let family = font_spec.family.to_string(); + let weight = font_spec.weight.unwrap_or(400); + let italic = font_spec.style == SugarloafFontStyle::Italic; + let stretch = map_stretch_macos(&font_spec.width); + + info!("Font search (CoreText): family='{family}' weight={weight} italic={italic}"); + + let Some(path) = crate::font::macos::find_font_path(&family, weight, italic, stretch) + else { + warn!("CoreText found no match for family='{family}'"); + return FindResult::NotFound(font_spec); + }; + + // Path-based load: never reads bytes. `evictable` is ignored on the + // macOS path since `FontData.data` is always `None` here — there's + // nothing to evict. + let _ = evictable; + match FontData::from_path_macos(path.clone(), &font_spec) { + Ok(d) => { + info!("Font '{family}' matched via CoreText at {}", path.display()); + FindResult::Found(d) + } + Err(e) => { + warn!("Failed to open font '{family}' via CoreText: {e}"); + FindResult::NotFound(font_spec) + } + } +} + +#[cfg(target_os = "macos")] +fn map_stretch_macos(width: &Option) -> crate::font::macos::Stretch { + use crate::font::macos::Stretch; + match width { + Some(SugarloafFontWidth::UltraCondensed) => Stretch::UltraCondensed, + Some(SugarloafFontWidth::ExtraCondensed) => Stretch::ExtraCondensed, + Some(SugarloafFontWidth::Condensed) => Stretch::Condensed, + Some(SugarloafFontWidth::SemiCondensed) => Stretch::SemiCondensed, + Some(SugarloafFontWidth::Normal) | None => Stretch::Normal, + Some(SugarloafFontWidth::SemiExpanded) => Stretch::SemiExpanded, + Some(SugarloafFontWidth::Expanded) => Stretch::Expanded, + Some(SugarloafFontWidth::ExtraExpanded) => Stretch::ExtraExpanded, + Some(SugarloafFontWidth::UltraExpanded) => Stretch::UltraExpanded, + } +} + +#[cfg(all(not(target_os = "macos"), not(target_arch = "wasm32")))] #[inline] fn find_font( db: &crate::font::loader::Database, @@ -907,16 +1516,16 @@ fn load_fallback_from_memory(font_spec: &SugarloafFont) -> FontData { (100, _) => constants::FONT_CASCADIAMONO_EXTRA_LIGHT, (200, _) => constants::FONT_CASCADIAMONO_LIGHT, (300, _) => constants::FONT_CASCADIAMONO_SEMI_LIGHT, - (400, _) => constants::FONT_CASCADIAMONO_REGULAR, - (500, _) => constants::FONT_CASCADIAMONO_REGULAR, + (400, _) => constants::FONT_CASCADIAMONO_NF_REGULAR, + (500, _) => constants::FONT_CASCADIAMONO_NF_REGULAR, (600, _) => constants::FONT_CASCADIAMONO_SEMI_BOLD, (700, _) => constants::FONT_CASCADIAMONO_SEMI_BOLD, (800, _) => constants::FONT_CASCADIAMONO_BOLD, (900, _) => constants::FONT_CASCADIAMONO_BOLD, - (_, _) => constants::FONT_CASCADIAMONO_REGULAR, + (_, _) => constants::FONT_CASCADIAMONO_NF_REGULAR, }; - FontData::from_slice(font_to_load).unwrap() + FontData::from_static_slice(font_to_load).unwrap() } #[allow(dead_code)] @@ -944,8 +1553,6 @@ fn find_font_path( #[cfg(not(target_arch = "wasm32"))] fn load_from_font_source(path: &PathBuf) -> Option { - use std::io::Read; - let cache = get_font_data_cache(); // Check if already cached - DashMap handles concurrent access efficiently @@ -953,18 +1560,160 @@ fn load_from_font_source(path: &PathBuf) -> Option { return Some(cached_data.clone()); } - // Load from disk if not cached - if let Ok(mut file) = std::fs::File::open(path) { - let mut font_data = vec![]; - if file.read_to_end(&mut font_data).is_ok() { - let shared_data = SharedData::new(font_data); - // Use entry API to handle concurrent inserts properly - let entry = cache - .entry(path.clone()) - .or_insert_with(|| shared_data.clone()); - return Some(entry.clone()); - } + // Memory-map the file rather than reading it into a `Vec`. The + // kernel backs the bytes with the font file and only pages in what + // font_introspector's charmap / metrics queries actually touch, so a + // large fallback (e.g. a CJK font, an emoji file) costs negligible + // resident RAM instead of its full on-disk size. Mmap is unsafe + // because the file can change underneath us or the mapping can fault; + // for read-only font files this is the universally-accepted trade-off + // (same as font-kit and FreeType). + let file = std::fs::File::open(path).ok()?; + let mmap = unsafe { memmap2::Mmap::map(&file).ok()? }; + let shared_data = SharedData::from_mmap(mmap); + let entry = cache + .entry(path.clone()) + .or_insert_with(|| shared_data.clone()); + Some(entry.clone()) +} + +#[cfg(all(test, target_os = "macos"))] +mod postscript_resolver_tests { + use super::*; + + /// End-to-end: insert a bundled font into a bare `FontLibraryData` + /// and verify the PostScript-name resolver returns the font_id we + /// just assigned. This is the bridge the macOS shaper's cascade-run + /// resolver walks over — if `insert` stops populating the map (e.g. + /// `handle()` returns `None` on a refactor), the shape path + /// silently falls back to primary instead of returning the right + /// font for a substituted run. + #[test] + fn insert_populates_postscript_lookup() { + // Read the PS name straight from the handle so the test doesn't + // hardcode a value that changes if the bundled font is updated. + let handle = crate::font::macos::FontHandle::from_static_bytes( + FONT_CASCADIAMONO_NF_REGULAR, + ) + .expect("parse CascadiaMono"); + let ps_name = handle.postscript_name(); + + let mut lib = FontLibraryData::default(); + let font_data = FontData::from_static_slice(FONT_CASCADIAMONO_NF_REGULAR) + .expect("load CascadiaMono"); + lib.insert(font_data); + + assert_eq!( + lib.font_id_for_postscript_name(&ps_name), + Some(0), + "inserted PS name '{ps_name}' should resolve to font_id 0" + ); + assert_eq!( + lib.font_id_for_postscript_name("not-a-real-font"), + None, + "unknown PS names must return None, not a stale hit" + ); } - None + /// `insert` keys on the handle's current PS name, so a second + /// insert of the same face must not overwrite the first's id — + /// otherwise later lookups would return a stale id pointing at a + /// now-shifted slot. The rest of the library keys on the first + /// id, so first-wins is the correct policy. + #[test] + fn duplicate_insert_keeps_first_id() { + let handle = crate::font::macos::FontHandle::from_static_bytes( + FONT_CASCADIAMONO_NF_REGULAR, + ) + .expect("parse CascadiaMono"); + let ps_name = handle.postscript_name(); + + let mut lib = FontLibraryData::default(); + lib.insert( + FontData::from_static_slice(FONT_CASCADIAMONO_NF_REGULAR).expect("load a"), + ); + lib.insert( + FontData::from_static_slice(FONT_CASCADIAMONO_NF_REGULAR).expect("load b"), + ); + assert_eq!( + lib.font_id_for_postscript_name(&ps_name), + Some(0), + "second insert of same face must not clobber the first's font_id" + ); + } + + /// Build a tiny `FontLibrary` that contains only CascadiaMono as + /// font_id=0 — i.e. no cascade fallbacks registered. Then ask it to + /// resolve a CJK codepoint CascadiaMono can't render. The lazy- + /// discovery path should call `CTFontCreateForString`, register the + /// discovered font under a new id, and return that id. + #[test] + fn resolve_font_for_char_lazy_discovers_cascade_font() { + use crate::SpanStyle; + use std::sync::Arc; + + let mut data = FontLibraryData::default(); + data.insert( + FontData::from_static_slice(FONT_CASCADIAMONO_NF_REGULAR).expect("load"), + ); + let lib = FontLibrary { + inner: Arc::new(parking_lot::RwLock::new(data)), + }; + let starting_len = lib.inner.read().inner.len(); + + let style = SpanStyle::default(); + // U+6C34 ('水') — not in CascadiaMono. Library has no fallback + // registered, so the pre-resolve walk returns None and the + // discovery path has to fire. + let (font_id, _is_emoji) = lib.resolve_font_for_char('\u{6C34}', &style); + + assert_ne!( + font_id, 0, + "lazy discovery should register a new font_id distinct from primary" + ); + assert!( + font_id < lib.inner.read().inner.len(), + "returned font_id should index into the library" + ); + assert_eq!( + lib.inner.read().inner.len(), + starting_len + 1, + "lazy discovery should have registered exactly one new font" + ); + } + + /// Two queries for codepoints that cascade to the same system font + /// (both CJK ideographs) must reuse the same `font_id` — the + /// postscript-name check under the write lock prevents double + /// registration so each face is stored at most once. + #[test] + fn resolve_font_for_char_reuses_discovered_font() { + use crate::SpanStyle; + use std::sync::Arc; + + let mut data = FontLibraryData::default(); + data.insert( + FontData::from_static_slice(FONT_CASCADIAMONO_NF_REGULAR).expect("load"), + ); + let lib = FontLibrary { + inner: Arc::new(parking_lot::RwLock::new(data)), + }; + let style = SpanStyle::default(); + + // Both codepoints should cascade to the same system CJK font on + // any stock macOS install. + let (id_a, _) = lib.resolve_font_for_char('\u{6C34}', &style); + let len_after_first = lib.inner.read().inner.len(); + let (id_b, _) = lib.resolve_font_for_char('\u{6728}', &style); + let len_after_second = lib.inner.read().inner.len(); + + assert_eq!( + id_a, id_b, + "two CJK codepoints from the same cascade font should reuse the same font_id" + ); + assert_eq!( + len_after_first, len_after_second, + "the second resolve must not register a duplicate font" + ); + } } diff --git a/sugarloaf/src/font/resources/CascadiaCode/CascadiaCode-Regular.otf b/sugarloaf/src/font/resources/CascadiaCode/CascadiaCode-Regular.otf deleted file mode 100644 index fd4b7f712b..0000000000 Binary files a/sugarloaf/src/font/resources/CascadiaCode/CascadiaCode-Regular.otf and /dev/null differ diff --git a/sugarloaf/src/font/resources/CascadiaCode/CascadiaCodeNF-Regular.otf b/sugarloaf/src/font/resources/CascadiaCode/CascadiaCodeNF-Regular.otf new file mode 100644 index 0000000000..bdf8c658f1 Binary files /dev/null and b/sugarloaf/src/font/resources/CascadiaCode/CascadiaCodeNF-Regular.otf differ diff --git a/sugarloaf/src/font_cache.rs b/sugarloaf/src/font_cache.rs index 9a856ce668..40420b5682 100644 --- a/sugarloaf/src/font_cache.rs +++ b/sugarloaf/src/font_cache.rs @@ -94,12 +94,18 @@ impl FontCache { } /// Resolve a single glyph: read from `cache` if present, otherwise -/// walk the fallback chain via `font_ctx` and store the result. -/// `font_ctx` is borrowed by the caller so multiple resolutions can -/// share one read-lock acquisition. +/// walk the fallback chain via `font_lib` and store the result. +/// +/// On macOS, `font_lib.resolve_font_for_char` includes lazy discovery +/// via `CTFontCreateForString` — an unknown codepoint gets a new +/// cascade font registered in the library on first encounter and the +/// new `font_id` is returned. Subsequent queries for any codepoint the +/// discovered font covers then hit the registered-font walk directly. +/// +/// Off macOS, the resolver is the plain walk (no discovery). pub(crate) fn resolve_with( cache: &mut FontCache, - font_ctx: &crate::font::FontLibraryData, + font_lib: &crate::font::FontLibrary, ch: char, attrs: Attributes, ) -> ResolvedGlyph { @@ -112,12 +118,20 @@ pub(crate) fn resolve_with( ..Default::default() }; let mut width = ch.width().unwrap_or(1) as f32; - let mut font_id = 0; - if let Some((fid, is_emoji)) = font_ctx.find_best_font_match(ch, &style) { - font_id = fid; - if is_emoji { - width = 2.0; - } + + #[cfg(target_os = "macos")] + let (font_id, is_emoji) = font_lib.resolve_font_for_char(ch, &style); + + #[cfg(not(target_os = "macos"))] + let (font_id, is_emoji) = { + let font_ctx = font_lib.inner.read(); + font_ctx + .find_best_font_match(ch, &style) + .unwrap_or((0, false)) + }; + + if is_emoji { + width = 2.0; } let resolved = ResolvedGlyph { @@ -134,6 +148,7 @@ pub(crate) fn resolve_with( /// under `font_id`. Returns `None` when the font data isn't available /// (font id unregistered or the SFNT bytes failed to parse); the /// caller is responsible for picking a rendering fallback. +#[cfg(not(target_os = "macos"))] pub(crate) fn compute_advance( font_ctx: &crate::font::FontLibraryData, font_id: usize, @@ -148,3 +163,28 @@ pub(crate) fn compute_advance( units_per_em: font_ref.metrics(&[]).units_per_em, }) } + +/// macOS variant: derive the advance from CoreText without ever touching +/// the font's raw bytes. Matches Ghostty's bytes-free font handling on +/// mac. +#[cfg(target_os = "macos")] +pub(crate) fn compute_advance( + font_ctx: &crate::font::FontLibraryData, + font_id: usize, + ch: char, +) -> Option { + let font = font_ctx.inner.get(&font_id)?; + let handle = if let Some(path) = font.path() { + crate::font::macos::FontHandle::from_path(path) + } else if let Some(bytes) = font.data() { + crate::font::macos::FontHandle::from_bytes(bytes.as_ref()) + } else { + None + }?; + let (advance_units, units_per_em) = + crate::font::macos::advance_units_for_char(&handle, ch)?; + Some(AdvanceInfo { + advance_units, + units_per_em, + }) +} diff --git a/sugarloaf/src/layout/content.rs b/sugarloaf/src/layout/content.rs index 8ea1dcf457..b836910eb3 100644 --- a/sugarloaf/src/layout/content.rs +++ b/sugarloaf/src/layout/content.rs @@ -8,6 +8,7 @@ use crate::font::FontLibrary; use crate::font_introspector::shape::ShapeContext; use crate::font_introspector::text::Script; +#[cfg(not(target_os = "macos"))] use crate::font_introspector::FontRef; use crate::layout::content_data::{ContentData, ContentState}; use crate::layout::render_data::RenderData; @@ -560,9 +561,31 @@ impl Content { &self, layout: &TextLayout, ) -> crate::layout::TextDimensions { + let font_size = layout.font_size; + + // macOS: read metrics + space-glyph advance straight from the + // primary CTFont. This is the last byte-dependent path that + // mattered on mac — using CTFont here means `FONT_DATA_CACHE` + // never holds the primary font either. + #[cfg(target_os = "macos")] + if let Some(handle) = self.fonts.ct_font(0) { + let metrics = crate::font::macos::font_metrics(&handle, font_size); + let char_width = crate::font::macos::advance_units_for_char(&handle, ' ') + .map(|(units, upem)| units * font_size / upem as f32) + .unwrap_or(font_size); + let line_height = + (metrics.ascent + metrics.descent + metrics.leading) * layout.line_height; + let scale = layout.dimensions.scale; + return crate::layout::TextDimensions { + width: char_width * scale, + height: (line_height * scale).ceil(), + scale, + }; + } + + #[cfg(not(target_os = "macos"))] if let Some(font_library_data) = self.fonts.inner.try_read() { let font_id = 0; // FONT_ID_REGULAR - let font_size = layout.font_size; // Get font data to create swash FontRef if let Some((font_data, offset, _key)) = font_library_data.get_data(&font_id) @@ -970,6 +993,7 @@ impl Content { } #[allow(clippy::too_many_arguments)] + #[cfg_attr(target_os = "macos", allow(unused_variables))] fn process_text_line( text_state: &mut BuilderState, line_number: usize, @@ -1047,36 +1071,61 @@ impl Content { } } - // Cache miss: shape the full run and store result + // Cache miss: shape the full run and store result. shaping_cache.set_content(font_id, content); - // Only allocate vars on the miss path - let vars: Vec<_> = text_state.vars.get(font_vars).to_vec(); + #[cfg(target_os = "macos")] + { + if let Some(handle) = fonts.ct_font(font_id) { + let shaped = crate::font::macos::shape_text( + &handle, + content, + scaled_font_size, + ); + let macos_metrics = + crate::font::macos::font_metrics(&handle, scaled_font_size); + line.render_data.push_run_macos( + style, + scaled_font_size, + line_number as u32, + &shaped, + &macos_metrics, + shaping_cache, + ); + } + } - let font_library = &fonts.inner.read(); - if let Some((shared_data, offset, key)) = font_library.get_data(&font_id) { - let font_ref = FontRef { - data: shared_data.as_ref(), - offset, - key, - }; - let mut shaper = scx - .builder(font_ref) - .script(script) - .size(scaled_font_size) - .features(features.iter().copied()) - .variations(vars.iter().copied()) - .build(); + #[cfg(not(target_os = "macos"))] + { + // Only allocate vars on the miss path + let vars: Vec<_> = text_state.vars.get(font_vars).to_vec(); + + let font_library = &fonts.inner.read(); + if let Some((shared_data, offset, key)) = font_library.get_data(&font_id) + { + let font_ref = FontRef { + data: shared_data.as_ref(), + offset, + key, + }; + let mut shaper = scx + .builder(font_ref) + .script(script) + .size(scaled_font_size) + .features(features.iter().copied()) + .variations(vars.iter().copied()) + .build(); - shaper.add_str(content); + shaper.add_str(content); - line.render_data.push_run( - style, - scaled_font_size, - line_number as u32, - shaper, - shaping_cache, - ); + line.render_data.push_run( + style, + scaled_font_size, + line_number as u32, + shaper, + shaping_cache, + ); + } } } } diff --git a/sugarloaf/src/layout/render_data.rs b/sugarloaf/src/layout/render_data.rs index 1d8bc9fb3d..084e528aad 100644 --- a/sugarloaf/src/layout/render_data.rs +++ b/sugarloaf/src/layout/render_data.rs @@ -13,6 +13,7 @@ use super::glyph::*; #[cfg(test)] use crate::font_introspector::shape::cluster::OwnedGlyphCluster; +#[cfg(not(target_os = "macos"))] use crate::font_introspector::shape::Shaper; use crate::font_introspector::Metrics; use crate::layout::content::{CachedRun, ShapingCache, SpanStyleDecoration}; @@ -86,6 +87,7 @@ impl RenderData { } impl RenderData { + #[cfg(not(target_os = "macos"))] #[allow(clippy::too_many_arguments)] pub(super) fn push_run( &mut self, @@ -159,6 +161,90 @@ impl RenderData { self.runs.push(run_data); } + /// macOS equivalent of `push_run`: consumes a pre-shaped slice from + /// CoreText instead of running the swash `Shaper` callback. + /// + /// Packing / cache-fill / `RunData` layout are byte-identical to the + /// swash path, so the cache-hit path (`push_cached_run`) and downstream + /// composition don't care which shaper produced the glyphs. + /// + /// `metrics` comes from [`crate::font::macos::font_metrics`] — CoreText + /// native ascent/descent/leading/underline, plus strikeout derived from + /// x-height (CT has no strikeout API). + #[cfg(target_os = "macos")] + #[allow(clippy::too_many_arguments)] + pub(super) fn push_run_macos( + &mut self, + style: SpanStyle, + size: f32, + line: u32, + shaped: &[crate::font::macos::ShapedGlyph], + metrics: &crate::font::macos::FontMetrics, + shaping_cache: &mut ShapingCache, + ) { + let mut glyphs = Vec::with_capacity(shaped.len()); + let mut detailed_glyphs = Vec::new(); + let mut advance = 0.0f32; + + for g in shaped { + advance += g.advance; + const MAX_SIMPLE_ADVANCE: u32 = 0x7FFF; + if g.x == 0.0 && g.y == 0.0 { + let packed_advance = (g.advance * 64.0) as u32; + if packed_advance <= MAX_SIMPLE_ADVANCE { + glyphs.push(GlyphData { + data: g.id as u32 | (packed_advance << 16), + size: g.cluster, + }); + continue; + } + } + let detail_index = detailed_glyphs.len() as u32; + detailed_glyphs.push(Glyph { + id: g.id, + x: g.x, + y: g.y, + advance: g.advance, + span: g.cluster as usize, + }); + glyphs.push(GlyphData { + data: GLYPH_DETAILED | detail_index, + size: g.cluster, + }); + } + + if let Some(graphic) = style.media { + self.graphics.insert(graphic.id); + } + + let cache_key = compute_cache_key(&glyphs, style.font_id, size); + + shaping_cache.finish_with_run(CachedRun { + glyphs: glyphs.clone(), + detailed_glyphs: detailed_glyphs.clone(), + advance, + cache_key, + }); + + let run_data = RunData { + span: style, + line, + size, + detailed_glyphs, + glyphs, + ascent: metrics.ascent, + descent: metrics.descent, + leading: metrics.leading, + underline_offset: metrics.underline_offset, + strikeout_offset: metrics.strikeout_offset, + strikeout_size: metrics.strikeout_thickness, + x_height: metrics.x_height, + advance, + cache_key, + }; + self.runs.push(run_data); + } + /// Push a pre-packed cached run — no repacking, no hashing. #[allow(clippy::too_many_arguments)] pub(super) fn push_cached_run( diff --git a/sugarloaf/src/renderer/compositor.rs b/sugarloaf/src/renderer/compositor.rs index 3cfd88bf39..a1a509bd7f 100644 --- a/sugarloaf/src/renderer/compositor.rs +++ b/sugarloaf/src/renderer/compositor.rs @@ -240,20 +240,13 @@ impl Compositor { } else { // Handle regular glyphs for glyph in glyphs { - // For PUA glyphs with a multi-cell constraint, rasterize at - // `cells × font_size` so the compositor only ever downscales - // in the fit pass below — upscaling an atlas bitmap would be - // blurry. For Cascadia-style fonts that already render ~2 - // cells wide this is a no-op downscale; for JetBrainsMono NF - // Mono (~1 cell wide) this is what makes the 2-cell slot - // actually look 2-cell-sized instead of small-and-centered. - let entry = match style.scale_constraint { - Some((_, cells)) if cells > 1 => { - let larger = ((style.font_size * cells as f32) as u16).max(1); - session.get_at_size(glyph.id, larger) - } - _ => session.get(glyph.id), - }; + // Rasterize Nerd Font / PUA glyphs once at the nominal font + // size. An earlier "rasterize at cells × font_size" trick + // produced a 2× raster that the constraint math below then + // tried to re-scale *from*, compounding into a ~2× oversized + // glyph. With nominal rasterization, the constraint's + // width/height factors land where they should. + let entry = session.get(glyph.id); if let Some(entry) = entry { if let Some(img) = session.get_image(entry.image) { let gx = (glyph.x + subpx_bias.0).floor() + entry.left as f32; @@ -284,28 +277,57 @@ impl Compositor { baseline: style.baseline, }) } else { - let target_w = cell_w * cells as f32; - let target_h = style.line_height; - let orig_w = entry.width as f32; - let orig_h = entry.height as f32; - - let scale = (target_w / orig_w).min(target_h / orig_h); + // No per-codepoint attribute: no scaling, no + // slot-centering. Glyph renders at its + // natural pen position and natural raster + // size. + let _ = (cell_w, cells); + Rect::new(gx, gy, entry.width as f32, entry.height as f32) + } + } else if entry.is_bitmap { + // Color bitmap (emoji) glyphs fall here when the + // shaper didn't attach an explicit constraint. + // + // `.cover` sizing with center alignment and + // 2.5 % horizontal padding. Cover scales the + // bitmap so it fills the advance × cell-height + // slot on at least one axis, rather than fit + // which leaves gaps. + // + // Vertical centering uses the font's *natural* + // cell (ascent + descent) rather than + // `line_height` — the latter picks up user + // line-height modifiers that shouldn't shift the + // emoji inside its cell. + const PAD_EACH: f32 = 0.025; + let orig_w = entry.width as f32; + let orig_h = entry.height as f32; + if orig_w > 0.0 && orig_h > 0.0 { + let cell_top = style.baseline - style.ascent; + let cell_h = style.ascent + style.descent; + let available_w = glyph.advance * (1.0 - 2.0 * PAD_EACH); + // Cover: pick the larger scale factor so the + // emoji fills the slot on at least one axis. + let scale = (available_w / orig_w).max(cell_h / orig_h); let sw = orig_w * scale; let sh = orig_h * scale; - - // Center horizontally within the constraint - // slot that starts at `glyph.x`. - let cx = glyph.x + (target_w - sw) / 2.0; - // Center vertically within the line. PUA / - // Nerd Font symbols aren't baseline-anchored - // the way text glyphs are — scaling `entry.top` - // shifts the image up because the top-bearing - // scales faster than the descent-bearing. Same - // choice ghostty makes for `isSymbol(cp)` via - // `.align_vertical = .center1`. - let cy = style.topline + (style.line_height - sh) / 2.0; - - Rect::new(cx, cy, sw, sh) + let cx = (glyph.x + subpx_bias.0).floor() + + (glyph.advance - sw) / 2.0; + let cy = cell_top + (cell_h - sh) / 2.0; + // Snap both edges to the pixel grid. Bitmap + // emoji (sbix — Apple Color Emoji) sampled + // at fractional offsets looks blurry; + // rounding cx/cy/sw/sh to whole pixels lets + // the sampler hit source texels cleanly. + // No-op for COLR glyphs whose scale already + // snapped. + let x0 = cx.round(); + let x1 = (cx + sw).round(); + let y0 = cy.round(); + let y1 = (cy + sh).round(); + Rect::new(x0, y0, x1 - x0, y1 - y0) + } else { + Rect::new(gx, gy, orig_w, orig_h) } } else { Rect::new(gx, gy, entry.width as f32, entry.height as f32) diff --git a/sugarloaf/src/renderer/image_cache/glyph.rs b/sugarloaf/src/renderer/image_cache/glyph.rs index bdcb0ed871..44ad851e18 100644 --- a/sugarloaf/src/renderer/image_cache/glyph.rs +++ b/sugarloaf/src/renderer/image_cache/glyph.rs @@ -1,22 +1,22 @@ use super::cache::ImageCache; use super::{AddImage, ImageData, ImageId, ImageLocation}; use crate::font::FontLibrary; -use crate::font_introspector::zeno::Format; -use crate::font_introspector::{ - scale::{ - image::{Content, Image as GlyphImage}, - *, - }, - FontRef, +use crate::font_introspector::scale::{ + image::{Content, Image as GlyphImage}, + *, }; +#[cfg(not(target_os = "macos"))] +use crate::font_introspector::zeno::Format; +#[cfg(not(target_os = "macos"))] +use crate::font_introspector::FontRef; use core::borrow::Borrow; use core::hash::{Hash, Hasher}; use rustc_hash::FxHashMap; use tracing::debug; +#[cfg(not(target_os = "macos"))] use zeno::{Angle, Transform}; -// const IS_MACOS: bool = cfg!(target_os = "macos"); - +#[cfg(not(target_os = "macos"))] const SOURCES: &[Source] = &[ Source::ColorOutline(0), Source::ColorBitmap(StrikeWith::BestFit), @@ -24,6 +24,51 @@ const SOURCES: &[Source] = &[ Source::Outline, ]; +/// macOS rasterization path: populates `scaled` with CoreText/CoreGraphics output +/// shaped to match what zeno fills in on other platforms. +/// +/// `is_emoji` drives the bitmap format (RGBA for color, R8 alpha for mono) — +/// it's taken from `FontData::is_emoji` so the caller doesn't have to probe the +/// font per glyph. +#[cfg(target_os = "macos")] +#[allow(clippy::too_many_arguments)] +fn rasterize_macos( + scaled: &mut GlyphImage, + handle: &crate::font::macos::FontHandle, + glyph_id: u16, + size: u16, + is_emoji: bool, + synthetic_italic: bool, + synthetic_bold: bool, +) -> bool { + match crate::font::macos::rasterize_glyph( + handle, + glyph_id, + size as f32, + is_emoji, + synthetic_italic, + synthetic_bold, + ) { + Some(g) => { + scaled.placement = zeno::Placement { + left: g.left, + top: g.top, + width: g.width, + height: g.height, + }; + scaled.content = if g.is_color { + Content::Color + } else { + Content::Mask + }; + scaled.data.clear(); + scaled.data.extend_from_slice(&g.bytes); + true + } + None => false, + } +} + pub struct GlyphCache { scx: ScaleContext, fonts: FxHashMap, @@ -100,6 +145,7 @@ pub struct GlyphCacheSession<'a> { scaled_image: &'a mut GlyphImage, font: usize, font_library: &'a FontLibrary, + #[cfg_attr(target_os = "macos", allow(dead_code))] scale_context: &'a mut ScaleContext, quant_size: u16, #[allow(unused)] @@ -144,15 +190,50 @@ impl GlyphCacheSession<'_> { ); self.scaled_image.data.clear(); - let font_library_data = self.font_library.inner.read(); - let enable_hint = font_library_data.hinting; - let font_data = font_library_data.get(&self.font); - let should_embolden = font_data.should_embolden; - let should_italicize = font_data.should_italicize; - - if let Some((shared_data, offset, cache_key)) = - font_library_data.get_data(&self.font) + + // Pull per-font metadata under a brief read lock, then release it so + // the macOS `ct_font` call (which may take its own read lock on cache + // miss) doesn't nest acquisitions. + let should_embolden; + let should_italicize; + #[cfg(target_os = "macos")] + let is_emoji; + #[cfg(not(target_os = "macos"))] + let enable_hint; + #[cfg(not(target_os = "macos"))] + let font_bytes_opt; { + let font_library_data = self.font_library.inner.read(); + let font_data = font_library_data.get(&self.font); + should_embolden = font_data.should_embolden; + should_italicize = font_data.should_italicize; + #[cfg(target_os = "macos")] + { + is_emoji = font_data.is_emoji; + } + #[cfg(not(target_os = "macos"))] + { + enable_hint = font_library_data.hinting; + font_bytes_opt = font_library_data.get_data(&self.font); + } + } + + #[cfg(target_os = "macos")] + let did_render = match self.font_library.ct_font(self.font) { + Some(handle) => rasterize_macos( + self.scaled_image, + &handle, + id, + size, + is_emoji, + should_italicize, + should_embolden, + ), + None => false, + }; + + #[cfg(not(target_os = "macos"))] + let did_render = if let Some((shared_data, offset, cache_key)) = font_bytes_opt { let font_ref = FontRef { data: shared_data.as_ref(), offset, @@ -166,14 +247,12 @@ impl GlyphCacheSession<'_> { // un-noticeable to the human eye. // As a result Apple's Quartz text renderer, which is targeted for Retina displays, // now ignores font hint information completely. - // .hint(!IS_MACOS) .hint(enable_hint) .size(size.into()) // .normalized_coords(coords) .build(); - // let embolden = if IS_MACOS { 0.25 } else { 0. }; - if Render::new(SOURCES) + Render::new(SOURCES) .format(Format::Alpha) // .offset(Vector::new(subpx[0].to_f32(), subpx[1].to_f32())) .embolden(if should_embolden { 0.5 } else { 0.0 }) @@ -186,92 +265,95 @@ impl GlyphCacheSession<'_> { None }) .render_into(&mut scaler, id, self.scaled_image) - { - let p = self.scaled_image.placement; - let w = p.width as u16; - let h = p.height as u16; - - // Handle zero-sized glyphs (spaces, zero-width characters) efficiently - if w == 0 || h == 0 { - let entry = GlyphEntry { - left: p.left, - top: p.top, - width: w, - height: h, - image: ImageId::empty(), // Use a special empty image ID - is_bitmap: false, - }; - self.entry.glyphs.insert(key, entry); - return Some(entry); - } + } else { + false + }; - // Use the appropriate content type and data format - let (image_data, content_type) = match self.scaled_image.content { - Content::Mask => { - // Alpha format: use data directly for R8 texture - ( - ImageData::Borrowed(&self.scaled_image.data), - super::ContentType::Mask, - ) - } - Content::Color => { - // Already RGBA format - ( - ImageData::Borrowed(&self.scaled_image.data), - super::ContentType::Color, - ) - } - Content::SubpixelMask => { - // Subpixel format (should not happen with Format::Alpha) - ( - ImageData::Borrowed(&self.scaled_image.data), - super::ContentType::Color, - ) - } - }; - - let req = AddImage { - width: w, - height: h, - has_alpha: true, - data: image_data, - content_type, - }; - let image = self.images.allocate(req)?; - - // let mut top = p.top; - // let mut height = h; - - // If dimension is None it means that we are running - // for the first time and in this case, we will obtain - // what the next glyph entries should respect in terms of - // top and height values - // - // e.g: Placement { left: 11, top: 42, width: 8, height: 50 } - // - // The calculation is made based on max_height - // If the rect max height is 50 and the glyph height is 68 - // and 48 top, then (68 - 50 = 18) height as difference and - // apply it to the top (bigger the top == up ^). - // if self.max_height > &0 && &h > self.max_height { - // let difference = h - self.max_height; - - // top -= difference as i32; - // height = *self.max_height; - // } + if did_render { + let p = self.scaled_image.placement; + let w = p.width as u16; + let h = p.height as u16; + // Handle zero-sized glyphs (spaces, zero-width characters) efficiently + if w == 0 || h == 0 { let entry = GlyphEntry { left: p.left, top: p.top, width: w, height: h, - image, - is_bitmap: self.scaled_image.content == Content::Color, + image: ImageId::empty(), // Use a special empty image ID + is_bitmap: false, }; - self.entry.glyphs.insert(key, entry); return Some(entry); } + + // Use the appropriate content type and data format + let (image_data, content_type) = match self.scaled_image.content { + Content::Mask => { + // Alpha format: use data directly for R8 texture + ( + ImageData::Borrowed(&self.scaled_image.data), + super::ContentType::Mask, + ) + } + Content::Color => { + // Already RGBA format + ( + ImageData::Borrowed(&self.scaled_image.data), + super::ContentType::Color, + ) + } + Content::SubpixelMask => { + // Subpixel format (should not happen with Format::Alpha) + ( + ImageData::Borrowed(&self.scaled_image.data), + super::ContentType::Color, + ) + } + }; + + let req = AddImage { + width: w, + height: h, + has_alpha: true, + data: image_data, + content_type, + }; + let image = self.images.allocate(req)?; + + // let mut top = p.top; + // let mut height = h; + + // If dimension is None it means that we are running + // for the first time and in this case, we will obtain + // what the next glyph entries should respect in terms of + // top and height values + // + // e.g: Placement { left: 11, top: 42, width: 8, height: 50 } + // + // The calculation is made based on max_height + // If the rect max height is 50 and the glyph height is 68 + // and 48 top, then (68 - 50 = 18) height as difference and + // apply it to the top (bigger the top == up ^). + // if self.max_height > &0 && &h > self.max_height { + // let difference = h - self.max_height; + + // top -= difference as i32; + // height = *self.max_height; + // } + + let entry = GlyphEntry { + left: p.left, + top: p.top, + width: w, + height: h, + image, + is_bitmap: self.scaled_image.content == Content::Color, + }; + + self.entry.glyphs.insert(key, entry); + return Some(entry); } None diff --git a/sugarloaf/src/renderer/mod.rs b/sugarloaf/src/renderer/mod.rs index 266637087c..da70b31f1e 100644 --- a/sugarloaf/src/renderer/mod.rs +++ b/sugarloaf/src/renderer/mod.rs @@ -1532,20 +1532,43 @@ impl Renderer { } => { // Use cached glyph data but need to render glyphs.clear(); + // Cell centering for East-Asian-Wide codepoints: + // when a glyph's shaped slot is wider than one + // primary cell (char_width > 1), shift it by + // half the extra space so 겔 / 水 / 한 sit + // visually centered across their two cells + // instead of hugging the left cell. Formula: + // `dx = (cell_width - face_width) / 2`. No-op + // for char_width == 1 (Latin, box-drawing), + // so glyphs that rely on tiling at their + // natural pen advance stay aligned. + let cell_shift = if use_grid_cell_size && char_width > 1.0 { + cell_width * (char_width - 1.0) / 2.0 + } else { + 0.0 + }; for shaped_glyph in cached_glyphs.iter() { - let x = px; + let x = px + cell_shift; let y = baseline; - - if use_grid_cell_size { - px += cell_width * char_width; + // Effective per-glyph pen advance — on the + // grid-cell-size path this is `cell_width * + // char_width` (e.g. 2 cells for East Asian + // Wide emoji), not the shaper's advance. + // Pass this through to the compositor so + // emoji bitmaps center inside the actual + // cell slot, not a 1-cell shaper advance. + let advance = if use_grid_cell_size { + cell_width * char_width } else { - px += shaped_glyph.x_advance; - } + shaped_glyph.x_advance + }; + px += advance; glyphs.push(Glyph { id: shaped_glyph.glyph_id as GlyphId, x, y, + advance, }); } @@ -1607,26 +1630,45 @@ impl Renderer { glyphs.clear(); let mut shaped_glyphs = Vec::new(); + // Same cell centering as above. + let cell_shift = if use_grid_cell_size && char_width > 1.0 { + cell_width * (char_width - 1.0) / 2.0 + } else { + 0.0 + }; + for glyph in &run.glyphs { - let x = px; + let x = px + cell_shift; let y = baseline; - let advance = glyph.simple_data().1; - - if use_grid_cell_size { - px += cell_width * char_width; + let shaper_advance = glyph.simple_data().1; + // See the cached-path comment above — on the + // grid-cell-size path the effective advance + // is cell_width × char_width, not the shaper's. + let advance = if use_grid_cell_size { + cell_width * char_width } else { - px += advance; - } + shaper_advance + }; + px += advance; let glyph_id = glyph.simple_data().0; - glyphs.push(Glyph { id: glyph_id, x, y }); + glyphs.push(Glyph { + id: glyph_id, + x, + y, + advance, + }); - // Store for caching + // Cache the raw shaper advance; `use_grid_cell_size` + // is a property of the font/config pipeline and + // its grid-adjusted advance is recomputed on + // cache hits, so only the shaper value belongs + // in the persistent cache. shaped_glyphs.push( crate::font::text_run_cache::ShapedGlyph { glyph_id: glyph_id as u32, - x_advance: advance, + x_advance: shaper_advance, y_advance: 0.0, x_offset: 0.0, y_offset: 0.0, diff --git a/sugarloaf/src/renderer/text.rs b/sugarloaf/src/renderer/text.rs index 11d16e8ed2..d4bbac0f3c 100644 --- a/sugarloaf/src/renderer/text.rs +++ b/sugarloaf/src/renderer/text.rs @@ -71,4 +71,7 @@ pub struct Glyph { pub x: f32, /// Y offset of the glyph. pub y: f32, + /// Horizontal advance. Used by the compositor to fit bitmap glyphs + /// (emoji) into their per-glyph cell slot. + pub advance: f32, } diff --git a/sugarloaf/src/sugarloaf.rs b/sugarloaf/src/sugarloaf.rs index 8e48dd937b..877a0682b1 100644 --- a/sugarloaf/src/sugarloaf.rs +++ b/sugarloaf/src/sugarloaf.rs @@ -218,8 +218,8 @@ impl Sugarloaf<'_> { if let Some(cached) = self.font_cache.get(&(ch, attrs)) { return *cached; } - let font_ctx = self.state.content.font_library().inner.read(); - resolve_with(&mut self.font_cache, &font_ctx, ch, attrs) + let font_lib = self.state.content.font_library().clone(); + resolve_with(&mut self.font_cache, &font_lib, ch, attrs) } /// Horizontal advance in pixels for a single char rendered with @@ -290,10 +290,10 @@ impl Sugarloaf<'_> { if queries.is_empty() { return Vec::new(); } - let font_ctx = self.state.content.font_library().inner.read(); + let font_lib = self.state.content.font_library().clone(); let mut out = Vec::with_capacity(queries.len()); for &(ch, attrs) in queries { - out.push(resolve_with(&mut self.font_cache, &font_ctx, ch, attrs)); + out.push(resolve_with(&mut self.font_cache, &font_lib, ch, attrs)); } out }