max / shop
- Co-Authored-By
- Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10 files changed,
+765 insertions,
-16 deletions
| @@ -74,6 +74,15 @@ | |||
| 74 | 74 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 75 | 75 | checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" | |
| 76 | 76 | ||
| 77 | + | [[package]] | |
| 78 | + | name = "block2" | |
| 79 | + | version = "0.6.2" | |
| 80 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 81 | + | checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" | |
| 82 | + | dependencies = [ | |
| 83 | + | "objc2", | |
| 84 | + | ] | |
| 85 | + | ||
| 77 | 86 | [[package]] | |
| 78 | 87 | name = "bytemuck" | |
| 79 | 88 | version = "1.25.2" | |
| @@ -227,6 +236,15 @@ | |||
| 227 | 236 | "windows-sys", | |
| 228 | 237 | ] | |
| 229 | 238 | ||
| 239 | + | [[package]] | |
| 240 | + | name = "euclid" | |
| 241 | + | version = "0.22.14" | |
| 242 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 243 | + | checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" | |
| 244 | + | dependencies = [ | |
| 245 | + | "num-traits", | |
| 246 | + | ] | |
| 247 | + | ||
| 230 | 248 | [[package]] | |
| 231 | 249 | name = "find-msvc-tools" | |
| 232 | 250 | version = "0.1.9" | |
| @@ -239,6 +257,15 @@ | |||
| 239 | 257 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 240 | 258 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | |
| 241 | 259 | ||
| 260 | + | [[package]] | |
| 261 | + | name = "font-types" | |
| 262 | + | version = "0.12.1" | |
| 263 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 264 | + | checksum = "ad67eced03f5504d9cbd3a879b5958b5c54d4e5fd794361c6eb21b05fb703411" | |
| 265 | + | dependencies = [ | |
| 266 | + | "bytemuck", | |
| 267 | + | ] | |
| 268 | + | ||
| 242 | 269 | [[package]] | |
| 243 | 270 | name = "gpu-allocator" | |
| 244 | 271 | version = "0.28.0" | |
| @@ -252,6 +279,15 @@ | |||
| 252 | 279 | "thiserror", | |
| 253 | 280 | ] | |
| 254 | 281 | ||
| 282 | + | [[package]] | |
| 283 | + | name = "guillotiere" | |
| 284 | + | version = "0.7.0" | |
| 285 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 286 | + | checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" | |
| 287 | + | dependencies = [ | |
| 288 | + | "euclid", | |
| 289 | + | ] | |
| 290 | + | ||
| 255 | 291 | [[package]] | |
| 256 | 292 | name = "half" | |
| 257 | 293 | version = "2.7.1" | |
| @@ -460,6 +496,18 @@ | |||
| 460 | 496 | "objc2", | |
| 461 | 497 | ] | |
| 462 | 498 | ||
| 499 | + | [[package]] | |
| 500 | + | name = "objc2-core-graphics" | |
| 501 | + | version = "0.3.2" | |
| 502 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 503 | + | checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" | |
| 504 | + | dependencies = [ | |
| 505 | + | "dispatch2", | |
| 506 | + | "objc2", | |
| 507 | + | "objc2-core-foundation", | |
| 508 | + | "objc2-io-surface", | |
| 509 | + | ] | |
| 510 | + | ||
| 463 | 511 | [[package]] | |
| 464 | 512 | name = "objc2-encode" | |
| 465 | 513 | version = "4.1.0" | |
| @@ -477,6 +525,17 @@ | |||
| 477 | 525 | "objc2-core-foundation", | |
| 478 | 526 | ] | |
| 479 | 527 | ||
| 528 | + | [[package]] | |
| 529 | + | name = "objc2-io-surface" | |
| 530 | + | version = "0.3.2" | |
| 531 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 532 | + | checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" | |
| 533 | + | dependencies = [ | |
| 534 | + | "bitflags", | |
| 535 | + | "objc2", | |
| 536 | + | "objc2-core-foundation", | |
| 537 | + | ] | |
| 538 | + | ||
| 480 | 539 | [[package]] | |
| 481 | 540 | name = "objc2-metal" | |
| 482 | 541 | version = "0.3.2" | |
| @@ -484,6 +543,7 @@ | |||
| 484 | 543 | checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" | |
| 485 | 544 | dependencies = [ | |
| 486 | 545 | "bitflags", | |
| 546 | + | "block2", | |
| 487 | 547 | "objc2", | |
| 488 | 548 | "objc2-foundation", | |
| 489 | 549 | ] | |
| @@ -497,6 +557,7 @@ | |||
| 497 | 557 | "bitflags", | |
| 498 | 558 | "objc2", | |
| 499 | 559 | "objc2-core-foundation", | |
| 560 | + | "objc2-core-graphics", | |
| 500 | 561 | "objc2-foundation", | |
| 501 | 562 | "objc2-metal", | |
| 502 | 563 | ] | |
| @@ -643,6 +704,17 @@ | |||
| 643 | 704 | "objc2-quartz-core", | |
| 644 | 705 | ] | |
| 645 | 706 | ||
| 707 | + | [[package]] | |
| 708 | + | name = "read-fonts" | |
| 709 | + | version = "0.41.0" | |
| 710 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 711 | + | checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" | |
| 712 | + | dependencies = [ | |
| 713 | + | "bytemuck", | |
| 714 | + | "font-types", | |
| 715 | + | "once_cell", | |
| 716 | + | ] | |
| 717 | + | ||
| 646 | 718 | [[package]] | |
| 647 | 719 | name = "redox_syscall" | |
| 648 | 720 | version = "0.5.18" | |
| @@ -727,12 +799,25 @@ | |||
| 727 | 799 | dependencies = [ | |
| 728 | 800 | "anyhow", | |
| 729 | 801 | "pollster", | |
| 802 | + | "shop-render", | |
| 730 | 803 | "shop-wayland", | |
| 731 | 804 | "tracing", | |
| 732 | 805 | "tracing-subscriber", | |
| 733 | 806 | "wgpu", | |
| 734 | 807 | ] | |
| 735 | 808 | ||
| 809 | + | [[package]] | |
| 810 | + | name = "shop-render" | |
| 811 | + | version = "0.0.0" | |
| 812 | + | dependencies = [ | |
| 813 | + | "anyhow", | |
| 814 | + | "bytemuck", | |
| 815 | + | "guillotiere", | |
| 816 | + | "swash", | |
| 817 | + | "tracing", | |
| 818 | + | "wgpu", | |
| 819 | + | ] | |
| 820 | + | ||
| 736 | 821 | [[package]] | |
| 737 | 822 | name = "shop-wayland" | |
| 738 | 823 | version = "0.0.0" | |
| @@ -744,6 +829,16 @@ | |||
| 744 | 829 | "wayland-client", | |
| 745 | 830 | ] | |
| 746 | 831 | ||
| 832 | + | [[package]] | |
| 833 | + | name = "skrifa" | |
| 834 | + | version = "0.44.0" | |
| 835 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 836 | + | checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" | |
| 837 | + | dependencies = [ | |
| 838 | + | "bytemuck", | |
| 839 | + | "read-fonts", | |
| 840 | + | ] | |
| 841 | + | ||
| 747 | 842 | [[package]] | |
| 748 | 843 | name = "slab" | |
| 749 | 844 | version = "0.4.12" | |
| @@ -799,6 +894,17 @@ | |||
| 799 | 894 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 800 | 895 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" | |
| 801 | 896 | ||
| 897 | + | [[package]] | |
| 898 | + | name = "swash" | |
| 899 | + | version = "0.2.10" | |
| 900 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 901 | + | checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" | |
| 902 | + | dependencies = [ | |
| 903 | + | "skrifa", | |
| 904 | + | "yazi", | |
| 905 | + | "zeno", | |
| 906 | + | ] | |
| 907 | + | ||
| 802 | 908 | [[package]] | |
| 803 | 909 | name = "syn" | |
| 804 | 910 | version = "2.0.119" | |
| @@ -1101,12 +1207,22 @@ | |||
| 1101 | 1207 | "rustc-hash", | |
| 1102 | 1208 | "smallvec", | |
| 1103 | 1209 | "thiserror", | |
| 1210 | + | "wgpu-core-deps-apple", | |
| 1104 | 1211 | "wgpu-core-deps-windows-linux-android", | |
| 1105 | 1212 | "wgpu-hal", | |
| 1106 | 1213 | "wgpu-naga-bridge", | |
| 1107 | 1214 | "wgpu-types", | |
| 1108 | 1215 | ] | |
| 1109 | 1216 | ||
| 1217 | + | [[package]] | |
| 1218 | + | name = "wgpu-core-deps-apple" | |
| 1219 | + | version = "30.0.0" | |
| 1220 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1221 | + | checksum = "3283b6da70d7fc892de95840215aa36381b5d0cbc479e88ee2b173c7b992b225" | |
| 1222 | + | dependencies = [ | |
| 1223 | + | "wgpu-hal", | |
| 1224 | + | ] | |
| 1225 | + | ||
| 1110 | 1226 | [[package]] | |
| 1111 | 1227 | name = "wgpu-core-deps-windows-linux-android" | |
| 1112 | 1228 | version = "30.0.0" | |
| @@ -1126,6 +1242,7 @@ | |||
| 1126 | 1242 | "arrayvec", | |
| 1127 | 1243 | "ash", | |
| 1128 | 1244 | "bitflags", | |
| 1245 | + | "block2", | |
| 1129 | 1246 | "bytemuck", | |
| 1130 | 1247 | "cfg-if", | |
| 1131 | 1248 | "cfg_aliases", | |
| @@ -1136,6 +1253,12 @@ | |||
| 1136 | 1253 | "log", | |
| 1137 | 1254 | "naga", | |
| 1138 | 1255 | "naga-types", | |
| 1256 | + | "objc2", | |
| 1257 | + | "objc2-core-foundation", | |
| 1258 | + | "objc2-core-graphics", | |
| 1259 | + | "objc2-foundation", | |
| 1260 | + | "objc2-metal", | |
| 1261 | + | "objc2-quartz-core", | |
| 1139 | 1262 | "ordered-float", | |
| 1140 | 1263 | "parking_lot", | |
| 1141 | 1264 | "portable-atomic", | |
| @@ -1321,6 +1444,18 @@ | |||
| 1321 | 1444 | "bytemuck", | |
| 1322 | 1445 | ] | |
| 1323 | 1446 | ||
| 1447 | + | [[package]] | |
| 1448 | + | name = "yazi" | |
| 1449 | + | version = "0.2.1" | |
| 1450 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1451 | + | checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" | |
| 1452 | + | ||
| 1453 | + | [[package]] | |
| 1454 | + | name = "zeno" | |
| 1455 | + | version = "0.3.3" | |
| 1456 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1457 | + | checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" | |
| 1458 | + | ||
| 1324 | 1459 | [[package]] | |
| 1325 | 1460 | name = "zerocopy" | |
| 1326 | 1461 | version = "0.8.55" |
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | "crates/shop", | |
| 5 | 5 | "crates/kitty-graphics", | |
| 6 | 6 | "crates/shop-wayland", | |
| 7 | + | "crates/shop-render", | |
| 7 | 8 | ] | |
| 8 | 9 | ||
| 9 | 10 | [workspace.dependencies] | |
| @@ -11,12 +12,15 @@ | |||
| 11 | 12 | wayland-client = { version = "0.31", features = ["system"] } | |
| 12 | 13 | calloop = "0.14" | |
| 13 | 14 | calloop-wayland-source = "0.4" | |
| 14 | - | wgpu = { version = "30", default-features = false, features = ["wgsl", "vulkan"] } | |
| 15 | + | wgpu = { version = "30", default-features = false, features = ["wgsl", "vulkan", "metal"] } | |
| 15 | 16 | raw-window-handle = "0.6" | |
| 16 | 17 | pollster = "1" | |
| 17 | 18 | anyhow = "1" | |
| 18 | 19 | tracing = "0.1" | |
| 19 | 20 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } | |
| 21 | + | swash = "0.2" | |
| 22 | + | guillotiere = "0.7" | |
| 23 | + | bytemuck = { version = "1", features = ["derive"] } | |
| 20 | 24 | ||
| 21 | 25 | [workspace.package] | |
| 22 | 26 | edition = "2024" |
| @@ -17,6 +17,7 @@ | |||
| 17 | 17 | ||
| 18 | 18 | [dependencies] | |
| 19 | 19 | shop-wayland = { path = "../shop-wayland" } | |
| 20 | + | shop-render = { path = "../shop-render" } | |
| 20 | 21 | wgpu.workspace = true | |
| 21 | 22 | pollster.workspace = true | |
| 22 | 23 | anyhow.workspace = true |
| @@ -85,8 +85,7 @@ | |||
| 85 | 85 | let xdg_shell = XdgShell::bind(&globals, &qh)?; | |
| 86 | 86 | ||
| 87 | 87 | let surface = compositor.create_surface(&qh); | |
| 88 | - | let xdg_window = | |
| 89 | - | xdg_shell.create_window(surface, WindowDecorations::ServerDefault, &qh); | |
| 88 | + | let xdg_window = xdg_shell.create_window(surface, WindowDecorations::ServerDefault, &qh); | |
| 90 | 89 | xdg_window.set_title(spec.title.to_string()); | |
| 91 | 90 | xdg_window.set_app_id(spec.app_id.to_string()); | |
| 92 | 91 | xdg_window.set_min_size(Some(spec.min_size)); | |
| @@ -157,8 +156,7 @@ | |||
| 157 | 156 | ) { | |
| 158 | 157 | } | |
| 159 | 158 | ||
| 160 | - | fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) { | |
| 161 | - | } | |
| 159 | + | fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {} | |
| 162 | 160 | ||
| 163 | 161 | fn surface_enter( | |
| 164 | 162 | &mut self, |
| @@ -1,24 +1,29 @@ | |||
| 1 | 1 | //! shop — Wayland terminal emulator. | |
| 2 | 2 | //! | |
| 3 | - | //! First-pixel milestone: SCTK window + wgpu clear color driven by the | |
| 4 | - | //! shop-wayland event queue. No PTY, no grid, no text yet. | |
| 3 | + | //! Milestone: glyph on screen. Loads DejaVu Sans Mono, shapes a hardcoded | |
| 4 | + | //! string with swash (ligatures on), rasterizes into a guillotiere-managed | |
| 5 | + | //! wgpu atlas, draws it with an instanced text pipeline. | |
| 5 | 6 | //! | |
| 6 | - | //! Run on a Wayland compositor (Sway on fw13): | |
| 7 | + | //! Run on a Wayland compositor: | |
| 7 | 8 | //! ```text | |
| 8 | 9 | //! RUST_LOG=info cargo run -p shop | |
| 9 | 10 | //! ``` | |
| 10 | 11 | ||
| 12 | + | use shop_render::TextRenderer; | |
| 11 | 13 | use shop_wayland::{Pending, Window, WindowSpec}; | |
| 12 | 14 | use tracing::{info, warn}; | |
| 13 | 15 | ||
| 14 | 16 | const INITIAL: (u32, u32) = (960, 540); | |
| 17 | + | const FONT_PATH: &str = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"; | |
| 18 | + | const FONT_PX: f32 = 42.0; | |
| 19 | + | const HEADLINE: &str = "shop — first glyphs"; | |
| 15 | 20 | const CLEAR: wgpu::Color = wgpu::Color { | |
| 16 | - | // Alloy Akari palette — placeholder mid-value blue-grey until TOKENS is wired in. | |
| 17 | 21 | r: 0.043, | |
| 18 | 22 | g: 0.055, | |
| 19 | 23 | b: 0.086, | |
| 20 | 24 | a: 1.0, | |
| 21 | 25 | }; | |
| 26 | + | const TEXT_COLOR: [f32; 4] = [0.90, 0.92, 0.96, 1.0]; | |
| 22 | 27 | ||
| 23 | 28 | fn main() -> anyhow::Result<()> { | |
| 24 | 29 | tracing_subscriber::fmt() | |
| @@ -28,6 +33,9 @@ | |||
| 28 | 33 | ) | |
| 29 | 34 | .init(); | |
| 30 | 35 | ||
| 36 | + | let font_data = std::fs::read(FONT_PATH) | |
| 37 | + | .map_err(|e| anyhow::anyhow!("read font {FONT_PATH}: {e}"))?; | |
| 38 | + | ||
| 31 | 39 | let mut window = Window::new(WindowSpec { | |
| 32 | 40 | title: "shop", | |
| 33 | 41 | app_id: "dev.makecreative.shop", | |
| @@ -74,6 +82,8 @@ | |||
| 74 | 82 | present_mode: wgpu::PresentMode::Mailbox, | |
| 75 | 83 | }; | |
| 76 | 84 | ||
| 85 | + | let mut text = TextRenderer::new(&device, format, font_data, FONT_PX)?; | |
| 86 | + | ||
| 77 | 87 | info!("entering event loop"); | |
| 78 | 88 | while !window.should_exit() { | |
| 79 | 89 | for change in window.dispatch()? { | |
| @@ -82,13 +92,12 @@ | |||
| 82 | 92 | config.width = width; | |
| 83 | 93 | config.height = height; | |
| 84 | 94 | surface.configure(&device, &config); | |
| 85 | - | if let Err(e) = draw(&surface, &device, &queue) { | |
| 95 | + | text.resize(&queue, width, height); | |
| 96 | + | if let Err(e) = draw(&surface, &device, &queue, &mut text) { | |
| 86 | 97 | warn!("draw failed: {e:?}"); | |
| 87 | 98 | } | |
| 88 | 99 | } | |
| 89 | - | Pending::CloseRequested => { | |
| 90 | - | info!("close requested"); | |
| 91 | - | } | |
| 100 | + | Pending::CloseRequested => info!("close requested"), | |
| 92 | 101 | } | |
| 93 | 102 | } | |
| 94 | 103 | } | |
| @@ -98,7 +107,12 @@ | |||
| 98 | 107 | Ok(()) | |
| 99 | 108 | } | |
| 100 | 109 | ||
| 101 | - | fn draw(surface: &wgpu::Surface<'_>, device: &wgpu::Device, queue: &wgpu::Queue) -> anyhow::Result<()> { | |
| 110 | + | fn draw( | |
| 111 | + | surface: &wgpu::Surface<'_>, | |
| 112 | + | device: &wgpu::Device, | |
| 113 | + | queue: &wgpu::Queue, | |
| 114 | + | text: &mut TextRenderer, | |
| 115 | + | ) -> anyhow::Result<()> { | |
| 102 | 116 | let frame = match surface.get_current_texture() { | |
| 103 | 117 | wgpu::CurrentSurfaceTexture::Success(t) | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t, | |
| 104 | 118 | other => anyhow::bail!("surface acquire: {other:?}"), | |
| @@ -107,10 +121,10 @@ | |||
| 107 | 121 | .texture | |
| 108 | 122 | .create_view(&wgpu::TextureViewDescriptor::default()); | |
| 109 | 123 | let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { | |
| 110 | - | label: Some("shop.clear"), | |
| 124 | + | label: Some("shop.frame"), | |
| 111 | 125 | }); | |
| 112 | 126 | { | |
| 113 | - | let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { | |
| 127 | + | let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { | |
| 114 | 128 | label: Some("shop.clear"), | |
| 115 | 129 | color_attachments: &[Some(wgpu::RenderPassColorAttachment { | |
| 116 | 130 | view: &view, | |
| @@ -124,6 +138,7 @@ | |||
| 124 | 138 | ..Default::default() | |
| 125 | 139 | }); | |
| 126 | 140 | } | |
| 141 | + | text.draw(device, queue, &view, &mut encoder, HEADLINE, 48.0, 40.0, TEXT_COLOR); | |
| 127 | 142 | queue.submit(std::iter::once(encoder.finish())); | |
| 128 | 143 | queue.present(frame); | |
| 129 | 144 | Ok(()) |
| @@ -1,0 +1,21 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "shop-render" | |
| 3 | + | version = "0.0.0" | |
| 4 | + | description = "wgpu text/quad renderer for shop (font-agnostic; owns atlas + pipeline)" | |
| 5 | + | edition.workspace = true | |
| 6 | + | rust-version.workspace = true | |
| 7 | + | authors.workspace = true | |
| 8 | + | repository.workspace = true | |
| 9 | + | license.workspace = true | |
| 10 | + | publish = false | |
| 11 | + | ||
| 12 | + | [lints] | |
| 13 | + | workspace = true | |
| 14 | + | ||
| 15 | + | [dependencies] | |
| 16 | + | wgpu.workspace = true | |
| 17 | + | swash.workspace = true | |
| 18 | + | guillotiere.workspace = true | |
| 19 | + | bytemuck.workspace = true | |
| 20 | + | anyhow.workspace = true | |
| 21 | + | tracing.workspace = true |
| @@ -1,0 +1,97 @@ | |||
| 1 | + | //! Glyph atlas: guillotiere allocator over a single R8Unorm wgpu texture. | |
| 2 | + | ||
| 3 | + | use guillotiere::{AllocId, Allocation, AtlasAllocator, size2}; | |
| 4 | + | ||
| 5 | + | pub(crate) struct GlyphAtlas { | |
| 6 | + | pub(crate) texture: wgpu::Texture, | |
| 7 | + | pub(crate) view: wgpu::TextureView, | |
| 8 | + | pub(crate) size: u32, | |
| 9 | + | allocator: AtlasAllocator, | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | #[derive(Copy, Clone)] | |
| 13 | + | pub(crate) struct AtlasSlot { | |
| 14 | + | pub(crate) _id: AllocId, | |
| 15 | + | pub(crate) uv_min: [f32; 2], | |
| 16 | + | pub(crate) uv_max: [f32; 2], | |
| 17 | + | pub(crate) px: [u32; 2], | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | impl GlyphAtlas { | |
| 21 | + | pub(crate) fn new(device: &wgpu::Device, size: u32) -> Self { | |
| 22 | + | let texture = device.create_texture(&wgpu::TextureDescriptor { | |
| 23 | + | label: Some("shop.glyph_atlas"), | |
| 24 | + | size: wgpu::Extent3d { | |
| 25 | + | width: size, | |
| 26 | + | height: size, | |
| 27 | + | depth_or_array_layers: 1, | |
| 28 | + | }, | |
| 29 | + | mip_level_count: 1, | |
| 30 | + | sample_count: 1, | |
| 31 | + | dimension: wgpu::TextureDimension::D2, | |
| 32 | + | format: wgpu::TextureFormat::R8Unorm, | |
| 33 | + | usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, | |
| 34 | + | view_formats: &[], | |
| 35 | + | }); | |
| 36 | + | let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); | |
| 37 | + | let allocator = AtlasAllocator::new(size2(size as i32, size as i32)); | |
| 38 | + | Self { | |
| 39 | + | texture, | |
| 40 | + | view, | |
| 41 | + | size, | |
| 42 | + | allocator, | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Reserve a rectangle and upload the coverage bitmap. Returns `None` if | |
| 47 | + | /// the atlas is full (caller should evict or grow — MVP just skips glyph). | |
| 48 | + | pub(crate) fn upload( | |
| 49 | + | &mut self, | |
| 50 | + | queue: &wgpu::Queue, | |
| 51 | + | width: u32, | |
| 52 | + | height: u32, | |
| 53 | + | bitmap: &[u8], | |
| 54 | + | ) -> Option<AtlasSlot> { | |
| 55 | + | if width == 0 || height == 0 { | |
| 56 | + | return Some(AtlasSlot { | |
| 57 | + | _id: AllocId::deserialize(0), | |
| 58 | + | uv_min: [0.0, 0.0], | |
| 59 | + | uv_max: [0.0, 0.0], | |
| 60 | + | px: [0, 0], | |
| 61 | + | }); | |
| 62 | + | } | |
| 63 | + | let Allocation { id, rectangle } = self | |
| 64 | + | .allocator | |
| 65 | + | .allocate(size2(width as i32 + 1, height as i32 + 1))?; | |
| 66 | + | let x = rectangle.min.x as u32; | |
| 67 | + | let y = rectangle.min.y as u32; | |
| 68 | + | ||
| 69 | + | queue.write_texture( | |
| 70 | + | wgpu::TexelCopyTextureInfo { | |
| 71 | + | texture: &self.texture, | |
| 72 | + | mip_level: 0, | |
| 73 | + | origin: wgpu::Origin3d { x, y, z: 0 }, | |
| 74 | + | aspect: wgpu::TextureAspect::All, | |
| 75 | + | }, | |
| 76 | + | bitmap, | |
| 77 | + | wgpu::TexelCopyBufferLayout { | |
| 78 | + | offset: 0, | |
| 79 | + | bytes_per_row: Some(width), | |
| 80 | + | rows_per_image: Some(height), | |
| 81 | + | }, | |
| 82 | + | wgpu::Extent3d { | |
| 83 | + | width, | |
| 84 | + | height, | |
| 85 | + | depth_or_array_layers: 1, | |
| 86 | + | }, | |
| 87 | + | ); | |
| 88 | + | ||
| 89 | + | let s = self.size as f32; | |
| 90 | + | Some(AtlasSlot { | |
| 91 | + | _id: id, | |
| 92 | + | uv_min: [x as f32 / s, y as f32 / s], | |
| 93 | + | uv_max: [(x + width) as f32 / s, (y + height) as f32 / s], | |
| 94 | + | px: [width, height], | |
| 95 | + | }) | |
| 96 | + | } | |
| 97 | + | } |
| @@ -1,0 +1,15 @@ | |||
| 1 | + | //! wgpu text renderer for shop. | |
| 2 | + | //! | |
| 3 | + | //! Owns the glyph atlas (guillotiere-allocated R8Unorm wgpu texture) and the | |
| 4 | + | //! text pipeline (single draw call per frame batching all glyph quads). Uses | |
| 5 | + | //! [swash] for shaping and rasterization — GSUB/GPOS, so ligatures fall out | |
| 6 | + | //! naturally when the font supports them. | |
| 7 | + | //! | |
| 8 | + | //! MVP scope: one font, one size, one pass, no fallback. Multi-font fallback | |
| 9 | + | //! and cluster tracking arrive with the grid milestone. | |
| 10 | + | ||
| 11 | + | mod atlas; | |
| 12 | + | mod pipeline; | |
| 13 | + | mod shaper; | |
| 14 | + | ||
| 15 | + | pub use pipeline::TextRenderer; |
| @@ -1,0 +1,359 @@ | |||
| 1 | + | //! wgpu text pipeline: one instanced draw call per frame. | |
| 2 | + | ||
| 3 | + | use std::collections::HashMap; | |
| 4 | + | ||
| 5 | + | use bytemuck::{Pod, Zeroable}; | |
| 6 | + | use swash::GlyphId; | |
| 7 | + | use wgpu::util::DeviceExt; | |
| 8 | + | ||
| 9 | + | use crate::{ | |
| 10 | + | atlas::{AtlasSlot, GlyphAtlas}, | |
| 11 | + | shaper::Shaper, | |
| 12 | + | }; | |
| 13 | + | ||
| 14 | + | const ATLAS_SIZE: u32 = 1024; | |
| 15 | + | ||
| 16 | + | #[repr(C)] | |
| 17 | + | #[derive(Clone, Copy, Pod, Zeroable)] | |
| 18 | + | struct Instance { | |
| 19 | + | pos: [f32; 2], | |
| 20 | + | size: [f32; 2], | |
| 21 | + | uv_min: [f32; 2], | |
| 22 | + | uv_max: [f32; 2], | |
| 23 | + | color: [f32; 4], | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | #[repr(C)] | |
| 27 | + | #[derive(Clone, Copy, Pod, Zeroable)] | |
| 28 | + | struct Uniforms { | |
| 29 | + | screen: [f32; 2], | |
| 30 | + | _pad: [f32; 2], | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | const QUAD: [[f32; 2]; 4] = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]]; | |
| 34 | + | const INDICES: [u16; 6] = [0, 1, 2, 2, 1, 3]; | |
| 35 | + | ||
| 36 | + | const SHADER: &str = r#" | |
| 37 | + | struct Uniforms { | |
| 38 | + | screen: vec2<f32>, | |
| 39 | + | _pad: vec2<f32>, | |
| 40 | + | }; | |
| 41 | + | ||
| 42 | + | @group(0) @binding(0) var<uniform> u: Uniforms; | |
| 43 | + | @group(0) @binding(1) var atlas: texture_2d<f32>; | |
| 44 | + | @group(0) @binding(2) var samp: sampler; | |
| 45 | + | ||
| 46 | + | struct VOut { | |
| 47 | + | @builtin(position) pos: vec4<f32>, | |
| 48 | + | @location(0) uv: vec2<f32>, | |
| 49 | + | @location(1) color: vec4<f32>, | |
| 50 | + | }; | |
| 51 | + | ||
| 52 | + | @vertex | |
| 53 | + | fn vs( | |
| 54 | + | @location(0) corner: vec2<f32>, | |
| 55 | + | @location(1) inst_pos: vec2<f32>, | |
| 56 | + | @location(2) inst_size: vec2<f32>, | |
| 57 | + | @location(3) uv_min: vec2<f32>, | |
| 58 | + | @location(4) uv_max: vec2<f32>, | |
| 59 | + | @location(5) color: vec4<f32>, | |
| 60 | + | ) -> VOut { | |
| 61 | + | let px = inst_pos + corner * inst_size; | |
| 62 | + | let ndc = vec2<f32>( | |
| 63 | + | (px.x / u.screen.x) * 2.0 - 1.0, | |
| 64 | + | 1.0 - (px.y / u.screen.y) * 2.0, | |
| 65 | + | ); | |
| 66 | + | var out: VOut; | |
| 67 | + | out.pos = vec4<f32>(ndc, 0.0, 1.0); | |
| 68 | + | out.uv = mix(uv_min, uv_max, corner); | |
| 69 | + | out.color = color; | |
| 70 | + | return out; | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | @fragment | |
| 74 | + | fn fs(in: VOut) -> @location(0) vec4<f32> { | |
| 75 | + | let a = textureSample(atlas, samp, in.uv).r; | |
| 76 | + | return vec4<f32>(in.color.rgb, in.color.a * a); | |
| 77 | + | } | |
| 78 | + | "#; | |
| 79 | + | ||
| 80 | + | pub struct TextRenderer { | |
| 81 | + | pipeline: wgpu::RenderPipeline, | |
| 82 | + | bind_group: wgpu::BindGroup, | |
| 83 | + | uniforms_buf: wgpu::Buffer, | |
| 84 | + | quad_buf: wgpu::Buffer, | |
| 85 | + | index_buf: wgpu::Buffer, | |
| 86 | + | instance_buf: wgpu::Buffer, | |
| 87 | + | instance_cap: u64, | |
| 88 | + | atlas: GlyphAtlas, | |
| 89 | + | shaper: Shaper, | |
| 90 | + | cache: HashMap<GlyphId, Option<CachedGlyph>>, | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | #[derive(Clone, Copy)] | |
| 94 | + | struct CachedGlyph { | |
| 95 | + | slot: AtlasSlot, | |
| 96 | + | left: i32, | |
| 97 | + | top: i32, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | impl TextRenderer { | |
| 101 | + | pub fn new( | |
| 102 | + | device: &wgpu::Device, | |
| 103 | + | format: wgpu::TextureFormat, | |
| 104 | + | font_data: Vec<u8>, | |
| 105 | + | font_px: f32, | |
| 106 | + | ) -> anyhow::Result<Self> { | |
| 107 | + | let atlas = GlyphAtlas::new(device, ATLAS_SIZE); | |
| 108 | + | let shaper = Shaper::new(font_data, font_px)?; | |
| 109 | + | ||
| 110 | + | let uniforms_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { | |
| 111 | + | label: Some("shop.text.uniforms"), | |
| 112 | + | contents: bytemuck::bytes_of(&Uniforms { | |
| 113 | + | screen: [1.0, 1.0], | |
| 114 | + | _pad: [0.0, 0.0], | |
| 115 | + | }), | |
| 116 | + | usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, | |
| 117 | + | }); | |
| 118 | + | let quad_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { | |
| 119 | + | label: Some("shop.text.quad"), | |
| 120 | + | contents: bytemuck::cast_slice(&QUAD), | |
| 121 | + | usage: wgpu::BufferUsages::VERTEX, | |
| 122 | + | }); | |
| 123 | + | let index_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { | |
| 124 | + | label: Some("shop.text.index"), | |
| 125 | + | contents: bytemuck::cast_slice(&INDICES), | |
| 126 | + | usage: wgpu::BufferUsages::INDEX, | |
| 127 | + | }); | |
| 128 | + | let instance_cap: u64 = 256; | |
| 129 | + | let instance_buf = device.create_buffer(&wgpu::BufferDescriptor { | |
| 130 | + | label: Some("shop.text.instances"), | |
| 131 | + | size: instance_cap * std::mem::size_of::<Instance>() as u64, | |
| 132 | + | usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, | |
| 133 | + | mapped_at_creation: false, | |
| 134 | + | }); | |
| 135 | + | ||
| 136 | + | let sampler = device.create_sampler(&wgpu::SamplerDescriptor { | |
| 137 | + | label: Some("shop.text.sampler"), | |
| 138 | + | mag_filter: wgpu::FilterMode::Linear, | |
| 139 | + | min_filter: wgpu::FilterMode::Linear, | |
| 140 | + | ..Default::default() | |
| 141 | + | }); | |
| 142 | + | ||
| 143 | + | let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { | |
| 144 | + | label: Some("shop.text.bgl"), | |
| 145 | + | entries: &[ | |
| 146 | + | wgpu::BindGroupLayoutEntry { | |
| 147 | + | binding: 0, | |
| 148 | + | visibility: wgpu::ShaderStages::VERTEX, | |
| 149 | + | ty: wgpu::BindingType::Buffer { | |
| 150 | + | ty: wgpu::BufferBindingType::Uniform, | |
| 151 | + | has_dynamic_offset: false, | |
| 152 | + | min_binding_size: None, | |
| 153 | + | }, | |
| 154 | + | count: None, | |
| 155 | + | }, | |
| 156 | + | wgpu::BindGroupLayoutEntry { | |
| 157 | + | binding: 1, | |
| 158 | + | visibility: wgpu::ShaderStages::FRAGMENT, | |
| 159 | + | ty: wgpu::BindingType::Texture { | |
| 160 | + | sample_type: wgpu::TextureSampleType::Float { filterable: true }, | |
| 161 | + | view_dimension: wgpu::TextureViewDimension::D2, | |
| 162 | + | multisampled: false, | |
| 163 | + | }, | |
| 164 | + | count: None, | |
| 165 | + | }, | |
| 166 | + | wgpu::BindGroupLayoutEntry { | |
| 167 | + | binding: 2, | |
| 168 | + | visibility: wgpu::ShaderStages::FRAGMENT, | |
| 169 | + | ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), | |
| 170 | + | count: None, | |
| 171 | + | }, | |
| 172 | + | ], | |
| 173 | + | }); | |
| 174 | + | ||
| 175 | + | let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { | |
| 176 | + | label: Some("shop.text.bg"), | |
| 177 | + | layout: &bind_group_layout, | |
| 178 | + | entries: &[ | |
| 179 | + | wgpu::BindGroupEntry { | |
| 180 | + | binding: 0, | |
| 181 | + | resource: uniforms_buf.as_entire_binding(), | |
| 182 | + | }, | |
| 183 | + | wgpu::BindGroupEntry { | |
| 184 | + | binding: 1, | |
| 185 | + | resource: wgpu::BindingResource::TextureView(&atlas.view), | |
| 186 | + | }, | |
| 187 | + | wgpu::BindGroupEntry { | |
| 188 | + | binding: 2, | |
| 189 | + | resource: wgpu::BindingResource::Sampler(&sampler), | |
| 190 | + | }, | |
| 191 | + | ], | |
| 192 | + | }); | |
| 193 | + | ||
| 194 | + | let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { | |
| 195 | + | label: Some("shop.text.shader"), | |
| 196 | + | source: wgpu::ShaderSource::Wgsl(SHADER.into()), | |
| 197 | + | }); | |
| 198 | + | let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { | |
| 199 | + | label: Some("shop.text.pl"), | |
| 200 | + | bind_group_layouts: &[Some(&bind_group_layout)], | |
| 201 | + | immediate_size: 0, | |
| 202 | + | }); | |
| 203 | + | let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { | |
| 204 | + | label: Some("shop.text.pipeline"), | |
| 205 | + | layout: Some(&pipeline_layout), | |
| 206 | + | vertex: wgpu::VertexState { | |
| 207 | + | module: &module, | |
| 208 | + | entry_point: Some("vs"), | |
| 209 | + | compilation_options: Default::default(), | |
| 210 | + | buffers: &[ | |
| 211 | + | Some(wgpu::VertexBufferLayout { | |
| 212 | + | array_stride: 8, | |
| 213 | + | step_mode: wgpu::VertexStepMode::Vertex, | |
| 214 | + | attributes: &wgpu::vertex_attr_array![0 => Float32x2], | |
| 215 | + | }), | |
| 216 | + | Some(wgpu::VertexBufferLayout { | |
| 217 | + | array_stride: std::mem::size_of::<Instance>() as u64, | |
| 218 | + | step_mode: wgpu::VertexStepMode::Instance, | |
| 219 | + | attributes: &wgpu::vertex_attr_array![ | |
| 220 | + | 1 => Float32x2, | |
| 221 | + | 2 => Float32x2, | |
| 222 | + | 3 => Float32x2, | |
| 223 | + | 4 => Float32x2, | |
| 224 | + | 5 => Float32x4, | |
| 225 | + | ], | |
| 226 | + | }), | |
| 227 | + | ], | |
| 228 | + | }, | |
| 229 | + | fragment: Some(wgpu::FragmentState { | |
| 230 | + | module: &module, | |
| 231 | + | entry_point: Some("fs"), | |
| 232 | + | compilation_options: Default::default(), | |
| 233 | + | targets: &[Some(wgpu::ColorTargetState { | |
| 234 | + | format, | |
| 235 | + | blend: Some(wgpu::BlendState::ALPHA_BLENDING), | |
| 236 | + | write_mask: wgpu::ColorWrites::ALL, | |
| 237 | + | })], | |
| 238 | + | }), | |
| 239 | + | primitive: wgpu::PrimitiveState::default(), | |
| 240 | + | depth_stencil: None, | |
| 241 | + | multisample: wgpu::MultisampleState::default(), | |
| 242 | + | multiview_mask: None, | |
| 243 | + | cache: None, | |
| 244 | + | }); | |
| 245 | + | ||
| 246 | + | Ok(Self { | |
| 247 | + | pipeline, | |
| 248 | + | bind_group, | |
| 249 | + | uniforms_buf, | |
| 250 | + | quad_buf, | |
| 251 | + | index_buf, | |
| 252 | + | instance_buf, | |
| 253 | + | instance_cap, | |
| 254 | + | atlas, | |
| 255 | + | shaper, | |
| 256 | + | cache: HashMap::new(), | |
| 257 | + | }) | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | pub fn resize(&self, queue: &wgpu::Queue, width: u32, height: u32) { | |
| 261 | + | let u = Uniforms { | |
| 262 | + | screen: [width as f32, height as f32], | |
| 263 | + | _pad: [0.0, 0.0], | |
| 264 | + | }; | |
| 265 | + | queue.write_buffer(&self.uniforms_buf, 0, bytemuck::bytes_of(&u)); | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | /// Shape `text`, rasterize any missing glyphs, and draw the resulting | |
| 269 | + | /// quads. `x`/`y` is the pen origin in pixels (top-left convention, with | |
| 270 | + | /// the baseline placed at `y + ascent`). | |
| 271 | + | pub fn draw( | |
| 272 | + | &mut self, | |
| 273 | + | device: &wgpu::Device, | |
| 274 | + | queue: &wgpu::Queue, | |
| 275 | + | view: &wgpu::TextureView, | |
| 276 | + | encoder: &mut wgpu::CommandEncoder, | |
| 277 | + | text: &str, | |
| 278 | + | x: f32, | |
| 279 | + | y: f32, | |
| 280 | + | color: [f32; 4], | |
| 281 | + | ) { | |
| 282 | + | let ascent = self.shaper.ascent(); | |
| 283 | + | let baseline_y = y + ascent; | |
| 284 | + | let glyphs = self.shaper.shape(text); | |
| 285 | + | let mut pen_x = x; | |
| 286 | + | let mut instances: Vec<Instance> = Vec::with_capacity(glyphs.len()); | |
| 287 | + | ||
| 288 | + | for g in glyphs { | |
| 289 | + | let cached = match self.cache.get(&g.id).copied() { | |
| 290 | + | Some(v) => v, | |
| 291 | + | None => { | |
| 292 | + | let entry = self.shaper.rasterize(g.id).and_then(|r| { | |
| 293 | + | self.atlas | |
| 294 | + | .upload(queue, r.width, r.height, &r.bitmap) | |
| 295 | + | .map(|slot| CachedGlyph { | |
| 296 | + | slot, | |
| 297 | + | left: r.placement_left, | |
| 298 | + | top: r.placement_top, | |
| 299 | + | }) | |
| 300 | + | }); | |
| 301 | + | self.cache.insert(g.id, entry); | |
| 302 | + | entry | |
| 303 | + | } | |
| 304 | + | }; | |
| 305 | + | if let Some(c) = cached | |
| 306 | + | && c.slot.px[0] > 0 | |
| 307 | + | && c.slot.px[1] > 0 | |
| 308 | + | { | |
| 309 | + | let gx = pen_x + g.x_offset + c.left as f32; | |
| 310 | + | let gy = baseline_y - g.y_offset - c.top as f32; | |
| 311 | + | instances.push(Instance { | |
| 312 | + | pos: [gx, gy], | |
| 313 | + | size: [c.slot.px[0] as f32, c.slot.px[1] as f32], | |
| 314 | + | uv_min: c.slot.uv_min, | |
| 315 | + | uv_max: c.slot.uv_max, | |
| 316 | + | color, | |
| 317 | + | }); | |
| 318 | + | } | |
| 319 | + | pen_x += g.advance; | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | if instances.is_empty() { | |
| 323 | + | return; | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | let needed = instances.len() as u64; | |
| 327 | + | if needed > self.instance_cap { | |
| 328 | + | let new_cap = needed.next_power_of_two(); | |
| 329 | + | self.instance_buf = device.create_buffer(&wgpu::BufferDescriptor { | |
| 330 | + | label: Some("shop.text.instances"), | |
| 331 | + | size: new_cap * std::mem::size_of::<Instance>() as u64, | |
| 332 | + | usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, | |
| 333 | + | mapped_at_creation: false, | |
| 334 | + | }); | |
| 335 | + | self.instance_cap = new_cap; | |
| 336 | + | } | |
| 337 | + | queue.write_buffer(&self.instance_buf, 0, bytemuck::cast_slice(&instances)); | |
| 338 | + | ||
| 339 | + | let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { | |
| 340 | + | label: Some("shop.text.pass"), | |
| 341 | + | color_attachments: &[Some(wgpu::RenderPassColorAttachment { | |
| 342 | + | view, | |
| 343 | + | resolve_target: None, | |
| 344 | + | ops: wgpu::Operations { | |
| 345 | + | load: wgpu::LoadOp::Load, | |
| 346 | + | store: wgpu::StoreOp::Store, | |
| 347 | + | }, | |
| 348 | + | depth_slice: None, | |
| 349 | + | })], | |
| 350 | + | ..Default::default() | |
| 351 | + | }); | |
| 352 | + | pass.set_pipeline(&self.pipeline); | |
| 353 | + | pass.set_bind_group(0, &self.bind_group, &[]); | |
| 354 | + | pass.set_vertex_buffer(0, self.quad_buf.slice(..)); | |
| 355 | + | pass.set_vertex_buffer(1, self.instance_buf.slice(..)); | |
| 356 | + | pass.set_index_buffer(self.index_buf.slice(..), wgpu::IndexFormat::Uint16); | |
| 357 | + | pass.draw_indexed(0..6, 0, 0..instances.len() as u32); | |
| 358 | + | } | |
| 359 | + | } |
| @@ -1,0 +1,104 @@ | |||
| 1 | + | //! Swash wrapper: parse a font, shape a string, rasterize a glyph. | |
| 2 | + | ||
| 3 | + | use swash::{ | |
| 4 | + | FontRef, GlyphId, | |
| 5 | + | scale::{Render, ScaleContext, Source, image::Content}, | |
| 6 | + | shape::ShapeContext, | |
| 7 | + | zeno::Format, | |
| 8 | + | }; | |
| 9 | + | ||
| 10 | + | pub(crate) struct Shaper { | |
| 11 | + | font_data: Vec<u8>, | |
| 12 | + | font_offset: u32, | |
| 13 | + | scale_ctx: ScaleContext, | |
| 14 | + | shape_ctx: ShapeContext, | |
| 15 | + | px: f32, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | pub(crate) struct ShapedGlyph { | |
| 19 | + | pub(crate) id: GlyphId, | |
| 20 | + | /// Pen advance in pixels after this glyph. | |
| 21 | + | pub(crate) advance: f32, | |
| 22 | + | /// Position offsets relative to the pen origin. | |
| 23 | + | pub(crate) x_offset: f32, | |
| 24 | + | pub(crate) y_offset: f32, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | pub(crate) struct Raster { | |
| 28 | + | pub(crate) bitmap: Vec<u8>, | |
| 29 | + | pub(crate) width: u32, | |
| 30 | + | pub(crate) height: u32, | |
| 31 | + | /// Left edge relative to the pen origin (positive = right of origin). | |
| 32 | + | pub(crate) placement_left: i32, | |
| 33 | + | /// Top edge relative to the baseline (positive = above baseline). | |
| 34 | + | pub(crate) placement_top: i32, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | impl Shaper { | |
| 38 | + | pub(crate) fn new(font_data: Vec<u8>, px: f32) -> anyhow::Result<Self> { | |
| 39 | + | let font = FontRef::from_index(&font_data, 0) | |
| 40 | + | .ok_or_else(|| anyhow::anyhow!("swash: not a font"))?; | |
| 41 | + | let font_offset = font.offset; | |
| 42 | + | Ok(Self { | |
| 43 | + | font_data, | |
| 44 | + | font_offset, | |
| 45 | + | scale_ctx: ScaleContext::new(), | |
| 46 | + | shape_ctx: ShapeContext::new(), | |
| 47 | + | px, | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | pub(crate) fn shape(&mut self, text: &str) -> Vec<ShapedGlyph> { | |
| 52 | + | let font = FontRef { | |
| 53 | + | data: &self.font_data, | |
| 54 | + | offset: self.font_offset, | |
| 55 | + | key: swash::CacheKey::new(), | |
| 56 | + | }; | |
| 57 | + | let mut shaper = self.shape_ctx.builder(font).size(self.px).build(); | |
| 58 | + | shaper.add_str(text); | |
| 59 | + | let mut out = Vec::new(); | |
| 60 | + | shaper.shape_with(|cluster| { | |
| 61 | + | for g in cluster.glyphs { | |
| 62 | + | out.push(ShapedGlyph { | |
| 63 | + | id: g.id, | |
| 64 | + | advance: g.advance, | |
| 65 | + | x_offset: g.x, | |
| 66 | + | y_offset: g.y, | |
| 67 | + | }); | |
| 68 | + | } | |
| 69 | + | }); | |
| 70 | + | out | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | pub(crate) fn rasterize(&mut self, id: GlyphId) -> Option<Raster> { | |
| 74 | + | let font = FontRef { | |
| 75 | + | data: &self.font_data, | |
| 76 | + | offset: self.font_offset, | |
| 77 | + | key: swash::CacheKey::new(), | |
| 78 | + | }; | |
| 79 | + | let mut scaler = self.scale_ctx.builder(font).size(self.px).hint(true).build(); | |
| 80 | + | let image = Render::new(&[Source::Outline]) | |
| 81 | + | .format(Format::Alpha) | |
| 82 | + | .render(&mut scaler, id)?; | |
| 83 | + | if image.content != Content::Mask { | |
| 84 | + | return None; | |
| 85 | + | } | |
| 86 | + | Some(Raster { | |
| 87 | + | bitmap: image.data, | |
| 88 | + | width: image.placement.width, | |
| 89 | + | height: image.placement.height, | |
| 90 | + | placement_left: image.placement.left, | |
| 91 | + | placement_top: image.placement.top, | |
| 92 | + | }) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | pub(crate) fn ascent(&self) -> f32 { | |
| 96 | + | let font = FontRef { | |
| 97 | + | data: &self.font_data, | |
| 98 | + | offset: self.font_offset, | |
| 99 | + | key: swash::CacheKey::new(), | |
| 100 | + | }; | |
| 101 | + | let metrics = font.metrics(&[]).scale(self.px); | |
| 102 | + | metrics.ascent | |
| 103 | + | } | |
| 104 | + | } |